First of all, actions can be unit tested. That means we can instantiate, exercise and verify expectations directly on actions instances.
In the example above, is an instance of Web::Controllers::Dashboard::Index
, we can invoke #call
on it, passing a Hash of parameters. The 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.
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
.
# 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
end
Here 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 . 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
end
We have used two exposures: :user
and :foo
, let’s verify if they are properly set.
The internal state of an action can be easily verified with exposures.
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. , 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(: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
end
We 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
end
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
end
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
.
Then 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
In 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
end
Please avoid test doubles when writing full integration tests, as we want to verify that the whole stack is behaving as expected.
Session Testing
If you need to test an action that works with a session, you can place the session in a params
block. For example, say you have an action with a current_account
:
module Web
module Controllers
module Dashboard
class Index
include Web::Action
expose :current_account
before :authenticate!
def call(params)
redirect
end
private
def authenticate!
redirect_to('/login') unless current_account
end
def current_account
@current_account ||= AccountRepository.new.find(session[:account_id])
end
end
end
end
end
When you want to test if the action made the correct changes to the session, one needs to assert against the exposed session, not the original one:
RSpec.describe Web::Controllers::Sessions::Create, type: :action do
let(:session) { {} }
let(:params) { Hash['rack.session' => session] }
context 'given valid authentication credentials' do
it 'records the user ID in the session' do
action.call(params)
# Hanami makes a copy of the session upon entry into the action
# Afterwards, it uses the copy and not the original one
# Fortunately, the copy is exposed and we can access it like any other exposure
expect(action.exposures[:session][:user_id]).to eq(444)
end
end