What i learnt testing

17 minute read

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

  1. Why Testing Matters
  2. Getting Started with RSpec
  3. Advanced RSpec Features
  4. Simplifying Tests with Shoulda Matchers
  5. Measuring Test Coverage with SimpleCov
  6. Enhancing Tests with FactoryBot and Faker
  7. Testing Different Layers of a Rails Application
  8. Mocking and Stubbing
  9. Continuous Integration and Testing
  10. Best Practices for Writing Good Tests
  11. Common Pitfalls and How to Avoid Them
  12. 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.

  • 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!