skip to content

In Minitest::Spec, what do describe, it, let and before turn into, and why must expectations be written as _(value).must_equal?

level: middleimportance: should knowfreq 42%

answer

  1. describe builds a Minitest::Spec subclass
  2. it defines test_0001_<desc>
  3. let: lazy, memoised per test
  4. before(:all) type is ignored
  5. _ aliased value and expect

basics

~20 s

describe creates a subclass of Minitest::Spec, which is a Minitest::Test; it defines test_ methods, before and after become setup and teardown, and let is a lazy per-test memoised method. Minitest 6 expectations work only on a wrapper: _(x), value(x) or expect(x).

solid answer

~40 s

`Minitest::Spec` is a thin DSL over `Minitest::Test`. `describe FineCalculator do ... end` creates a new **subclass** of `Minitest::Spec` (nested `describe` blocks subclass the outer one). `it "caps the fine"` defines an instance method named `test_0001_caps the fine`, so it runs like any `test_` method, on a fresh instance. `before` and `after` define `setup` and `teardown`; their `:each`/`:all` argument is ignored, so `before(:all)` still runs before every test. `let(:calc) { ... }` defines a method that runs its block **on first call and memoises it for that test only**. Expectations such as `must_equal` live on `Minitest::Expectation`: you wrap the value with `_(x)` (aliases `value` and `expect`), or pass a block, `_ { code }.must_raise ArgumentError`. Since 6.0 they are no longer defined on `Object`.

code

ruby · 12 lines
ruby
describe FineCalculator do
  let(:calc) { FineCalculator.new(daily_rate_cents: 25, cap_cents: 1_000) }

  it "rejects negative days" do
    error = _ { calc.fine_for(-1) }.must_raise ArgumentError
    _(error.message).must_match(/must be >= 0/)
  end

  it "waives fines for staff" do
    expect(calc.waiver_for(Member.new(role: :staff))).must_equal 100
  end
end

go deeper

for a junior

Recall that describe groups tests, it defines one, before and after wrap each test, and values are wrapped as _(value).must_equal expected.

for a middle

Explain the translation to Minitest::Test subclasses and test_ methods, lazy per-test let, the ignored before type, and block targets for must_raise.

for a senior

Show you can predict inheritance across nested describes, find the removed Object expectations in a 6.0 upgrade, and avoid shared state behind begin-end constants.

for a principal

Decide whether a team standardises on assertions or expectations, knowing the runtime is identical and the cost is consistency and onboarding.

## Specs are tests with different spelling `require "minitest/autorun"` loads both styles. The spec layer (`minitest/spec`) does not have its own runner or lifecycle; it generates ordinary `Minitest::Test` classes and methods. Knowing the translation explains every behaviour of a spec file. ```ruby describe FineCalculator do let(:calc) { FineCalculator.new(daily_rate_cents: 25, cap_cents: 1_000) } before { @member = Member.new(status: :active) } it "charges the daily rate" do _(calc.fine_for(3)).must_equal 75 end describe "when very late" do it "caps the fine" do _(calc.fine_for(90)).must_equal 1_000 end end end ``` ## The translation table | spec DSL | becomes | |---|---| | `describe X do` | a new subclass of `Minitest::Spec` (itself `< Minitest::Test`) | | nested `describe` | a subclass of the enclosing describe's class | | `it "desc" do` / `specify` | an instance method `test_0001_desc`, numbered per class | | `before do` | a `setup` method that calls `super()` then your block | | `after do` | a `teardown` method that runs your block then `super()` | | `let(:name) { }` | a method `name` that memoises the block's value in the test instance | | `subject { }` | `let(:subject) { }` | Consequences worth knowing: - **Nested describes inherit `let` and `before`**, because they are subclasses, but `it` blocks are deliberately **not** inherited: Minitest undefines them in child classes so an outer test does not re-run inside every inner group. - **`before(:all)` is not "once per group".** The type argument is ignored and exists only to ease porting from RSpec; the block runs before every test. For one-time expensive setup the Minitest README suggests a constant assigned with `begin ... end` inside the describe, with the usual care about shared state. - **`let` is lazy.** The block runs the first time the method is called in a test, then returns the cached value for the rest of that test; a new test instance starts empty. If nothing calls `calc`, it is never built. - **`let` names are policed.** A name starting with `test`, or one that would override a `Minitest::Spec` method, raises `ArgumentError`. ## Expectations and the `_` wrapper Each expectation is generated from an assertion: `must_equal` from `assert_equal`, `must_be_nil` from `assert_nil`, `must_include` from `assert_includes`, `must_raise` from `assert_raises`, and `wont_*` from the matching `refute_*`. They are methods of `Minitest::Expectation`, a small struct holding the **target** and the **test context**. You create one with: - `_(value)` - the canonical form; - `value(value)` and `expect(value)` - aliases of `_` for readability; - `_ { code }` - a block target, needed by `must_raise`, `must_output`, `must_be_silent` and `must_throw`. Because the wrapper stores the context, expectations work even inside threads started by the test, where the old thread-local lookup failed. Minitest 5.12 deprecated calling expectations directly on any object (`calc.fine_for(3).must_equal 75`), and **Minitest 6.0 removed them from `Object`**. In Minitest 6 that line raises `NoMethodError` and the test is reported as an Error. ## The block-versus-value trap `_(calc.fine_for(-1)).must_raise ArgumentError` does not work: Ruby evaluates the argument `calc.fine_for(-1)` **before** `_` is even called, so the `ArgumentError` escapes the test as an Error. The block form `_ { calc.fine_for(-1) }.must_raise ArgumentError` defers the call so `assert_raises` can catch it, and it returns the exception like `assert_raises` does. ## Common mistakes in spec files - Writing `before(:all)` to open a connection once and then wondering why the suite is slow: it opens one per test. - Relying on `let` for side effects (creating a record) that a test never triggers because it never calls the method. - Using `_(expr).must_raise` instead of `_ { expr }.must_raise`, which turns the expected exception into an Error. - Expecting an outer `it` to run again inside each nested `describe`; only `let` and hooks are inherited. ## Mixing styles Inside an `it` block, plain assertions work too (`assert_equal 75, calc.fine_for(3)`), since the class is a `Minitest::Test`. Teams usually pick one style per suite, but the translation means there is no runtime difference, only spelling.

  • In Minitest::Spec, an outer describe has two it blocks and a nested describe has one. How many tests run?
    Three. Each `it` defines a `test_` method on its own class, and Minitest undefines the outer `it` methods in child classes so they are not inherited and re-run. The nested class still inherits the outer `let` and `before` definitions, because it is a subclass.
  • In Minitest::Spec, can you define a let named test_calc or setup?
    No. `let` raises `ArgumentError` when the name starts with `test`, since it would be collected as a test, or when it would override a method `Minitest::Spec` already defines, such as `setup`. `subject` is the one allowed exception, because it is itself a `let`.
  • In Minitest::Spec, does before(:all) run once for the describe block?
    No. `before` ignores its type argument, which exists only to make porting easier, and defines `setup`, so the block runs before every test. For one-time work, assign a constant with `begin ... end` inside the describe, and make sure the shared object is not mutated by tests.

saying these in an interview costs you the question

  • Minitest::Spec has its own runner, separate from Minitest::Test.
  • before(:all) in Minitest::Spec runs once per describe block.
  • let evaluates its block eagerly before each it block runs.
  • In Minitest 6, calc.fine_for(3).must_equal 75 works like before.
  • _(calc.fine_for(-1)).must_raise ArgumentError catches the exception.