A Minitest test is a plain class¶
Level: 201 · a Python programmer who has written a unittest.TestCase and wants the Ruby equivalent, warts included
One line: Minitest, a bundled gem, runs every test_* method of a Minitest::Test subclass in a seeded random order and reports runs, assertions, failures, errors, skips — the same shape as unittest, whose TestCase runs test_* methods alphabetically and ships mock in the standard library, which Minitest 6 no longer does.
A Minitest test file is three things you already know: a class inheriting Minitest::Test, methods whose names start with test_, and assertions that are ordinary method calls — assert_equal expected, actual (expected first), assert_raises(Klass) { … } which returns the exception so you can inspect its message, assert_in_delta for floats, skip to mark a test as not run. setup runs before each test, teardown after. The run below is in-process: Minitest.run(["--seed", "1"]) is exactly what ruby test_calc.rb --seed 1 does after a require "minitest/autorun", with the seed fixed so the shuffled order is the same every time and the two timing lines (Finished in …, … runs/s) dropped, since they are the only nondeterministic lines a test run prints. The same file also holds a describe/it block: minitest/spec is a thin DSL over the same classes, where _(value).must_equal 2 is assert_equal 2, value. One test fails on purpose so you can see the report's Expected: / Actual: block, and one is skipped.
Verified output of minitest_and_unittest_rb.rb — regenerated by tools/run_examples.py, never hand-typed.
1. the report of Minitest.run(["--seed", "1"]), timing lines removed:
Run options: --seed 1
# Running:
..FS..
1) Failure:
TestCalc#test_deliberate_failure [FILE:LINE]:
Expected: {name: "a", n: 1}
Actual: {name: "b", n: 1}
6 runs, 7 assertions, 1 failures, 0 errors, 1 skips
You have skipped tests. Run with --verbose for details.
2. Minitest.run returned false (false because one test failed)
3. require "minitest/mock": LoadError -- not bundled with Minitest 6
Reading the output¶
The report opens with Run options: --seed 1, then one character per test as it runs: . passed, F failed, S skipped, E would be an error (an exception the test did not expect). The order ..FS.. is the seed's shuffle, not the source order — Minitest randomizes deliberately so that tests which depend on each other are found early. The failure block names the test as TestCalc#test_deliberate_failure with its file and line (replaced by [FILE:LINE] here) and prints the expected and actual values with inspect, in 4.0's {name: "a"} hash style. The summary line counts six runs (five test_ methods plus the spec's it), seven assertions, one failure and one skip; the spec block's test contributed one run under a generated name. Minitest.run returned false (row 2), which is what makes a real test process exit non-zero. Row 3 is a change worth knowing: require "minitest/mock" raises LoadError under the Minitest 6 that ships with Ruby 4.0, because Minitest::Mock and Object#stub were split out into the minitest-mock gem (Not machine-checked here.) — so the test double in test_double_by_hand is a plain object with a singleton method, which is often all you need.
Compared with Python¶
unittest is the same design with the names changed: TestCase for Minitest::Test, setUp/tearDown, assertEqual(first, second) (no expected-first convention; the diff labels them - and +), assertRaises as a context manager exposing cm.exception, assertAlmostEqual, @unittest.skip. The twin runs TextTestRunner(verbosity=2) on a StringIO stream and removes the Ran 6 tests in 0.001s timing and the traceback's indented lines (file paths, source and 3.13's caret markers, which differ between Python releases). The visible differences: tests run in alphabetical order, always, so an order-dependence bug can hide; the verbose report names every test with its qualified name __main__.TestCalc.test_add; the assertion failure shows a unified diff of the two dicts with ? marker lines under the changed characters; and subTest (row test_subtests) lets one test method report each parameter set separately, which Minitest lacks — a Ruby loop inside a test stops at the first failed assertion. Row 3 is the mirror of Ruby's row 3: unittest.mock — Mock, MagicMock, patch — is standard library, so patch.object(Calc, "add", return_value=42) needs no install. pytest, with its bare assert and rewritten assertion messages, is the community's usual choice, and it runs unittest classes unchanged (Not machine-checked here.).
Verified output of minitest_and_unittest_py.py — regenerated by tools/run_examples.py, never hand-typed.
1. the report of TextTestRunner(verbosity=2), timing and traceback lines removed:
test_add (__main__.TestCalc.test_add) ... ok
test_deliberate_failure (__main__.TestCalc.test_deliberate_failure) ... FAIL
test_div_by_zero (__main__.TestCalc.test_div_by_zero) ... ok
test_double_with_mock (__main__.TestCalc.test_double_with_mock) ... ok
test_skipped (__main__.TestCalc.test_skipped) ... skipped 'not today'
test_subtests (__main__.TestCalc.test_subtests) ... ok
======================================================================
FAIL: test_deliberate_failure (__main__.TestCalc.test_deliberate_failure)
----------------------------------------------------------------------
Traceback (most recent call last):
AssertionError: {'name': 'a', 'n': 1} != {'name': 'b', 'n': 1}
- {'n': 1, 'name': 'a'}
? ^
+ {'n': 1, 'name': 'b'}
? ^
----------------------------------------------------------------------
Ran 6 tests in (time removed)
FAILED (failures=1, skipped=1)
2. result.wasSuccessful() is False (one failure): ran 6, failures 1, errors 0, skipped 1
3. import unittest.mock: loaded -- Mock, patch and MagicMock are in the stdlib: True
Try it¶
- Write a
Stackclass withpush,pop,empty?and aStack::Emptyerror for popping an empty stack, then aMinitest::Testwith three tests: it starts empty, push-then-pop is last-in-first-out, and popping when empty raises with the messagestack is empty. Run it in-process with--seed 1and drop the timing lines. - Add a
teardownthat appends the test's name (name) to a list, run the suite twice with seeds1and2, and print the two orders side by side to see the shuffle. Then run once with["--seed", "1", "--verbose"]and see the skipped test listed by name.
Solution to 1
minitest_and_unittest_kata_rb.rb in full — pasted here by tools/run_examples.py from the file CI runs.
# Kata: test a Stack with Minitest -- push, pop, empty?, and the error on an
# empty pop -- run with a fixed seed and the timing lines removed.
require "minitest"
require "stringio"
class Stack
class Empty < StandardError; end
def initialize = @items = []
def push(x) = (@items.push(x); self)
def pop = @items.empty? ? raise(Empty, "stack is empty") : @items.pop
def empty? = @items.empty?
def size = @items.size
end
class TestStack < Minitest::Test
def setup
@stack = Stack.new
end
def test_starts_empty
assert_empty @stack.instance_variable_get(:@items)
assert_predicate @stack, :empty?
end
def test_push_then_pop_is_lifo
@stack.push(1).push(2)
assert_equal 2, @stack.pop
assert_equal 1, @stack.pop
assert @stack.empty?
end
def test_pop_on_empty_raises
error = assert_raises(Stack::Empty) { @stack.pop }
assert_equal "stack is empty", error.message
end
end
buffer = StringIO.new
$stdout = buffer
passed = Minitest.run(["--seed", "1"])
$stdout = STDOUT
buffer.string.each_line do |line|
print line unless line.match?(/Finished in|runs\/s/)
end
puts "passed: #{passed}"
Verified output of minitest_and_unittest_kata_rb.rb — regenerated by tools/run_examples.py, never hand-typed.
See also¶
- The command-line flags —
ruby -Ilib -Itest test/test_calc.rbis how a test file is usually started - Backtraces and
caller— what the[FILE:LINE]in a failure block comes from - Custom exception classes — the
Stack::Emptypattern the kata uses, and whatassert_raisesreturns - Gems, Bundler and the Gemfile — why
minitestis a bundled gem andminitest-mockis not - Resources — Minitest, test-unit, RSpec and pytest documentation
- Rust: a first test, step by step ↗ — the same three-line test in a language where the test runner is the build tool
- Rust: a test double by hand ↗ — the singleton-method double of row 3, done with a trait
- Refinements are lexical, not global —
refine String do … endinside a module builds aRefinement, andusingthat module activates it from… - Ruby has no docstrings — A Ruby method carries no documentation at run time