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.

  1. # spec/web/controllers/dashboard/index_spec.rb
  2. require_relative '../../../../apps/web/controllers/dashboard/index'
  3. RSpec.describe Web::Controllers::Dashboard::Index do
  4. let(:action) { Web::Controllers::Dashboard::Index.new }
  5. let(:params) { Hash[] }
  6. it "is successful" do
  7. response = action.call(params)
  8. expect(response[0]).to be(200)
  9. end
  10. end

In 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.

  1. # spec/web/controllers/users/show_spec.rb
  2. require_relative '../../../../apps/web/controllers/users/show'
  3. RSpec.describe Web::Controllers::Users::Show do
  4. let(:action) { Web::Controllers::Users::Show.new }
  5. let(:format) { 'application/json' }
  6. let(:user_id) { '23' }
  7. it "is successful" do
  8. response = action.call(id: user_id, 'HTTP_ACCEPT' => format)
  9. expect(response[0]).to eq(200)
  10. expect(response[1]['Content-Type']).to eq("#{ format }; charset=utf-8")
  11. expect(response[2]).to eq(["ID: #{ user_id }"])
  12. end
  13. end

Here the corresponding production code.

  1. # apps/web/controllers/users/show.rb
  2. module Web
  3. module Controllers
  4. module Users
  5. class Show
  6. include Web::Action
  7. def call(params)
  8. puts params.class # => Web::Controllers::Users::Show::Params
  9. self.body = "ID: #{ params[:id] }"
  10. end
  11. end
  12. end
  13. end
  14. 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.

  1. # apps/web/controllers/users/show.rb
  2. module Web
  3. module Controllers
  4. module Users
  5. class Show
  6. include Web::Action
  7. expose :user, :foo
  8. def call(params)
  9. @user = UserRepository.new.find(params[:id])
  10. @foo = 'bar'
  11. end
  12. end
  13. end
  14. end
  15. end

We have used two exposures: :user and :foo, let’s verify if they are properly set.

  1. # spec/web/controllers/users/show_spec.rb
  2. require_relative '../../../../apps/web/controllers/users/show'
  3. RSpec.describe Web::Controllers::Users::Show do
  4. let(:user) { UserRepository.new.create(name: 'Luca') }
  5. let(:action) { Web::Controllers::Users::Show.new }
  6. it "is successful" do
  7. response = action.call(id: user.id)
  8. expect(response[0]).to be(200)
  9. action.user.must_equal user
  10. action.exposures.must_equal({user: user, foo: 'bar'})
  11. end
  12. end

The 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).

  1. # spec/web/controllers/users/show_spec.rb
  2. require_relative '../../../../apps/web/controllers/users/show'
  3. RSpec.describe Web::Controllers::Users::Show do
  4. let(:action) { Web::Controllers::Users::Show.new(repository: repository) }
  5. let(:user) { User.new(id: 23, name: 'Luca') }
  6. let(:repository) { double('repository', find: user) }
  7. it "is successful" do
  8. response = action.call(id: user.id)
  9. expect(response[0]).to eq(200)
  10. expect(action.user).to eq(user)
  11. expect(action.exposures).to eq({user: user})
  12. end
  13. end

We have injected the repository dependency which is a mock in our case. Here how to adapt our action.

  1. # apps/web/controllers/users/show.rb
  2. module Web
  3. module Controllers
  4. module Users
  5. class Show
  6. include Web::Action
  7. expose :user
  8. def initialize(repository: UserRepository.new)
  9. @repository = repository
  10. end
  11. def call(params)
  12. @user = @repository.find(params[:id])
  13. end
  14. end
  15. end
  16. end
  17. end

Please 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.

  1. # spec/web/controllers/users/create_spec.rb
  2. require_relative '../../../../apps/web/controllers/users/create'
  3. RSpec.describe Web::Controllers::Users::Create do
  4. let(:action) { Web::Controllers::Users::Create.new }
  5. let(:user_params) { name: 'Luca' }
  6. it "is successful" do
  7. response = action.call(id: user_params)
  8. flash = action.exposures[:flash]
  9. expect(flash[:info]).to eq('User was successfully created')
  10. end
  11. end

Requests 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.

  1. # config/environment.rb
  2. # ...
  3. Hanami::Container.configure do
  4. mount ApiV1::Application, at: '/api/v1'
  5. mount Web::Application, at: '/'
  6. end

Then we have the following action.

  1. # apps/api_v1/controllers/users/show.rb
  2. module ApiV1
  3. module Controllers
  4. module Users
  5. class Show
  6. include ApiV1::Action
  7. accept :json
  8. def call(params)
  9. user = UserRepository.new.find(params[:id])
  10. self.body = JSON.generate(user.to_h)
  11. end
  12. end
  13. end
  14. end
  15. 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.

  1. # spec/api_v1/requests/users_spec.rb
  2. RSpec.describe "API V1 users" do
  3. include Rack::Test::Methods
  4. # app is required by Rack::Test
  5. let(:app) { Hanami.app }
  6. let(:user) { UserRepository.new.create(name: 'Luca') }
  7. it "is successful" do
  8. get "/api/v1/users/#{ user.id }"
  9. expect(last_response.status).to be(200)
  10. expect(last_response.body).to eq(JSON.generate(user.to_h))
  11. end
  12. 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:

  1. module Web
  2. module Controllers
  3. module Dashboard
  4. class Index
  5. include Web::Action
  6. expose :current_account
  7. before :authenticate!
  8. def call(params)
  9. redirect
  10. end
  11. private
  12. def authenticate!
  13. redirect_to('/login') unless current_account
  14. end
  15. def current_account
  16. @current_account ||= AccountRepository.new.find(session[:account_id])
  17. end
  18. end
  19. end
  20. end
  21. end

For testing this code you can put account_id in to 'rack.session' key in params:

  1. RSpec.describe Web::Controllers::Dashboard::Index, type: :action do
  2. let(:params) { { 'rack.session' => session } }
  3. subject { action.call(params) }
  4. context 'when user is logged in' do
  5. let(:session) { { account_id: 1 } }
  6. it { expect(subject[0]).to eq 200 }
  7. end
  8. context 'when user is not logged in' do
  9. let(:session) { {} }
  10. it { expect(subject).to redirect_to('/login') }
  11. end
  12. 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:

  1. RSpec.describe Web::Controllers::Sessions::Create, type: :action do
  2. let(:session) { {} }
  3. let(:params) { Hash['rack.session' => session] }
  4. context 'given valid authentication credentials' do
  5. it 'records the user ID in the session' do
  6. action.call(params)
  7. # Hanami makes a copy of the session upon entry into the action
  8. # Afterwards, it uses the copy and not the original one
  9. # Fortunately, the copy is exposed and we can access it like any other exposure
  10. expect(action.exposures[:session][:user_id]).to eq(444)
  11. end
  12. end
  13. end