Actions: Testing
Hanami pays a lot of attention to code testability and it offers advanced features to make our lives easier. The framework supports RSpec (default) and Minitest.
Unit Tests
First of all, actions can be unit tested. That means we can instantiate, exercise and verify expectations directly on actions instances.
# spec/web/controllers/dashboard/index_spec.rb
require_relative '../../../../apps/web/controllers/dashboard/index'
RSpec.describe Web::Controllers::Dashboard::Index do
let(:action) { Web::Controllers::Dashboard::Index.new }
let(:params) { Hash[] }
it "is successful" do
response = action.call(params)
expect(response[0]).to be(200)
end
endIn the example above, action is an instance of Web::Controllers::Dashboard::Index, we can invoke #call on it, passing a Hash of parameters.
The implicit returning value is a serialized Rack response.
We’re asserting that the status code (response[0]) is successful (equals to 200).
Running Tests
We can run the entire test suite or a single file.
The default Rake task for the application serves for our first case: bundle exec rake.
All the dependencies and the application code (actions, views, entities, etc..) are eagerly loaded.
Boot time is slow in this case.
The entire test suite can be run via default Rake task. It loads all the dependencies, and the application code.
The second scenario can be done via: ruby -Ispec spec/web/controllers/dashboard/index_spec.rb (or rspec spec/web/controllers/dashboard/index_spec.rb if we use RSpec).
When we run a single file example only the framework and the application settings are loaded.
Please note the require_relative line in the example.
It’s auto generated for us and it’s needed to load the current action under test.
This mechanism allows us to run unit tests in isolation.
Boot time is magnitudes faster.
A single unit test can be run directly. It only loads the dependencies, but not the application code.
The class under test is loaded via require_relative, a line automatically generated for us.
In this way we can have a faster startup time and a shorter feedback cycle.
Params
When testing an action, we can easily simulate parameters and headers coming from the request.
We just need to pass them as a Hash.
Headers for Rack env such as HTTP_ACCEPT can be mixed with params like :id.
The following test example uses both.
# spec/web/controllers/users/show_spec.rb
require_relative '../../../../apps/web/controllers/users/show'
RSpec.describe Web::Controllers::Users::Show do
let(:action) { Web::Controllers::Users::Show.new }
let(:format) { 'application/json' }
let(:user_id) { '23' }
it "is successful" do
response = action.call(id: user_id, 'HTTP_ACCEPT' => format)
expect(response[0]).to eq(200)
expect(response[1]['Content-Type']).to eq("#{ format }; charset=utf-8")
expect(response[2]).to eq(["ID: #{ user_id }"])
end
endHere the corresponding production code.
# apps/web/controllers/users/show.rb
module Web
module Controllers
module Users
class Show
include Web::Action
def call(params)
puts params.class # => Web::Controllers::Users::Show::Params
self.body = "ID: #{ params[:id] }"
end
end
end
end
end
Simulating request params and headers is simple for Hanami actions. We pass them as a Hash and they are transformed into an instance of Hanami::Action::Params.
Exposures
There are cases where we want to verify the internal state of an action.
Imagine we have a classic user profile page, like depicted in the example above.
The action asks for a record that corresponds to the given id, and then set a @user instance variable.
How do we verify that the record is the one that we are looking for?
Because we want to make @user available to the outside world, we’re going to use an exposure.
They are used to pass a data payload between an action and the corresponding view.
When we do expose :user, Hanami creates a getter (#user), so we can easily assert if the record is the right one.
# apps/web/controllers/users/show.rb
module Web
module Controllers
module Users
class Show
include Web::Action
expose :user, :foo
def call(params)
@user = UserRepository.new.find(params[:id])
@foo = 'bar'
end
end
end
end
endWe have used two exposures: :user and :foo, let’s verify if they are properly set.
# spec/web/controllers/users/show_spec.rb
require_relative '../../../../apps/web/controllers/users/show'
RSpec.describe Web::Controllers::Users::Show do
let(:user) { UserRepository.new.create(name: 'Luca') }
let(:action) { Web::Controllers::Users::Show.new }
it "is successful" do
response = action.call(id: user.id)
expect(response[0]).to be(200)
action.user.must_equal user
action.exposures.must_equal({user: user, foo: 'bar'})
end
endThe internal state of an action can be easily verified with exposures.
Dependency Injection
During unit testing, we may want to use mocks to make tests faster or to avoid hitting external systems like databases, file system or remote services.
Because we can instantiate actions during tests, there is no need to use testing antipatterns (eg. any_instance_of, or UserRepository.new.stub(:find)).
Instead, we can just specify which collaborators we want to use via dependency injection.
Let’s rewrite the test above so that it does not hit the database. We’re going to use RSpec for this example as it has a nicer API for mocks (doubles).
# spec/web/controllers/users/show_spec.rb
require_relative '../../../../apps/web/controllers/users/show'
RSpec.describe Web::Controllers::Users::Show do
let(:action) { Web::Controllers::Users::Show.new(repository: repository) }
let(:user) { User.new(id: 23, name: 'Luca') }
let(:repository) { double('repository', find: user) }
it "is successful" do
response = action.call(id: user.id)
expect(response[0]).to eq(200)
expect(action.user).to eq(user)
expect(action.exposures).to eq({user: user})
end
endWe have injected the repository dependency which is a mock in our case. Here how to adapt our action.
# apps/web/controllers/users/show.rb
module Web
module Controllers
module Users
class Show
include Web::Action
expose :user
def initialize(repository: UserRepository.new)
@repository = repository
end
def call(params)
@user = @repository.find(params[:id])
end
end
end
end
endPlease be careful using doubles in unit tests. Always verify that the mocks are in a true representation of the corresponding production code.
Flash messages
In your action tests, you can check flash messages too. For this, you can use exposures method for getting all flash data.
The following test example uses this method.
# spec/web/controllers/users/create_spec.rb
require_relative '../../../../apps/web/controllers/users/create'
RSpec.describe Web::Controllers::Users::Create do
let(:action) { Web::Controllers::Users::Create.new }
let(:user_params) { name: 'Luca' }
it "is successful" do
response = action.call(id: user_params)
flash = action.exposures[:flash]
expect(flash[:info]).to eq('User was successfully created')
end
endRequests Tests
Unit tests are a great tool to assert that low level interfaces work as expected. We always advise combining them with integration tests.
In the case of Hanami web applications, we can write features (aka acceptance tests) with Capybara, but what do we use when we are building HTTP APIs?
The tool that we suggest is rack-test.
Imagine we have an API application mounted at /api/v1 in our Hanami::Container.
# config/environment.rb
# ...
Hanami::Container.configure do
mount ApiV1::Application, at: '/api/v1'
mount Web::Application, at: '/'
endThen we have the following action.
# apps/api_v1/controllers/users/show.rb
module ApiV1
module Controllers
module Users
class Show
include ApiV1::Action
accept :json
def call(params)
user = UserRepository.new.find(params[:id])
self.body = JSON.generate(user.to_h)
end
end
end
end
endIn this case we don’t care too much about the internal state of the action, but about the output visible to the external world.
This is why we haven’t set user as an instance variable and why we haven’t exposed it.
# spec/api_v1/requests/users_spec.rb
RSpec.describe "API V1 users" do
include Rack::Test::Methods
# app is required by Rack::Test
let(:app) { Hanami.app }
let(:user) { UserRepository.new.create(name: 'Luca') }
it "is successful" do
get "/api/v1/users/#{ user.id }"
expect(last_response.status).to be(200)
expect(last_response.body).to eq(JSON.generate(user.to_h))
end
endPlease avoid test doubles when writing full integration tests, as we want to verify that the whole stack is behaving as expected.