What i learnt testing
Published:
What i learnt testing in Ruby: RSpec, Shoulda Matchers, SimpleCov, and Beyond
Welcome to our in-depth exploration of testing in Ruby! Whether you’re a seasoned developer or just embarking on your Ruby journey, understanding how to write effective tests is crucial for building robust, maintainable, and scalable applications. In this comprehensive guide, we’ll delve into essential testing tools like RSpec
, Shoulda Matchers
, and SimpleCov
, and explore best practices and advanced techniques to elevate your testing strategy.
Table of Contents
- Why Testing Matters
- Getting Started with RSpec
- Advanced RSpec Features
- Simplifying Tests with Shoulda Matchers
- Measuring Test Coverage with SimpleCov
- Enhancing Tests with FactoryBot and Faker
- Testing Different Layers of a Rails Application
- Mocking and Stubbing
- Continuous Integration and Testing
- Best Practices for Writing Good Tests
- Common Pitfalls and How to Avoid Them
- Conclusion
Why Testing Matters
Testing is more than just a safety net to catch bugs; it’s a fundamental aspect of software development that ensures your application behaves as expected. Here are some key reasons why testing is indispensable:
Reliability: Tests help verify that your code works correctly, reducing the chances of unexpected behavior in production.
Maintainability: Well-written tests make it easier to refactor and extend your codebase with confidence.
Documentation: Tests serve as living documentation, illustrating how different parts of your application are supposed to function.
Collaboration: In team environments, tests provide a clear contract for how components should interact, facilitating smoother collaboration.
Getting Started with RSpec
RSpec is the de facto testing framework for Ruby applications, offering a rich syntax and a plethora of features that make writing tests intuitive and enjoyable.
Installation
To get started with RSpec, add it to your project’s Gemfile:
group :development, :test do
gem 'rspec-rails', '~> 5.0'
end
Then, install the gem and initialize RSpec:
bundle install
rails generate rspec:install
This command sets up the necessary configuration files and directories for RSpec.
Writing Your First Spec
Let’s create a simple model spec for a User
model.
User Model
# app/models/user.rb
class User < ApplicationRecord
validates :name, presence: true
validates :email, presence: true, uniqueness: true
end
User Spec
# spec/models/user_spec.rb
require 'rails_helper'
RSpec.describe User, type: :model do
it 'is valid with a name and email' do
user = User.new(name: 'Alice', email: 'alice@example.com')
expect(user).to be_valid
end
it 'is invalid without a name' do
user = User.new(name: nil, email: 'alice@example.com')
expect(user).not_to be_valid
expect(user.errors[:name]).to include("can't be blank")
end
it 'is invalid without an email' do
user = User.new(name: 'Alice', email: nil)
expect(user).not_to be_valid
expect(user.errors[:email]).to include("can't be blank")
end
it 'is invalid with a duplicate email' do
User.create!(name: 'Bob', email: 'bob@example.com')
user = User.new(name: 'Alice', email: 'bob@example.com')
expect(user).not_to be_valid
expect(user.errors[:email]).to include('has already been taken')
end
end
Running Your Tests
Execute your tests with:
bundle exec rspec
RSpec will run all specs and provide a detailed summary of passing and failing tests.
Advanced RSpec Features
While the basics of RSpec are straightforward, the framework offers advanced features that can significantly enhance your testing capabilities.
Contexts and Describes
Use describe
and context
blocks to organize your tests logically.
RSpec.describe User, type: :model do
describe 'validations' do
context 'when all attributes are present' do
it 'is valid' do
# test code
end
end
context 'when name is missing' do
it 'is invalid' do
# test code
end
end
end
end
Hooks: Before, After, and Around
RSpec provides hooks to run code before, after, or around your tests.
RSpec.describe User, type: :model do
before(:each) do
@user = User.new(name: 'Alice', email: 'alice@example.com')
end
after(:each) do
# Cleanup code if necessary
end
it 'is valid with valid attributes' do
expect(@user).to be_valid
end
it 'is invalid without a name' do
@user.name = nil
expect(@user).not_to be_valid
end
end
Shared Examples and Shared Contexts
DRY (Don’t Repeat Yourself) up your tests by using shared examples and contexts.
Shared Examples
# spec/support/shared_examples/user_validations.rb
RSpec.shared_examples 'a valid user' do
it 'is valid with valid attributes' do
expect(subject).to be_valid
end
end
Using Shared Examples
RSpec.describe User, type: :model do
subject { User.new(name: 'Alice', email: 'alice@example.com') }
it_behaves_like 'a valid user'
it 'is invalid without a name' do
subject.name = nil
expect(subject).not_to be_valid
end
end
Custom Matchers
Create custom matchers to encapsulate complex expectations.
# spec/support/matchers/have_valid_email.rb
RSpec::Matchers.define :have_valid_email do
match do |user|
user.email =~ /A[^@s]+@[^@s]+z/
end
failure_message do |user|
"expected that #{user.email} is a valid email"
end
end
Using Custom Matchers
RSpec.describe User, type: :model do
it 'has a valid email format' do
user = User.new(name: 'Alice', email: 'alice@example.com')
expect(user).to have_valid_email
end
end
Simplifying Tests with Shoulda Matchers
While RSpec is powerful, writing repetitive boilerplate code for common validations and associations can be tedious. Shoulda Matchers
simplifies this process by providing concise one-liners for common tests.
Installation
Add shoulda-matchers
to your Gemfile:
group :test do
gem 'shoulda-matchers', '~> 5.0'
end
Then, configure it in rails_helper.rb
:
# spec/rails_helper.rb
Shoulda::Matchers.configure do |config|
config.integrate do |with|
with.test_framework :rspec
with.library :rails
end
end
Using Shoulda Matchers
Refactor your User
model spec with Shoulda Matchers:
RSpec.describe User, type: :model do
it { should validate_presence_of(:name) }
it { should validate_presence_of(:email) }
it { should validate_uniqueness_of(:email) }
end
Testing Associations
Shoulda Matchers also simplifies testing associations.
# app/models/post.rb
class Post < ApplicationRecord
belongs_to :user
has_many :comments
end
# spec/models/post_spec.rb
RSpec.describe Post, type: :model do
it { should belong_to(:user) }
it { should have_many(:comments) }
end
Validating Lengths and Formats
You can also test attribute lengths and formats easily.
RSpec.describe User, type: :model do
it { should validate_length_of(:name).is_at_least(3) }
it { should allow_value('user@example.com').for(:email) }
it { should_not allow_value('useratexample.com').for(:email) }
end
Measuring Test Coverage with SimpleCov
Understanding how much of your code is tested is vital for maintaining code quality. SimpleCov
provides a neat way to visualize your test coverage.
Installation
Add simplecov
to your Gemfile:
group :test do
gem 'simplecov', require: false
end
Then, initialize SimpleCov at the very top of your spec_helper.rb
or rails_helper.rb
:
# spec/rails_helper.rb
require 'simplecov'
SimpleCov.start 'rails'
Configuring SimpleCov
You can customize SimpleCov’s configuration to suit your project’s needs.
# spec/rails_helper.rb
require 'simplecov'
SimpleCov.start 'rails' do
add_filter '/bin/'
add_filter '/db/'
add_filter '/spec/'
add_group 'Models', 'app/models'
add_group 'Controllers', 'app/controllers'
add_group 'Helpers', 'app/helpers'
end
Viewing Coverage Reports
After running your tests, SimpleCov generates a coverage report in the coverage/
directory. Open coverage/index.html
in your browser to see a detailed breakdown of covered and uncovered code.
Enforcing Coverage Thresholds
Ensure your project maintains a minimum coverage percentage by configuring SimpleCov to fail the test suite if the coverage is too low.
# spec/rails_helper.rb
SimpleCov.start 'rails' do
minimum_coverage 90
end
If the coverage drops below 90%, the test suite will fail, prompting you to write additional tests.
Enhancing Tests with FactoryBot and Faker
Creating test data is a common task in testing. FactoryBot
and Faker
streamline this process by providing factories and realistic dummy data.
Installation
Add the gems to your Gemfile:
group :test do
gem 'factory_bot_rails'
gem 'faker'
end
Then, run:
bundle install
Configuring FactoryBot
Include FactoryBot methods in your RSpec configuration.
# spec/rails_helper.rb
RSpec.configure do |config|
config.include FactoryBot::Syntax::Methods
end
Creating Factories
Define factories for your models.
# spec/factories/users.rb
FactoryBot.define do
factory :user do
name { Faker::Name.name }
email { Faker::Internet.unique.email }
end
end
# spec/factories/posts.rb
FactoryBot.define do
factory :post do
title { Faker::Lorem.sentence }
content { Faker::Lorem.paragraph }
association :user
end
end
Using Factories in Tests
Create test data effortlessly in your specs.
RSpec.describe Post, type: :model do
it 'is valid with valid attributes' do
post = build(:post)
expect(post).to be_valid
end
it 'is invalid without a title' do
post = build(:post, title: nil)
expect(post).not_to be_valid
end
end
Testing Different Layers of a Rails Application
Testing isn’t limited to models. It’s essential to test controllers, views, helpers, and even the integration of different components.
Controller Specs
RSpec allows you to test the behavior of your controllers.
# spec/controllers/users_controller_spec.rb
require 'rails_helper'
RSpec.describe UsersController, type: :controller do
describe 'GET #index' do
it 'returns a success response' do
get :index
expect(response).to be_successful
end
end
describe 'POST #create' do
context 'with valid parameters' do
let(:valid_attributes) { { name: 'Alice', email: 'alice@example.com' } }
it 'creates a new User' do
expect {
post :create, params: { user: valid_attributes }
}.to change(User, :count).by(1)
end
it 'redirects to the created user' do
post :create, params: { user: valid_attributes }
expect(response).to redirect_to(User.last)
end
end
context 'with invalid parameters' do
let(:invalid_attributes) { { name: nil, email: 'invalid_email' } }
it 'does not create a new User' do
expect {
post :create, params: { user: invalid_attributes }
}.not_to change(User, :count)
end
it 'renders the new template' do
post :create, params: { user: invalid_attributes }
expect(response).to render_template(:new)
end
end
end
end
Request Specs
Request specs are higher-level tests that simulate HTTP requests and test the integration of different parts of your application.
# spec/requests/users_spec.rb
require 'rails_helper'
RSpec.describe 'Users', type: :request do
describe 'GET /users' do
it 'renders the index template' do
get users_path
expect(response).to render_template(:index)
end
end
describe 'POST /users' do
context 'with valid parameters' do
let(:valid_attributes) { { name: 'Alice', email: 'alice@example.com' } }
it 'creates a new user' do
expect {
post users_path, params: { user: valid_attributes }
}.to change(User, :count).by(1)
end
it 'redirects to the user show page' do
post users_path, params: { user: valid_attributes }
expect(response).to redirect_to(user_path(User.last))
end
end
context 'with invalid parameters' do
let(:invalid_attributes) { { name: '', email: 'invalid' } }
it 'does not create a new user' do
expect {
post users_path, params: { user: invalid_attributes }
}.not_to change(User, :count)
end
it 'renders the new template again' do
post users_path, params: { user: invalid_attributes }
expect(response).to render_template(:new)
end
end
end
end
Feature (System) Specs
Feature specs, also known as system tests, simulate user interactions with your application.
# spec/features/user_sign_up_spec.rb
require 'rails_helper'
RSpec.feature 'User Sign Up', type: :feature do
scenario 'User signs up with valid details' do
visit new_user_registration_path
fill_in 'Name', with: 'Alice'
fill_in 'Email', with: 'alice@example.com'
fill_in 'Password', with: 'password123'
fill_in 'Password confirmation', with: 'password123'
click_button 'Sign up'
expect(page).to have_content('Welcome! You have signed up successfully.')
end
scenario 'User signs up with invalid details' do
visit new_user_registration_path
fill_in 'Name', with: ''
fill_in 'Email', with: 'invalid_email'
fill_in 'Password', with: 'pass'
fill_in 'Password confirmation', with: 'word'
click_button 'Sign up'
expect(page).to have_content("Name can't be blank")
expect(page).to have_content('Email is invalid')
expect(page).to have_content('Password is too short')
expect(page).to have_content("Password confirmation doesn't match Password")
end
end
Mocking and Stubbing
Testing often requires isolating the unit of code being tested from its dependencies. Mocking and stubbing are techniques to achieve this isolation.
Using allow
and receive
RSpec provides methods like allow
and receive
to stub methods on objects.
RSpec.describe User, type: :model do
describe '#send_welcome_email' do
it 'sends an email to the user' do
user = build(:user)
mailer = double('UserMailer')
allow(UserMailer).to receive(:welcome_email).with(user).and_return(mailer)
allow(mailer).to receive(:deliver_now)
user.send_welcome_email
expect(UserMailer).to have_received(:welcome_email).with(user)
expect(mailer).to have_received(:deliver_now)
end
end
end
Mocking External Services
When your application interacts with external APIs, it’s crucial to mock these interactions in tests to avoid dependencies on external systems.
Consider using gems like webmock
or vcr
to handle HTTP requests.
Example with WebMock
# Gemfile
group :test do
gem 'webmock'
end
# spec/rails_helper.rb
require 'webmock/rspec'
WebMock.disable_net_connect!(allow_localhost: true)
# spec/services/external_api_service_spec.rb
require 'rails_helper'
RSpec.describe ExternalApiService do
it 'fetches data from the external API' do
stub_request(:get, "https://api.example.com/data")
.to_return(status: 200, body: '{"key":"value"}', headers: {})
service = ExternalApiService.new
response = service.fetch_data
expect(response).to eq({"key" => "value"})
expect(a_request(:get, "https://api.example.com/data")).to have_been_made.once
end
end
Continuous Integration and Testing
Integrating your test suite with Continuous Integration (CI) ensures that your tests run automatically on each commit, maintaining code quality and preventing regressions.
Popular CI Services
- GitHub Actions: Offers seamless integration with GitHub repositories.
- Travis CI: A popular CI service with support for multiple languages.
- CircleCI: Known for its speed and scalability.
- GitLab CI: Integrated with GitLab repositories.
Setting Up GitHub Actions for Ruby Testing
Here’s how to set up a basic GitHub Actions workflow for a Ruby on Rails project.
Create Workflow File
Create a file at .github/workflows/ci.yml
with the following content:
name: Ruby on Rails CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:13
ports: ['5432:5432']
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: test_db
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v3
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.1'
- name: Install dependencies
run: |
gem install bundler
bundle install --jobs 4 --retry 3
- name: Set up Database
env:
RAILS_ENV: test
run: |
cp config/database.yml.github-actions config/database.yml
bundle exec rails db:create
bundle exec rails db:schema:load
- name: Run Tests
env:
RAILS_ENV: test
run: bundle exec rspec
Configuring Database for CI
Create a separate database configuration for GitHub Actions.
# config/database.yml.github-actions
test:
adapter: postgresql
encoding: unicode
database: test_db
pool: 5
username: postgres
password: postgres
host: localhost
Benefits of CI Integration
- Automated Testing: Ensures that tests run automatically on every commit.
- Early Detection: Catches issues early in the development process.
- Consistent Environment: Tests run in a consistent environment, reducing “it works on my machine” issues.
- Enhanced Collaboration: Facilitates better collaboration by providing immediate feedback on code changes.
Best Practices for Writing Good Tests
Effective testing goes beyond using the right tools. Adhering to best practices ensures that your tests are reliable, maintainable, and valuable.
1. Write Clear and Descriptive Tests
Your tests should clearly describe what they’re testing. Use descriptive names for your examples and contexts.
RSpec.describe Order, type: :model do
describe '#total_price' do
context 'when the order has multiple items' do
it 'calculates the correct total price' do
# test code
end
end
end
end
2. Keep Tests Isolated
Ensure each test is independent. Avoid dependencies between tests to prevent flaky results and make debugging easier.
- Use Factories: Utilize FactoryBot to create necessary data within each test.
- Avoid Shared State: Refrain from relying on data or state set up in other tests.
3. Use Factories Instead of Fixtures
Fixtures can become cumbersome and less flexible compared to factories. FactoryBot
allows you to create test data dynamically, making your tests more adaptable.
4. Test Behavior, Not Implementation
Focus on what your code does, not how it does it. This approach makes your tests more resilient to changes in implementation details.
5. Keep Tests Fast
Slow tests can hinder development speed. Strive to keep your test suite fast by:
- Avoiding Unnecessary Tests: Only test what is essential.
- Using Transactional Fixtures: Roll back database changes after each test.
- Parallelizing Tests: Run tests in parallel where possible.
6. Use Continuous Refactoring
Just like production code, your tests benefit from regular refactoring. Keep them clean, DRY, and well-organized to maintain readability and efficiency.
7. Leverage Test Coverage Tools
Use tools like SimpleCov
to monitor your test coverage, ensuring that critical parts of your application are tested.
8. Handle External Dependencies Gracefully
Mock or stub external services and APIs to ensure that your tests remain fast and reliable, regardless of external system availability.
9. Write Tests Before Code (TDD)
Adopting Test-Driven Development (TDD) can lead to better-designed, more maintainable code by encouraging you to think about requirements before implementation.
10. Review and Maintain Tests
Regularly review your test suite to remove outdated tests, update existing ones, and add new tests as your application evolves.
Common Pitfalls and How to Avoid Them
Even with the best intentions, developers can fall into common testing pitfalls. Being aware of these can help you avoid them.
1. Over-Mocking
Mocking too many dependencies can lead to tests that are brittle and tightly coupled to implementation details.
- Solution: Mock only external services and dependencies, not internal methods or objects.
2. Ignoring Test Failures
Not addressing failing tests promptly can lead to a false sense of security.
- Solution: Treat failing tests as high-priority issues and resolve them immediately.
3. Writing Incomplete Tests
Tests that do not cover all possible scenarios can miss critical bugs.
- Solution: Strive for comprehensive coverage, including edge cases and error conditions.
4. Flaky Tests
Tests that intermittently fail can erode trust in the test suite.
- Solution: Identify and fix the causes of flakiness, such as reliance on external services without proper stubbing.
5. Large, Monolithic Tests
Tests that are too large can be hard to understand and maintain.
- Solution: Break down tests into smaller, focused examples that test specific behaviors.
6. Poor Test Performance
Slow tests can discourage frequent testing and slow down the development process.
- Solution: Optimize test performance by avoiding unnecessary setup, using factories efficiently, and leveraging parallel testing.
Conclusion
Testing is an integral part of Ruby development, ensuring that your applications are robust, maintainable, and free from critical bugs. Tools like RSpec
, Shoulda Matchers
, and SimpleCov
provide a solid foundation for building a comprehensive test suite. By adhering to best practices and leveraging advanced testing techniques, you can enhance the quality of your code and streamline your development workflow.
Remember, the goal of testing is not just to find bugs but to design better software. Embrace testing as a valuable tool in your development arsenal, and you’ll reap the benefits of cleaner code, faster iterations, and greater confidence in your applications.
Happy testing!