What is it?

Even Better Specs is an opinionated set of best practices to support the creation of tests that are easy to read and maintain.

It focuses on the Ruby testing framework RSpec, but some of it could potentially be applied to other frameworks and languages (like sus and Crystal).

Guiding principles

Describe what you are testing

Be clear about what you are testing.

describe 'User' do
  describe 'the authenticate method for User' do
  end

  describe 'if the user is an admin' do
  end
end

Use the Ruby documentation convention of . when referring to a class method's name and # when referring to an instance method's name.

describe User do
  describe '.authenticate' do
  end

  describe '#admin?' do
  end
end

In request tests, describe the controller constant and its actions.

describe UsersController, type: :request do
  describe '#index' do
    it 'returns a successful response' do
      get users_url

      expect(response.code).to eq('200')
    end
  end

  describe '#create' do
    it 'creates a user' do
      post users_url, params: { user: { name: 'Tom Jobim' } }

      expect(User.last.name).to eq('Tom Jobim')
    end
  end
end

RuboCop

You can enforce this guideline using the cop RSpec/DescribeClass from the rubocop-rspec gem.

Discuss this guideline

Use contexts

Contexts are a powerful way to make your tests clear and well organized. They should start with when.

it 'has 200 status code if logged in' do
  expect(response.code).to eq('200')
end

describe 'it returns 401 status code if not logged in' do
  it { expect(response.code).to eq('401') }
end
context 'when logged in' do
  it 'returns 200 status code' do
    expect(response.code).to eq('200')
  end
end

context 'when logged out' do
  it 'returns 401 status code' do
    expect(response.code).to eq('401')
  end
end

RuboCop

You can enforce this guideline using the cops RSpec/ContextMethod and RSpec/ContextWording from the rubocop-rspec gem.

Discuss this guideline

Factories, not fixtures

Factories are more flexible and easier to work with. Understand more here.

def full_name
  "#{first_name} #{last_name}"
end
it 'returns the full name' do
  user = create(:user, first_name: 'Santos', last_name: 'Dumont')

  expect(user.full_name).to eq('Santos Dumont')
end

If your code doesn't hit the database, prefer build_stubbed over create since the former will make your tests faster đŸ”„

it 'returns the full name' do
  user = build_stubbed(:user, first_name: 'Santos', last_name: 'Dumont')

  expect(user.full_name).to eq('Santos Dumont')
end
Discuss this guideline

Leverage described class

Tests are not supposed to be DRY, but that doesn't mean we need to repeat ourselves in vain. Leverage described_class to make your tests maintainable over the years.

describe Pilot do
  describe '.most_successful' do
    it 'returns the most successful pilot' do
      senna = create(:pilot, name: 'Ayrton Senna')
      create(:pilot, name: 'Alain Prost')
      create(:race, winner: senna)

      most_successful_pilot = Pilot.most_successful

      expect(most_successful_pilot.name).to eq('Ayrton Senna')
    end
  end
end
describe Pilot do
  describe '.most_successful' do
    it 'returns the most successful pilot' do
      senna = create(:pilot, name: 'Ayrton Senna')
      create(:pilot, name: 'Alain Prost')
      create(:race, winner: senna)

      most_successful_pilot = described_class.most_successful

      expect(most_successful_pilot.name).to eq('Ayrton Senna')
    end
  end
end

If the class Pilot ever gets renamed, one just need to change it at the top level describe.

RuboCop

You can enforce this guideline using the cop RSpec/DescribedClass from the rubocop-rspec gem.

Discuss this guideline

Use subject

Use subject instead of described_class.new when no argument is given to the initializer.

class Calculator
  attr_reader :base

  def initialize(base = 0)
    @base = base
  end

  def add(number1, number2)
    base + number1 + number2
  end
end
describe '#add' do
 it 'sums two numbers' do
   calculator = described_class.new

   expect(calculator.add(1, 2)).to eq(3)
 end
end
describe '#add' do
  it 'sums two numbers' do
    expect(subject.add(1, 2)).to eq(3)
  end
end

It can be overridden when argument is given to the initializer.

describe '#add' do
  it 'sums two numbers' do
    subject = described_class.new(5)

    expect(subject.add(1, 2)).to eq(8)
  end
end
Discuss this guideline

Short description

A spec description must not be longer than 100 characters neither span many lines. If this happens you should split it using context.

it 'returns 422 when user first_name is missing from params and when user last_name is missing from params' do
end

it 'returns 422 when user first_name is missing from params \
    and when user last_name is missing from params' do
end
context 'when user first_name is missing from params' do
  it 'returns 422' do
  end
end

context 'when user last_name is missing from params' do
  it 'returns 422' do
  end
end
Discuss this guideline

Group expectations

Having an it for each expectation can lead to a terrible test performance. Group expectations that use a similar data setup to improve performance and make the tests more readable.

it 'returns 200' do
  user = create(:user)

  get user_path(user)

  expect(response.code).to eq('200')
end

it 'returns JSON content type' do
  user = create(:user)

  get user_path(user)

  expect(response.content_type).to eq('application/json')
end
it 'responds with 200 http status and a JSON content type' do
  user = create(:user)

  get user_path(user)

  expect(response.code).to eq('200')
  expect(response.content_type).to eq('application/json')
end

We recommend enabling failure aggregation globally to make it list all failures at once.

Discuss this guideline

All possible cases

Testing is a good practice, but if you do not test the edge cases, it will not be useful. Test valid, edge and invalid cases.

If you have way too many cases to test, it might be an indication your subject class is doing too much and must be break down into other classes.

before_action :authenticate_user!
before_action :find_product

def destroy
  @product.destroy
  redirect_to products_path
end
describe '#destroy' do
  context 'when the product exists' do
    it 'deletes the product' do
    end
  end
end
describe '#destroy' do
  context 'when the product exists' do
    it 'deletes the product' do
    end
  end

  context 'when the product does not exist' do
    it 'raises 404' do
    end
  end

  context 'when user is not authenticated' do
    it 'raises 404' do
    end
  end
end
Discuss this guideline

Request vs controller specs

Do request tests instead of controller tests.

The official recommendation of the Rails team and the RSpec core team is to write request specs instead. Request specs allow you to focus on a single controller action, but unlike controller tests involve the router, the middleware stack, and both rack requests and responses. This adds realism to the test that you are writing, and helps avoid many of the issues that are common in controller specs. In Rails 5, request specs are significantly faster than either request or controller specs were in Rails 4.

See source.

describe UsersController, type: :controller do
  describe "#index" do
    it "returns a successful response" do
      get :index

      expect(response.code).to eq('200')
    end
  end
end
describe UsersController, type: :request do
  describe "#index" do
    it "returns a successful response" do
      user = create(:user, name: "Carlos Chagas")

      get users_path

      expect(response.code).to eq('200')
      expect(response.body).to include("Carlos Chagas")
    end
  end
end
Discuss this guideline

Instance double over double

Instance double ensures the object responds to methods being called, which is not true for double.

class User
  def full_name
    "#{first_name} #{last_name}"
  end
end
it "passes" do
  user = double(:user, name: "Gustavo Kuerten")
  puts user.name
end
it "fails" do
  user = instance_double(User, name: "Gustavo Kuerten")
  puts user.name
end

The above test will fail with the following message since the name method really doesn't exist.

the User class does not implement the instance method: name. Perhaps you meant to use `class_double` instead?

RuboCop

You can enforce this guideline using the cop RSpec/VerifiedDoubles from the rubocop-rspec gem.

Discuss this guideline

Let's not

Do not use let / let!. These tend to turn your tests very complicated over time as one needs to look up variables defined then apply deltas to figure their current state. Understand more here.

Tests are not supposed to be DRY, but easy to read and maintain.

describe '#full_name' do
  let(:user) { build(:user, first_name: 'Edson', last_name: 'Pelé') }

  context 'when first name and last name are present' do
    it 'returns the full name' do
      expect(user.full_name).to eq('Edson Pelé')
    end
  end

  context 'when last name is not present' do
    it 'returns the first name' do
      user.last_name = nil
      expect(user.full_name).to eq('Edson')
    end
  end
end
describe '#full_name' do
  context 'when first name and last name are present' do
    it 'returns the full name' do
      user = build(:user, first_name: 'Edson', last_name: 'Pelé')

      expect(user.full_name).to eq('Edson Pelé')
    end
  end

  context 'when last name is not present' do
    it 'returns the first name' do
      user = build(:user, first_name: 'Edson', last_name: nil)

      expect(user.full_name).to eq('Edson')
    end
  end
end

RuboCop

You can enforce this guideline using the cop RSpec/MultipleMemoizedHelpers from the rubocop-rspec gem:

# .rubocop.yml
RSpec/MultipleMemoizedHelpers:
  Max: 0
  AllowSubject: false
Discuss this guideline

Avoid hooks

Avoid hooks since they usually cause your tests to become more complex in the long run.

describe '#index' do
  context 'when user is authenticated' do
    before do
      @user = create(:user)
      sign_in @user
      get profile_path
    end

    context 'when user has a profile' do
      it 'returns 200' do
        create(:profile, user: @user)
        expect(response.code).to eq('200')
      end
    end

    context 'when user does not have a profile' do
      it 'returns 404' do
        expect(response.code).to eq('404')
      end
    end
  end
end
describe '#index' do
  context 'when user is authenticated' do
    context 'when user has a profile' do
      it 'returns 200' do
        user = create(:user)
        create(:profile, user: user)
        sign_in user

        get profile_path

        expect(response.code).to eq('200')
      end
    end

    context 'when user does not have a profile' do
      it 'returns 404' do
        user = create(:user)
        sign_in user

        get profile_path

        expect(response.code).to eq('404')
      end
    end
  end
end
Discuss this guideline

Don't use shared examples

Shared examples is a feature that allow us to not repeat code, but tests are not real code, so introducing it increases the complexity of your suite.

shared_examples 'a normal dog' do
  it { is_expected.to be_able_to_bark }
end

describe Dog do
  subject { described_class.new(able_to_bark?: true) }
  it_behaves_like 'a normal dog'
end
describe Dog do
  describe '#able_to_bark?' do
    it 'barks' do
      subject = described_class.new(able_to_bark?: true)

      expect(subject.able_to_bark?).to eq(true)
    end
  end
end
Discuss this guideline

Mock external dependencies

Mock external dependencies to your test subject. By external dependencies I mean code that's not the main responsibility of the subject under test.

def github_stars(repository_id)
  stars = Github.fetch_repository_stars(repository_id)
  "Stars: #{stars}"
end
describe '#github_stars' do
  it 'displays the number of stars' do
    expect(subject.github_stars(1)).to eq('Stars: 10')
  end
end
describe '#github_stars' do
  it 'displays the number of stars' do
    expect(Github).to receive(:fetch_repository_stars).with(1).and_return(10)

    expect(subject.github_stars(1)).to eq('Stars: 10')
  end
end

Unless you intentionally want to test the underlying dependencies. This type of test must be limited to very few cases though otherwise it will make your test suite slow and hard to maintain.

def process(payload)
  UserCreator.new(payload).create
  ProductCreator.new(payload).create
end
describe '#process' do
  it 'creates a user and a product' do
    payload = { user: { name: 'Isabel' }, product: { name: 'Book' } }

    subject.process(payload)

    expect(User.count).to eq(1)
    expect(Product.count).to eq(1)
  end
end
Discuss this guideline

Stub HTTP requests

Sometimes you need to access external services. In these cases you can't rely on the real service but you should stub it with solutions like webmock or VCR.

def request
  uri = URI.parse("http://www.example.com/")
  request = Net::HTTP::Post.new(uri.path)
  request['Content-Length'] = 3

  Net::HTTP.start(uri.host, uri.port) do |http|
    http.request(request, "abc")
  end
end
describe '#request' do
  response = subject.request

  expect(response.code).to eq('200')
end
describe '#request' do
  stub_request(:post, "www.example.com").with(body: "abc", headers: { 'Content-Length' => 3 })

  response = subject.request

  expect(response.code).to eq('200')
end
Discuss this guideline

Stub environment variables

Prefer to stub environment variables whenever possible so your tests become more self-contained, the stub_env gem might help you with that.

def notify_sales_team
  mail(
    to: ENV['SALES_TEAM_EMAIL'],
    subject: 'New sale'
  )
end
describe '#notify_sales_team' do
  it 'prepares the email' do
    subject = described_class.notify_sales_team

    expect(subject.to).to eq(['sales-group@company.com'])
    expect(subject.subject).to eq('New sale')
  end
end
# .env.test
SALES_TEAM_EMAIL='sales-group@company.com'
describe '#notify_sales_team' do
  it 'prepares the email' do
    stub_env('SALES_TEAM_EMAIL', 'sales-group@company.com')

    subject = described_class.notify_sales_team

    expect(subject.to).to eq(['sales-group@company.com'])
    expect(subject.subject).to eq('New sale')
  end
end
Discuss this guideline

Create only the data you need

Create only the data you need for each test. Having more data than necessary might make your suite slow.

describe '.featured_product' do
  it 'returns the featured product' do
    create_list(:product, 5)
    product_featured = create(:product, featured: true)

    expect(described_class.featured_product).to eq(product_featured)
  end
end
describe '.featured_product' do
  it 'returns the featured product' do
    create(:product, featured: false)
    product_featured = create(:product, featured: true)

    expect(described_class.featured_product).to eq(product_featured)
  end
end
Discuss this guideline