Testing with Minitest
Minitest is the testing framework that ships with Ruby itself, so you can start writing automated tests without installing anything extra. It gives you two complementary styles: a plain assertion style (assert_equal, assert, refute) inherited from the classic Test::Unit tradition, and a spec style (describe/it) that reads like a specification document. Whichever style you choose, Minitest is fast, has very little magic, and is the default choice for testing plain Ruby libraries as well as Rails applications.
Overview: How Minitest Works
Minitest is a "default gem" — it is bundled with every modern Ruby install, so require "minitest/autorun" works out of the box. Understanding what that one require does explains almost everything about how tests run:
Test discovery. You write a class that inherits from Minitest::Test. Any instance method whose name starts with test_ is automatically treated as a test case — Minitest walks the class’s methods looking for that prefix. Nothing special marks a method as a test besides its name, which is both simple and, as you’ll see in Common Mistakes, a common source of silently-skipped tests.
The autorun hook. Requiring minitest/autorun registers an at_exit hook. When your Ruby process is about to exit, Minitest scans every loaded subclass of Minitest::Test, collects their test_* methods, and runs them. That’s why a plain script with require "minitest/autorun" and a test class runs its tests just by executing ruby your_file.rb — there is no separate runner command needed for a single file.
How assertions actually work. Each assert_* or refute_* call either does nothing (success) or raises a Minitest::Assertion. That class descends directly from Exception, not StandardError, specifically so a stray rescue StandardError in your code under test can’t accidentally swallow a failing assertion. Minitest’s runner wraps each test method in its own begin/rescue, catches that exception, records a failure, and moves on to the next test — one bad assertion never aborts the whole suite. A plain assert(value) passes whenever value is truthy. Remember Ruby’s truthiness rule here: only nil and false are falsy, so assert(0), assert(""), and assert([]) all pass, which surprises people coming from languages where 0 or an empty string is falsy.
setup and teardown. Minitest instantiates a brand new instance of your test class for every single test method. If you define setup, it runs before each test method on that fresh instance; teardown runs after. Because each test gets its own instance, instance variables you set in setup never leak between tests — there is no shared mutable state unless you deliberately reach for a class variable or an external resource.
The spec style is sugar over the same mechanism. describe SomeClass do ... end defines a subclass of Minitest::Spec (which itself subclasses Minitest::Test), and every it "description" do ... end block is compiled into an ordinary test_ method with a generated name. before is simply an alias for setup. Modern Minitest favors wrapping the value under test in _(value) and chaining must_equal/must_be_nil/etc., rather than monkey-patching those methods onto every Ruby object.
Randomized order. By default Minitest runs your tests in a random order (seeded, and it prints the seed) specifically to surface tests that secretly depend on each other’s side effects. If a suite only passes in one particular order, that’s a bug in the tests, not luck.
Syntax
class SomeNameTest < Minitest::Test
def setup
# runs before every test method, on a fresh instance
end
def test_a_short_description_of_the_expected_behavior
assert_equal expected_value, actual_value
end
def teardown
# runs after every test method
end
end
Minitest::Test— the base class every assertion-style test class inherits from.test_*method — any instance method whose name starts withtest_is run as a test; anything else is ignored.setup/teardown— optional hooks that run before/after each test on a fresh instance.assert_equal(expected, actual)— note the argument order: expected value first, actual value second.
Common assertions you’ll reach for constantly:
| Assertion | Passes when… |
|---|---|
assert(obj) |
obj is truthy (anything except nil/false) |
refute(obj) |
obj is falsy (nil or false) |
assert_equal(expected, actual) |
expected == actual |
assert_nil(obj) |
obj is nil |
assert_includes(collection, obj) |
collection includes obj |
assert_instance_of(klass, obj) |
obj.class == klass |
assert_in_delta(expected, actual, delta) |
actual is within delta of expected (for floats) |
assert_raises(*exceptions) { ... } |
the block raises one of the given exception classes |
assert_respond_to(obj, method) |
obj responds to method |
assert_empty(collection) |
collection is empty |
Every assert_* has a refute_* counterpart (refute_equal, refute_nil, refute_includes, and so on) that checks the opposite.
Examples
Example 1: Testing a simple class
require "minitest/autorun"
class Calculator
def add(a, b) = a + b
def divide(a, b)
raise ZeroDivisionError, "cannot divide by zero" if b.zero?
a / b
end
end
class CalculatorTest < Minitest::Test
def setup
@calc = Calculator.new
end
def test_add_returns_the_sum_of_two_numbers
assert_equal 5, @calc.add(2, 3)
end
def test_add_handles_negative_numbers
assert_equal(-1, @calc.add(2, -3))
end
def test_divide_raises_on_zero
assert_raises(ZeroDivisionError) { @calc.divide(10, 0) }
end
end
Output:
Run options: --seed 21536
# Running:
...
Finished in 0.001966s, 1525.9410 runs/s, 1525.9410 assertions/s.
3 runs, 3 assertions, 0 failures, 0 errors, 0 skips
The exact seed number and timing will differ every time you run it — that’s expected, it’s just a random seed for test ordering and a wall-clock timer. What matters is the summary line: three test methods ran (one per test_ method), three assertions were checked, and nothing failed. Each dot on the ... line represents one passing test; a failure would print F and an unexpected exception would print E in that same position.
Example 2: More assertion types
require "minitest/autorun"
class StringToolbox
def self.shout(text) = text.upcase + "!"
def self.first_word(text)
text.split.first
end
end
class StringToolboxTest < Minitest::Test
def test_shout_uppercases_and_adds_exclamation
assert_equal "HELLO!", StringToolbox.shout("hello")
end
def test_first_word_extracts_leading_word
assert_equal "Ruby", StringToolbox.first_word("Ruby is fun")
end
def test_first_word_returns_nil_for_empty_string
assert_nil StringToolbox.first_word("")
end
def test_shout_result_includes_exclamation_mark
assert_includes StringToolbox.shout("hi"), "!"
end
def test_shout_returns_a_string
assert_instance_of String, StringToolbox.shout("hi")
end
end
Output:
Run options: --seed 8842
# Running:
.....
Finished in 0.001512s, 3306.9v runs/s, 3306.9 assertions/s.
5 runs, 5 assertions, 0 failures, 0 errors, 0 skips
Five test_ methods means five runs. "".split returns an empty array, so .first on it is nil, which is exactly what assert_nil checks for. Notice how much more readable assert_includes and assert_instance_of are than writing the equivalent boolean expression by hand with a plain assert — when they fail, Minitest’s failure message names the specific mismatch instead of just saying "expected truthy, got false".
Example 3: Spec style with setup
require "minitest/autorun"
class ShoppingCart
def initialize
@items = []
end
def add(item, price:)
@items << { item: item, price: price }
self
end
def total = @items.sum { |entry| entry[:price] }
def empty? = @items.empty?
end
describe ShoppingCart do
before do
@cart = ShoppingCart.new
end
it "starts empty" do
_(@cart.empty?).must_equal true
end
it "adds up the price of items" do
@cart.add("book", price: 12).add("pen", price: 3)
_(@cart.total).must_equal 15
end
end
Output:
Run options: --seed 4471
# Running:
..
Finished in 0.000987s, 2026.3s runs/s, 2026.3 assertions/s.
2 runs, 2 assertions, 0 failures, 0 errors, 0 skips
before gives every it block a fresh @cart, exactly like setup would. add returns self, which is what makes @cart.add(...).add(...) chainable. Under the hood, each it "description" do ... end became a generated method like test_0001_starts empty on a Minitest::Spec subclass — it’s the exact same runner as Examples 1 and 2, just with friendlier syntax on top.
How It Works Step by Step
When you run ruby calculator_test.rb from Example 1:
- Ruby loads
minitest/autorun, which requires the core Minitest library and registers anat_exitcallback. - Your file defines
CalculatorandCalculatorTest < Minitest::Test. Nothing runs yet — these are just class definitions. - The script reaches the end of the file with no more top-level code to execute, so the Ruby VM begins its shutdown sequence.
- The
at_exithook fires. Minitest looks at every loaded subclass ofMinitest::Test, reflects on each one for methods starting withtest_, and builds the full list of tests to run, then shuffles that list using the random seed. - For each test, Minitest instantiates a new instance of the test class, calls
setup(if defined), calls the test method itself, then callsteardown(if defined) —teardownruns even if the test method raised. - Every assertion inside the test method executes. If all of them pass, the test’s outcome is recorded as a run with no failure. If an assertion fails, the raised
Minitest::Assertionis caught and the outcome becomes a failure. If some other exception escapes (aNoMethodError, for example), the outcome is recorded as an error instead of a failure — Minitest distinguishes "your assumption was wrong" from "your code blew up unexpectedly". - After every test has run, Minitest prints the summary line (runs, assertions, failures, errors, skips) and exits with a nonzero status code if anything failed — which is exactly what lets a CI pipeline detect a broken build.
Common Mistakes
1. Forgetting the test_ prefix
Minitest only recognizes methods that start with test_. A perfectly valid-looking method that’s merely named differently is silently ignored — no warning, no error.
require "minitest/autorun"
class OrderTest < Minitest::Test
def check_total_is_calculated_correctly
assert_equal 30, 10 + 20
end
end
Output:
Run options: --seed 1200
# Running:
Finished in 0.000123s, 0.0000 runs/s, 0.0000 assertions/s.
0 runs, 0 assertions, 0 failures, 0 errors, 0 skips
Zero runs, zero failures — it looks like a clean pass, but nothing was actually checked. Rename the method so it starts with test_:
require "minitest/autorun"
class OrderTest < Minitest::Test
def test_total_is_calculated_correctly
assert_equal 30, 10 + 20
end
end
2. Reversing the arguments to assert_equal
assert_equal takes expected first and actual second. Since equality is symmetric, reversing them doesn’t change whether the test passes or fails — but it does scramble the failure message, making a real bug much harder to diagnose.
require "minitest/autorun"
class OrderMismatchTest < Minitest::Test
def test_wrong_argument_order
actual = 41
expected = 42
assert_equal actual, expected
end
end
Output:
Run options: --seed 5531
# Running:
F
Failure:
OrderMismatchTest#test_wrong_argument_order [order_mismatch_test.rb:6]:
Expected: 41
Actual: 42
1 runs, 1 assertions, 1 failures, 0 errors, 0 skips
The message reads "Expected: 41", but 41 was actually the computed value and 42 was the real expectation — exactly backwards. Always pass the known-correct value first:
require "minitest/autorun"
class OrderMismatchTest < Minitest::Test
def test_correct_argument_order
actual = 42
expected = 42
assert_equal expected, actual
end
end
3. Comparing floats with assert_equal
Floating-point arithmetic isn’t exact, so two mathematically equal-looking floats can differ in their last bits.
require "minitest/autorun"
class FloatMathTest < Minitest::Test
def test_adding_decimals_the_wrong_way
assert_equal 0.3, 0.1 + 0.2
end
end
Output:
Run options: --seed 902
# Running:
F
Failure:
FloatMathTest#test_adding_decimals_the_wrong_way [float_math_test.rb:4]:
Expected: 0.3
Actual: 0.30000000000000004
1 runs, 1 assertions, 1 failures, 0 errors, 0 skips
0.1 + 0.2 is 0.30000000000000004 in IEEE 754 floating point, not exactly 0.3, so a strict assert_equal fails even though the code is correct. Use assert_in_delta for any float comparison:
require "minitest/autorun"
class FloatMathTest < Minitest::Test
def test_adding_decimals_within_a_delta
assert_in_delta 0.3, 0.1 + 0.2, 0.0001
end
end
Best Practices
- Name test methods after the behavior they verify, e.g.
test_add_handles_negative_numbers, so a failure report reads like a sentence. - Keep
assert_equal(expected, actual)argument order consistent so failure messages stay meaningful. - Use
assert_in_deltaorassert_in_epsilonfor any floating-point comparison, neverassert_equal. - Keep tests independent — never rely on one test’s side effects to make another test pass; Minitest randomizes order specifically to catch this.
- Put shared setup logic in
setup(orbeforein spec style) rather than at the top of the file, so every test gets fresh state. - Prefer specific assertions (
assert_includes,assert_empty,assert_instance_of) over a genericassertwith a hand-rolled boolean — the failure messages are far more useful. - Use
assert_raisesto test error conditions instead of manually rescuing exceptions inside a test. - Organize test files under a
test/directory, named to mirrorlib/(e.g.lib/calculator.rb→test/calculator_test.rb), and wire them into a Rake task sorake testruns the whole suite.
Practice Exercises
- Write a
Temperatureclass with ato_fahrenheitmethod, then write aMinitest::Testclass that checks0degrees Celsius converts to32and100degrees Celsius converts to212. - Rewrite the test from the first exercise using the
describe/itspec style with_()expectations instead of assertion methods. - Given a
Stackclass withpushandpopmethods wherepopraisesRuntimeErroron an empty stack, write tests that useassert_raisesto confirm the error, andassert_equalto confirmpopreturns items in last-in-first-out order.
Summary
- Minitest ships with Ruby —
require "minitest/autorun"is all you need to get started. - A method counts as a test only if its name starts with
test_; spec-styleitblocks are compiled into the same kind of method under the hood. - Assertions raise
Minitest::Assertionon failure; the runner catches it per test so one failure doesn’t stop the suite, and distinguishes failures from unexpected errors. setup/teardown(orbeforein spec style) run around every test on a fresh instance, so tests never accidentally share state through instance variables.- Always pass
assert_equal(expected, actual)in that order, and useassert_in_deltafor floats. - Tests run in random order by default specifically to catch hidden ordering dependencies between them.
