V1.3: 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
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.

# 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 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
end

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

# 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
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

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.

# 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

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.

# config/environment.rb
# ...
Hanami::Container.configure do
  mount ApiV1::Application, at: '/api/v1'
  mount Web::Application,   at: '/'
end

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

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

RSpec.describe Web::Controllers::Dashboard::Index, type: :action do
  let(:params) { { 'rack.session' => session } }

  subject { action.call(params) }

  context 'when user is logged in' do
    let(:session) { { account_id: 1 } }

    it { expect(subject[0]).to eq 200 }
  end

  context 'when user is not logged in' do
    let(:session) { {} }

    it { expect(subject).to redirect_to('/login') }
  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
end