Instantiating Service Objects
… and check why 5600+ Rails engineers read also this
Instantiating Service Objects
In my last blogpost about adapters I promised a more detailed insight into instantiating Adapters & Service Objects. So here we go.
Boring style
class ProductsController
def create
metrics = MetricsAdapter.new(METRICS_CONFIG.fetch(Rails.env))
service = CreateProductService.new(metrics)
product = service.call(params[:product])
redirect_to product_path(product), notice: "Product created"
rescue CreateProductService::Failed => failure
# ... probably render ...
end
end
This is the simplest way, nothing new under the sun. When your needs are small, dependencies simple or non-existing (or created inside service, or you use globals, in other words: not passed explicitely) you might not need anything more.
Testing
Ideally we want to test our controllers in simplest possible way. In Rails codebase,
unlike in desktop application, every controller action is an entry point into the
system. Its our main()
method. So we want our controllers to be very thin,
instantiating the right kind of objects, giving them access to the input, and putting
the whole world in motion. The simplest, the better, because controllers are the
hardest beasts when it come to testing.
Controller
describe ProductsController do
specify "#create" do
product_attributes = {
"name" =>"Product Name",
"price"=>"123.45",
}
expect(MetricsAdapter).to receive(:new).with("testApiKey").and_return(
metrics = double(:metrics)
)
expect(CreateProductService).to receive(:new).with(metrics).and_return(
create_product_service = double(:register_user_service,
call: Product.new.tap{|p| p.id = 10 },
)
)
expect(create_product_service).to receive(:call).with(product_attributes)
post :create, {"product"=> product_attributes}
expect(flash[:notice]).to be_present
expect(subject).to redirect_to("/products/10")
end
end
It’s up to you whether you want to mock the service or not. Remember that the purpose of this test is not to determine whether the service is doing its job, but whether controller is. And the controller concers are
- passing
params
,request
andsession
(subsets of) data for the services when they need it - controlling the flow of the interaction by using
redirect_to
orrender
. In case of happy path as well as when something goes wrong. - Updating the long-living parts of user interaction with our system such as
session
andcookies
- Optionally, notifying user about the achieved result of the actions. Often with the
use of
flash
orflash.now
. I wrote optionally because I think in many cases the communication of action status should actually be a responsibility of the view layer, not a controller one
These are the things you should be testing, nothing less, nothing more.
However mocking adapters might be necessary because we don’t want to be sending or collecting our data from test environment.
Service
When testing the service you need to instantiate it and its dependencies manually as well.
describe CreateProductService do
let(:metrics_adapter) do
FakeMetricsAdapter.new
end
subject(:create_product_service) do
described_class.new(metrics_adapter)
end
specify "something something" do
create_product_service.call(..)
expect(..)
end
end
Modules
When instantiating becomes more complicated I extract the process of creating
the full object into an injector
. The purpose is to make it easy to create
new instance everywhere and to make it trivial for people to overwrite the
dependencies by overwriting methods.
module CreateProductServiceInjector
def metrics_adapter
@metrics_adapter ||= MetricsAdapter.new( METRICS_CONFIG.fetch(Rails.env) )
end
def create_product_service
@create_product_service ||= CreateProductService.new(metrics_adapter)
end
end
class ProductsController
include CreateProductServiceInjector
def create
product = create_product_service.call(params[:product])
redirect_to product_path(product), notice: "Product created"
rescue CreateProductService::Failed => failure
# ... probably render ...
end
end
Testing
The nice thing is you can test the instantiating process itself easily with injector (or skip it completely if you consider it to be typo-testing that provides very little value) and don’t bother much with it anymore.
Injector
Here we only test that we can inject the objects and change the dependencies.
describe CreateProductServiceInjector do
subject(:injected) do
Object.new.extend(described_class)
end
specify "#metrics_adapter" do
expect(MetricsAdapter).to receive(:new).with("testApiKey").and_return(
metrics = double(:metrics)
)
expect(injected.metrics_adapter).to eq(metrics)
end
specify "#create_product_service" do
expect(injected).to receive(:metrics_adapter).and_return(
metrics = double(:metrics)
)
expect(CreateProductService).to receive(:new).with(metrics).and_return(
service = double(:register_user_service)
)
expect(injected.create_product_service).to eq(service)
end
end
Is it worth it? Well, it depends how complicated setting your object is. Some of my colleagues just test that the object can be constructed (hopefully this has no side effects in your codebase):
describe CreateProductServiceInjector do
subject(:injected) do
Object.new.extend(described_class)
end
specify "can instantiate service" do
expect{ injected.create_product_service }.not_to raise_error
end
end
Controller
Our controller is only interested in cooperating with create_product_service
.
It doesn’t care what needs to be done to fully set it up. It’s the job of Injector
.
We can throw away the code for creating the service.
describe ProductsController do
specify "#create" do
product_attributes = {
"name" =>"Product Name",
"price"=>"123.45",
}
expect(controller.create_product_service).to receive(:call).
with(product_attributes).
and_return( Product.new.tap{|p| p.id = 10 } )
post :create, {"product"=> product_attributes}
expect(flash[:notice]).to be_present
expect(subject).to redirect_to("/products/10")
end
end
Service Object
You can use the injector in your tests as well. Just include it.
Rspec is a DSL that is just creating classes and method for you.
You can overwrite the metrics_adapter
dependency using Rspec DSL
with let
or just by defining metrics_adapter
method yourself.
Just remember that let
is adding memoization for you automatically.
If you use your own method definition make sure to memoize as well
(in some cases it is not necessary, but when you start stubbing/mocking
it is).
describe CreateProductService do
include CreateProductServiceInjector
specify "something something" do
create_product_service.call(..)
expect(..)
end
let(:metrics_adapter) do
FakeMetricsAdapter.new
end
#or
def metrics_adapter
@adapter ||= FakeMetricsAdapter.new
end
end
There is nothing preventing you from mixing classic ruby OOP with Rspec DSL. You can use it to your advantage.
The downside that I see is that you can’t easily say from reading
the code that metrics_adapter
is a dependency of our
class under test (CreateProductService
). As I said in simplest
case it might not be worthy, in more complicated ones it might be however.
Example
Here is a more complicated example from one of our project.
require 'notifications_center/db/active_record_sagas_db'
require "notifications_center/schedulers/resque_scheduler"
require "notifications_center/clocks/real"
module NotificationsCenterInjector
def notifications_center
@notifications_center ||= begin
apns_adapter = Rails.configuration.apns_adapter
policy = Rails.configuration.apns_push_notifications_policy
mixpanel_adapter = Rails.configuration.mixpanel_adapter
url_helpers = Rails.application.routes_url_helpers
db = NotificationsCenter::DB::ActiveRecordSagasDb.new
scheduler = NotificationsCenter::Schedulers::ResqueScheduler.new
clock = NotificationsCenter::Clocks::Real.new
push = PushNotificationService.new(
url_helpers,
apns_adapter,
policy,
mixpanel_adapter
)
NotificationsCenter.new(db, push, scheduler, clock)
end
end
end
Dependor
You might also consider using dependor gem for this.
class Injector
extend Dependor::Let
let(:metrics_adapter) do
MetricsAdapter.new( METRICS_CONFIG.fetch(Rails.env) )
end
let(:create_product_service)
CreateProductService.new(metrics_adapter)
end
end
class ProductsController
extend Dependor::Injectable
inject_from Injector
inject :create_product_service
def create
product = create_product_service.call(params[:product])
redirect_to product_path(product), notice: "Product created"
rescue CreateProductService::Failed => failure
# ... probably render ...
end
end
The nice thing about dependor is that it provides a lot of small APIs
and doesn’t force you to use any of them. Some of them do more magic
(I am looking at you Dependor::AutoInject
)
and some of medium level (Dependor::Injectable
)
and some almost none magic whatsoever(Dependor::Shorty
).
You can use only the parts that you like and are comfortable with.
Testing
Injector
The simple way that just checks if things don’t crash and nothing more.
require 'dependor/rspec'
describe Injector do
let(:injector) { described_class.new }
specify do
expect{injector.create_product_service}.to_not raise_error
end
end
Service
For testing the service you go whatever way you want.
Create new instance manually or use
Dependor::Isolate
.
require 'dependor/rspec'
describe CreateProductService do
let(:metrics_adapter) do
FakeMetricsAdapter.new
end
subject(:create_product_service) { isolate(CreateProductService) }
specify "something something" do
create_product_service.call(..)
expect(..)
end
end
That’s it
Thanks for reading. If you liked it and you wanna find out more subscribe to course below.