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 andcontext
for state. Minitest doesn’t implement acontext
method out of the box, but I add the alias todescribe
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.