TL;DR Use dependency injection to write service objects that can have pure unit tests that don't hit the database.

The Problem

Take a service object like this.

# app/services/create_auth_token.rb

class CreateAuthToken < AppService
  def initialize(phone_number:)
    @phone_number = phone_number
  end

  def call
    person = Person.find_by phone_number: @phone_number

    return ServiceResult.fail('Phone number not found') if person.nil?

    person.create_auth_token
    person.save

    ServiceResult.succeed
  end
end

Let's think about what we need to test. There is one if statement resulting in two paths.

  • The phone number is found, an auth token is created, the service succeeds
  • The phone number is not found, the service fails

Now let's think about how to test those two cases. It references Person.find_by and calls person.save on the model. Both of these create dependency on Active Record and will hit the database. To test this, we need to write an integration test.

# spec/services/create_auth_token_spec.rb

def create_person
  # hitting DB here
  Person.create(
    display_name: 'Test',
    email: SecureRandom.uuid + '@test.com',
    phone_number: '555-555-5555'
  )
end

RSpec.describe CreateAuthToken, 'call' do
  it 'creates an auth token and succeeds when phone number found' do
    person = create_person

    # will hit DB twice in service call
    result = CreateAuthToken.call(phone_number: person.phone_number)

    expect(result.success?).to eq true
    expect(person.auth_token).not_to eq nil
  end

  it 'fails when no phone number found' do
    # will hit db once when it doesn't find the phone number
    result = CreateAuthToken.call(phone_number: '555-123-1234')

    expect(result.success?).to eq false
  end
end

This is an integration test, not a unit test. Testing the "found" and "not found" scenarios depends on the records in the database. We have to set that up ahead of time using Person.create.

We could accept it and move on. We could leave the direct dependency on Active Record and live with the integration test. But we don't have to.

Solution - Dependency Injection

To make this service unit-testable, we must remove the direct calls to Active Record. This is where dependency injection comes in. We'll add a new parameter to the service. It will be called person_repo abbreviated person repository. The person repository's job is to interact with the external persistence system. It needs to be able to do find_by and save.

# app/services/create_auth_token.rb

class CreateAuthToken < AppService
  def initialize(phone_number:, person_repo: Person) # dependency injection
    @phone_number = phone_number
    @person_repo = person_repo
  end

  def call
    person = @person_repo.find_by phone_number: @phone_number

    return ServiceResult.fail('Phone number not found') if person.nil?

    person.create_auth_token
    @person_repo.save(person)

    ServiceResult.succeed
  end
end

We're saying – this service will take a person_repo which could be Person from Active Record but it doesn't have to be. It could be anything.

We'll default it to be Person for convenience. We could also use a factory or an IoC container but for our example, default is the simplest.

There's one problem with our service. Person does not have a 'save' method. The instance of a person does, but we need to avoid calling that directly. We can fix that by adding a method to our ApplicationRecord class.

# app/models/application_record.rb

class ApplicationRecord < ActiveRecord::Base
  self.abstract_class = true

  # extra method to allow AR to fulfill @person_repo dependency
  def self.save(model)
    model.save
  end
end

Rewrite as Pure Unit Test

We're ready to inject something different in our test to stop tests from hitting the database. Enter the mock.

# spec/services/create_auth_token_spec.rb

# note Person.new replaced Person.create, no DB call here
def create_person
  Person.new(
    display_name: 'Test',
    email: SecureRandom.uuid + '@test.com',
    phone_number: '555-555-5555'
  )
end

RSpec.describe CreateAuthToken, 'call' do
  it 'creates an auth token' do
    person = create_person

    # set up mock to receive calls and return canned responses
    mock = double(Person, find_by: person, save: true)

    result = CreateAuthToken.call(phone_number: person.phone_number, person_repo: mock)

    expect(result.success?).to eq true
    expect(person.auth_token).not_to eq nil
    expect(mock).to have_received(:save)
  end

  it 'returns failed result when no phone number found' do
    # set up mock to return nil to hit the 'not found' scenario
    mock = double(ApplicationRecord, find_by: nil)

    result = CreateAuthToken.call(phone_number: '555-123-1234', person_repo: mock)

    expect(result.success?).to eq false
  end

These tests do not touch the database anymore. The mock simulates the found and not found scenario for us, rather than having to set it up. The test is faster and more focused on what the service actually does.

Disclaimer

I'm new to Ruby and Rails. My backend experience is in Node and .Net. In .Net there's a huge emphasis on dependency injection. It's a first-class concern of the modern .Net frameworks. That background biases my approach to this problem.

Conclusion

In my opinion, this feels pretty natural in Ruby/Rails. We'll see if this pattern works well when services get more complicated. The number of dependencies needs to stay under control. We can keep services very small and focused to avoid that.

Further Reading/Watching