Testing with Minitest

Published by Programming Line in Ruby. Last updated .

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 with test_ 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 an at_exit callback.
  • Your file defines Calculator and CalculatorTest < 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_exit hook fires. Minitest looks at every loaded subclass of Minitest::Test, reflects on each one for methods starting with test_, 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 calls teardown (if defined) — teardown runs 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::Assertion is caught and the outcome becomes a failure. If some other exception escapes (a NoMethodError, 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_delta or assert_in_epsilon for any floating-point comparison, never assert_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 (or before in 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 generic assert with a hand-rolled boolean — the failure messages are far more useful.
  • Use assert_raises to test error conditions instead of manually rescuing exceptions inside a test.
  • Organize test files under a test/ directory, named to mirror lib/ (e.g. lib/calculator.rb → test/calculator_test.rb), and wire them into a Rake task so rake test runs the whole suite.

Practice Exercises

  • Write a Temperature class with a to_fahrenheit method, then write a Minitest::Test class that checks 0 degrees Celsius converts to 32 and 100 degrees Celsius converts to 212.
  • Rewrite the test from the first exercise using the describe/it spec style with _() expectations instead of assertion methods.
  • Given a Stack class with push and pop methods where pop raises RuntimeError on an empty stack, write tests that use assert_raises to confirm the error, and assert_equal to confirm pop returns 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-style it blocks are compiled into the same kind of method under the hood.
  • Assertions raise Minitest::Assertion on failure; the runner catches it per test so one failure doesn’t stop the suite, and distinguishes failures from unexpected errors.
  • setup/teardown (or before in 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 use assert_in_delta for floats.
  • Tests run in random order by default specifically to catch hidden ordering dependencies between them.