Feedback on Hanami Minitest

Hola, Hanamigos! :waving_hand:

In May we’ll be releasing the first version of Hanami Minitest for Hanami v2. I’ve been working on this PR to put it together: Get started by timriley · Pull Request #3 · hanami/hanami-minitest · GitHub

I’m not a regular Minitest user myself, so I need your help to get this right.

I’ve put together an example app that uses the files that will be generated by Hanami Minitest: GitHub - timriley/minitest_example: Example Hanami app using Minitest

Setup and support files

These setup and support files are broadly a mirror of how we set up RSpec, just converted into what I hope is reasonable minitest idioms.

Of note:

  • We open up Minitest::Test to allow for custom behaviour to be provided to all tests. You can see an example of us doing this ourselves from spec/support/operations.rb. Most ordinary tests will therefore inherit from Minitest::Test.
  • We have (top-level) RequestTest and FeatureTest classes to inherit from for those particular categories of test. I went with this approach because it felt like it would look and feel better compared to nested superclasses.
    • In general we try not to take top-level names away from our users, but I did struggle to find a decent namespace to keep these within. Putting them inside Hanami::Minitest may work, e.g. Hanami::Minitest::FeatureTest, but that limits our ability in the future to put actual classes with that name in the gem in the future. Another option would be to take just a single top-level name and put these test superclasses within it, e.g. Test::FeatureTest. But when I played that out, the naming for the basic test class never felt right: Test::Test? Gross. Test::Case? But then that’s inconsistent with Test::FeatureTest. See what I mean? This is why I chose those basic top-level classes to start with.
  • Where we have any support code that actually needs to exist as concrete classes, we put them inside a TestSupport namespace. (Naming this one was easier!).
  • The general approach we’ve taken for both Hanami RSpec and now Hanami Minitest has been to generate all necessary setup code into the user’s app, rather than trying to keep it within the gem code. This gives the user maximum flexibility to tweak that setup to suit their app. Over time, we may look to provide some more of this setup “out of the box”, but for now that’s out of scope for these initial

test/test_helper.rb:

# frozen_string_literal: true

require "pathname"
TEST_ROOT = Pathname(__dir__).realpath.freeze

ENV["HANAMI_ENV"] ||= "test"
require "hanami/prepare"

require_relative "support/minitest"
TEST_ROOT.glob("support/**/*.rb").each { |f| require f }

test/support/minitest.rb:

# frozen_string_literal: true

require "minitest/autorun"

module Minitest
  class Test
    # Add helper methods to be used by all tests here.
  end
end

test/support/operations.rb:

# frozen_string_literal: true

require "dry/monads"

module Minitest
  class Test
    # Provide `Success` and `Failure` for testing operation results.
    include Dry::Monads[:result]
  end
end

test/support/requests.rb:

# frozen_string_literal: true

require "rack/test"

class RequestTest < Minitest::Test
  include Rack::Test::Methods

  # Defines the app for Rack::Test requests.
  def app
    Hanami.app
  end
end

test/support/features.rb:

# frozen_string_literal: true

require "capybara/minitest"

class FeatureTest < Minitest::Test
  include Capybara::DSL
  include Capybara::Minitest::Assertions

  def teardown
    super
    Capybara.reset_sessions!
    Capybara.use_default_driver
  end
end

Capybara.app = Hanami.app

test/support/db.rb:

# frozen_string_literal: true

require_relative "features"
require_relative "db/cleaning"

module TestSupport
  module DB
    def self.included(mod)
      mod.include DB::Cleaning
    end

    # Add helper methods to be used by DB tests here.
  end
end

class FeatureTest
  include TestSupport::DB
end

test/support/db/cleaning.rb:

# frozen_string_literal: true

require "database_cleaner/sequel"

module TestSupport
  module DB
    module Cleaning
      def self.included(base)
        base.extend(ClassMethods)
      end

      module ClassMethods
        def db_cleaning_with_truncation!
          @db_cleaning_with_truncation = true
        end

        def js! = db_cleaning_with_truncation!
      end

      def setup
        # Clean all databases before the first test
        Cleaning.once do
          Cleaning.all_databases.each do |db|
            DatabaseCleaner[:sequel, db: db].clean_with :truncation, except: ["schema_migrations"]
          end
        end

        use_truncation = self.class.instance_variable_get(:@db_cleaning_with_truncation)
        strategy = use_truncation ? :truncation : :transaction

        Cleaning.all_databases.each do |db|
          DatabaseCleaner[:sequel, db: db].strategy = strategy
          DatabaseCleaner[:sequel, db: db].start
        end

        super
      end

      def teardown
        Cleaning.all_databases.each do |db|
          DatabaseCleaner[:sequel, db: db].clean
        end

        super
      end

      class << self
        def once
          @cleaned_once ||= false
          return if @cleaned_once

          yield

          @cleaned_once = true
        end

        def all_databases
          @all_databases ||= begin
            slices = [Hanami.app] + Hanami.app.slices.with_nested

            slices.each_with_object([]) { |slice, dbs|
              next unless slice.key?("db.rom")

              dbs.concat slice["db.rom"].gateways.values.map(&:connection)
            }.uniq
          end
        end
      end
    end
  end
end

Some actual tests

Now you’ve seen all the setup, here’s how real tests look.

A request test (generated as part of the basic hanami-minitest install), test/requests/root_test.rb:

# frozen_string_literal: true

require "test_helper"

class RootTest < RequestTest
  def test_not_found
    get "/"

    # Generate new action via:
    #   `bundle exec hanami generate action home.index --url=/`
    assert_equal 404, last_response.status
  end
end

An action test (generated as part of hanami g action), test/actions/posts/index_test.rb:

# frozen_string_literal: true

require "test_helper"

class MinitestExample::Actions::Posts::IndexTest < Minitest::Test
  def test_works
    params = {}
    response = subject.call(params)
    assert_predicate response, :successful?
  end

  private

  def subject
    MinitestExample::Actions::Posts::Index.new
  end
end

A plain old regular test, this one demonstrating that Result is available to use in every test, test/posts/operations/create_test.rb:

# frozen_string_literal: true

require "test_helper"

module Posts
  module Operations
    class CreateTest < Minitest::Test
      def test_returns_success
        result = subject.call
        assert_equal Success("Created!"), result
      end

      private

      def subject
        MinitestExample::Posts::Operations::Create.new
      end
    end
  end
end

And lastly, a Capybara-driven feature test, this one demonstrating the database cleaning working in between individual test scenarios, test/features/posts_test.rb:

# frozen_string_literal: true

require "test_helper"

class PostsTest < FeatureTest
  def test_displays_posts_index_page
    relation = Hanami.app["relations.posts"]
    relation.insert(title: "Together breakfast", published_at: Time.local(2026,2,20,16,15))

    visit "/posts"

    assert_equal 200, page.status_code
    assert_selector "h1", text: "MinitestExample::Views::Posts::Index"
    assert_selector "div.post", text: "Together breakfast"
  end

  # Add another test to demonstrate database cleaning
  def test_displays_posts_index_page_2
    relation = Hanami.app["relations.posts"]
    relation.insert(title: "Tiger Millionaire", published_at: Time.local(2026,2,20,16,15))

    visit "/posts"

    assert_equal 200, page.status_code
    assert_selector "h1", text: "MinitestExample::Views::Posts::Index"
    assert_selector "div.post", text: "Tiger Millionaire"
  end
end

How to run your tests

These tests all run via bundle exec rake test:

❯ bundle exec rake test
Run options: --seed 51773

# Running:

.......

Finished in 0.089953s, 77.8184 runs/s, 144.5199 assertions/s.

7 runs, 13 assertions, 0 failures, 0 errors, 0 skips

Run an individual test file by passing TEST=

❯ bundle exec rake test TEST=test/features/posts_test.rb
Run options: --seed 24183

# Running:

..

Finished in 0.052808s, 37.8730 runs/s, 113.6191 assertions/s.

2 runs, 6 assertions, 0 failures, 0 errors, 0 skips

Or by running that test file directly using bundle exec ruby -Itest:

❯ bundle exec ruby -Itest test/features/posts_test.rb
Run options: --seed 24272

# Running:

..

Finished in 0.061096s, 32.7354 runs/s, 98.2061 assertions/s.

2 runs, 6 assertions, 0 failures, 0 errors, 0 skips

Your feedback, please!

Minitest users, how does this all feel to you? Is there anything you’d like to see us change, in order for this to be a more conventional, natural-feeling Minitest experience?

One thing I want specific feedback about is what to name our test classes.

If you review the test examples above, you’ll see a variety of approaches:

class RootTest < RequestTest

class MinitestExample::Actions::Posts::IndexTest < Minitest::Test

module Posts
  module Operations
    class CreateTest < Minitest::Test

class PostsTest < FeatureTest

Obviously, we can’t ship tests like this. We need a single consistent naming approach that can apply to all test types. What should it be?

If you’d like to try this out yourself directly, please pull down GitHub - timriley/minitest_example: Example Hanami app using Minitest and have a play!

Whoop whoop! I’m a huge Minitest fan and don’t personally use RSpec for anything, so this is great news.

Question…I’m assuming Minitest’s “spec”-style syntax would work too? Not seeing any examples here, but I’m guessing it’s just a matter of adding in the mixin to the class, aka:

# test/support/minitest.rb
module Minitest
  class Test
    extend Minitest::Spec::DSL
  end
end

# test/posts/operations/create_test.rb
module Posts
  module Operations
    class CreateTest < Minitest::Test
      describe "creating the post" do
        it "returns success" do
          expect(subject.()).must_equal Success("Created!")
        end
      end

      private

      def subject
        MinitestExample::Posts::Operations::Create.new
      end
    end
  end
end

Ah yes, I would like it if we could support both vanilla Minitest as well as the spec flavour, i.e. so our test generators can make files in the format you prefer.

We could add some config that would allow you to choose one or the other.

But yes, in the meantime, if extend Minitest::Spec::DSL works inside plain Minitest right now, it should work with the setup that I’ve prepared so far. Maybe you could give it a go and check?

I know this would arguably be better as a Minitest plugin, but it would be amazing to get the declarative test “… support from Rails, because it makes:

  • the tests much more human readable
  • Allows for multi line test names, great for edge cases & intent for the test
  • The rails behavior of “fail if this test does not have a body is a great TODO mechanism when working on a feature

Also, very glad this is happening! I’m sorry I haven’t been able to contribute :sob::sob::sob:

Could it be as simple as :

def test(name='anonymous') = define_method("test_#{name}") { yield }

?

Thanks for working on this..

Good timing, in these last days, I was working at making minitest work on my new hanami2.2 app … and I prefer (from far) spec style. So I refactor my code to match as far as possible yours.

  • I think @jaredwhite response above provides a way to make spec style working.

  • My goal is to go further providing a top-level describe method to replace class MyTest < Minitest::Test. With adequate arguments, this method could load needed modules (TestSupport submodules) ; this would be equivalent as subclassing with either FeatureTest, RequestTest … with a fine grained approach since I replace one possible superclass by many modules possibly included..
    Syntax example :

    describe 'key', :db, :features do
      # below the tests with :
      # module TestSupport::DB and TestSupport::Features included
      # a context alias for internal describe methods
      # and a few helpers :
      # subject -> Hanami.app[key]
      # app -> Hanami.app
      # deps(any-key) -> Hanami.app[any-key]
      context 'on call' do
        it 'succeeds' do
          _(subject.call).must_equal "Success!"
        end
      end
    end
    

    To make it work, I had to ‘adjust’ @timriley minitest_example code. I am not satisfied of the tricks I had to implement (but it works). The issues I encounter are about correctly handling the top level hooks (setup or teardown), especially with cleaning database functionnality.

    My code is living in minitest_example fork (branch spec) : my_fork
    I translate a few @timriley tests to spec syntax. Tests about database cleaning are not implemented in my fork, but at home it works :wink:

    It was a fun journey. How far this could meet Hanami design goals ?