TL;DR Use dependency injection to write service objects that can have pure unit tests that don't hit the database.
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
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
# 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
# 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.
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.
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.