Minitest Assertions with Spec-Style Blocks

Written: Jun 11, 2025

I just recently started using Minitest again on some greenfield projects after spending several years working primarily in RSpec. Having been over to the other side, I can say there was certainly a mix of things I liked and things I didn’t, but without a doubt, it was a useful learning experience.

Now that I’m back using Minitest daily once again, I’ve been writing tests using simple, vanilla Test::Unit-style assertions but organizing them with the sort of block syntax you’d expect from RSpec or Minitest::Spec. Here I’m going to show you what this hybrid approach looks like and how and why you might want to try it for yourself.

What it looks like

Here’s a sample test for a Rails service object that uses this approach:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
class NotificationServiceTest < ActiveSupport::TestCase
  before do
    @service = NotificationService.new
    @user = users(:active_andy)
  end

  describe "#send_notification" do
    context "when the user has email notifications enabled" do
      before do
        @user.update!(email_notifications: true)
      end

      context "urgent notifications" do
        before do
          @notification = notifications(:urgent)
        end

        test "sends an email immediately" do
          assert_difference 'ActionMailer::Base.deliveries.count', 1 do
            @service.send_notification(@user, @notification)
          end
        end

        test "logs the notification attempt" do
          @service.send_notification(@user, @notification)

          log_entry = NotificationLog.last
          assert_equal 'delivered', log_entry.delivery_status
          assert_equal @user.id, log_entry.user_id
          assert_equal @notification.id, log_entry.notification_id
          assert_equal 'email', log_entry.delivery_method
        end
      end

      context "for standard notifications" do
        before do
          @notification = notifications(:weekly_digest)
        end

        test "queues the email for batch delivery" do
          assert_enqueued_with(job: NotificationEmailJob) do
            @service.send_notification(@user, @notification)
          end
        end

        test "does not send immediately" do
          assert_no_difference 'ActionMailer::Base.deliveries.count' do
            @service.send_notification(@user, @notification)
          end
        end
      end
    end

    context "when user has email notifications disabled" do
      before do
        @user.update!(email_notifications: false)
        @notification = notifications(:urgent)
      end

      test "does not send any email" do
        assert_no_difference 'ActionMailer::Base.deliveries.count' do
          @service.send_notification(@user, @notification)
        end
      end

      test "logs that the notification was skipped" do
        @service.send_notification(@user, @notification)

        log_entry = NotificationLog.last
        assert_equal 'skipped_user_preference', log_entry.delivery_status
      end
    end
  end
end

And here’s a simpler example with a model test:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class UserTest < ActiveSupport::TestCase
  before do
    @user = users(:active_andy)
  end

  describe "#can_login?" do
    test "allows login" do
      assert @user.can_login?
    end

    context "when the user is inactive" do
      before do
        @user = users(:inactive_ira)
      end

      test "does not allow login" do
        assert_not @user.can_login?
      end
    end
  end

  # and so on...
end

Setting up your test environment

Since I tend to use the same style almost everywhere in my test suite (the main exception being Rails system tests, which tend to be longer and more script-like), I set this up in my test_helper.rb with the following:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ENV["RAILS_ENV"] ||= "test"
require_relative "../config/environment"
require "rails/test_help"

class ActiveSupport::TestCase
  # Run tests in parallel with specified workers
  parallelize(workers: :number_of_processors)

  # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
  fixtures :all

  # Enable spec-style DSL
  extend Minitest::Spec::DSL
  class << self
    alias context describe
  end
end

The key line is extend Minitest::Spec::DSL, which gives you access to describe, before, and it methods in your test classes (though I usually opt for the more specific test method in place of it).

What’s great about this approach

Natural test organization

Using describe and context blocks lets you group related tests logically, making your tests more readable. This is essential when testing complex methods with branching behaviors, and when compared to an equivalent test in the usual Test::Unit flat structure, it wins hands-down for even nominally complex classes.

Incremental setup enabled by before blocks

Especially in more mature code bases, it’s not unusual to find tests that are hundreds of lines long or more. And while this should probably be a signal that you’re overdue to refactor a class, that’s not always possible. In cases like this, does it make any sense to have a single setup method at the beginning of the file that instantiates all the state you might need for all tests? Or should every test method be responsible for its entire setup?

Using before blocks within your various describe and context scopes, you’re able to define and progressively refine the setup for each of your tests in a way that keeps the most relevant parts of the setup close to the tests that demonstrate how it works.

Cognitive simplicity

There’s just one way to assert equality (assert_equal), one way to assert truthiness (assert), one way to assert an exception (assert_raises), and all of these are trivial to interpret. The expectations provided by Minitest::Spec are one step removed from this, and reading them usually involves parsing parentheses and sometimes reading them inside-out or backwards. (They’re still a great improvement over RSpec’s DSL, method chaining, etc.)

Crystal clear failure messages

When assert_equal expected, actual fails, you get exactly what you need - the expected value, the actual value, and the line where it failed. And the same goes for other assertions as well. The information from the console maps directly back to one line in your test and its moving parts.

Lessons learned

  • Use describe for subjects and context for state. Minitest doesn’t implement a context method out of the box, but I add the alias to describe to separate the thing I’m testing from its states and scenarios.
  • Don’t nest too deeply. Two or three levels of nesting is usually plenty. If you find you’re needing more than that, it’s probably indicating that you’ve got one method trying to do too much.
  • Watch your setup cascade. When you have nested before blocks, they all run for inner tests. This is usually what you want, but be mindful of what state you’re building up. (Avoiding very deeply nested blocks also helps with this.)

When this approach makes sense

This might be something to try if:

  • Your project has a number of classes with complex branching logic. It’s especially good for organizing tests of business logic as well as controllers in apps that use Hotwire where, depending on request parameters and other factors, you might be responding by rendering a number of different templates.
  • You’ve got classes with many methods. Partitioning tests into blocks can provide you with a simple index of your tests.
  • You’re already using Minitest::Spec, and you just don’t care to try to translate failure messages from the console to the lines in your test.

And if you decide you want to try this out, remember that you can get started testing just a single class. It’s great to implement what works best for you everywhere, but it doesn’t have to be all or nothing to see if you gain anything from it.