In RSpec, how do the block matchers change, output and yield_control observe what the code in expect { } did?
answer
- run the block, compare before and after
- by, from and to
- do/end binds to to, not change
- output swaps $stdout for a StringIO
- yield_control needs the probe passed on
basics
~20 schange { order.total_cents } evaluates the value before and after running the expect block and compares them, refined by by, from and to. output(...).to_stdout captures what the block prints; yield_control checks that the method yielded to the probe block it was given.
solid answer
~40 s`expect { order.add(item) }.to change { order.total_cents }.by(500)` runs the value block, then the code, then the value block again, and checks the difference; `from(1999).to(2499)` checks both ends, and `change(order, :total_cents)` is the receiver-and-message form. A plain `change` passes if the values differ or the same object's `hash` changed, so in-place mutation counts. The value block must use braces: with `do ... end` Ruby binds it to `to`, and RSpec raises a `SyntaxError` saying the block was not received. `output("Receipt\n").to_stdout` temporarily replaces `$stdout` with a `StringIO`, so it misses subprocess output and writes to the `STDOUT` constant; `to_stdout_from_any_process` reopens the stream to a tempfile instead. `yield_control` needs the probe: `expect { |b| order.each_item(&b) }.to yield_control.twice`.
code
ruby · 14 linesRSpec.describe Order do
let(:order) { Order.new }
it "adds the item price to the total" do
expect { order.add(LineItem.new(sku: "B2", price_cents: 500)) }
.to change { order.total_cents }.by(500)
.and change { order.line_items.size }.by(1)
end
it "prints the total on the receipt" do
order.add(LineItem.new(sku: "B2", price_cents: 500))
expect { order.print_receipt }.to output(/Total: 5\.00/).to_stdout
end
endgo deeper
Know that change, output and yield_control all need expect with braces, and that by, from and to refine change.
Explain how change compares before and after values, why do/end breaks it, and how output swaps $stdout for a StringIO.
Diagnose silent output specs caused by subprocesses or STDOUT, and replace brittle before-and-after reads with change matchers that document intent.
Weigh slow any-process capture and side-effect assertions against moving printing and state changes behind seams that return values.
## Block matchers observe effects A value matcher looks at a result. A **block matcher** runs the code inside `expect { ... }` itself, so it can look at what happened **around** the call. `raise_error` is the best known; `change`, `output` and `yield_control` are the others interviewers ask about. All of them need the block form; handed a plain value they have nothing to run, and the expectation fails. | Matcher | What it observes | Refinements | |---|---|---| | `change { value }` | a value before and after the block | `by`, `by_at_least`, `by_at_most`, `from`, `to` | | `output(expected)` | text written to a stream during the block | `to_stdout`, `to_stderr`, `to_stdout_from_any_process`, `to_stderr_from_any_process` | | `yield_control` | whether the method yielded to a block | `once`, `twice`, `thrice`, `exactly(n).times`, `at_least(n).times`, `at_most(n).times` | ## change ```ruby expect { order.add(LineItem.new(sku: "B2", price_cents: 500)) } .to change { order.total_cents }.from(1999).to(2499) expect { order.add(item) }.to change(order, :total_cents).by(500) ``` The matcher evaluates the value, runs the expect block, and evaluates the value again: 1. With no refinement it passes when the two values differ with `!=`, **or** when their `hash` differs. The second test catches a method that returns the same Array object after pushing into it. 2. `by(n)` subtracts before from after and compares with `n`; `by_at_least` and `by_at_most` bound it. 3. `from(x)` and `to(y)` check the endpoints, and `from` also verifies the starting value, which documents the precondition. Two traps: - **do/end binding.** In `expect { ... }.to change do order.total_cents end`, Ruby attaches the `do ... end` block to `to`, not to `change`. RSpec detects this and raises `SyntaxError` with "Block not received by the `change` matcher". Use braces. - **Negated refinements.** `not_to change { order.total_cents }` is supported, but `not_to change { ... }.by(1)` or `.to(...)` raises `NotImplementedError`, because "did not change by one" has no clear meaning. ## output `expect { order.print_receipt }.to output(/Total: 24\.99/).to_stdout` checks printed text. The expected value may be a String (exact), a Regexp, or omitted to mean "printed something". Without `to_stdout` or `to_stderr`, the matcher raises an error asking you to chain one. The ordinary forms work by assigning a `StringIO` to `$stdout` or `$stderr` for the duration of the block. Anything that bypasses those globals is missed: a child process, a C extension writing to file descriptor 1, or code that writes to the `STDOUT` constant it captured earlier. `to_stdout_from_any_process` reopens the real stream onto a tempfile, which catches all of these; its own comment calls it about 30 times slower, so use it only when needed. ## yield_control To see whether a method yields, the matcher gives the expect block a **probe** to pass on: ```ruby expect { |probe| order.each_item(&probe) }.to yield_control.twice ``` - The block parameter is the probe; forwarding it with `&probe` lets the matcher count the yields. - An expect block that takes no parameter is rejected with an error saying it must accept an argument; one that accepts the probe but never passes it on raises an error telling you to pass it to the method as a block. - Sibling matchers check the yielded arguments: `yield_with_args`, `yield_with_no_args` and `yield_successive_args`. ## Choosing between them - State that a method changes lives in an object or a store: `change`. - A command-line or logging side effect: `output`. - An iterator or callback API: `yield_control` and its argument-checking siblings. - Several of these at once: combine block matchers with `.and`, for example two `change` matchers on one action. ## Why not read the values by hand A spec can always record `before = order.total_cents`, run the action and compare. The matcher is still better for three reasons: - The failure message names the expression and both values, for example that the total should have changed by 500 but changed by 0. - `from` documents the precondition inside the same expectation instead of in a separate line a reader might skip. - The hash comparison catches in-place mutation that a hand-written `==` on the same object would miss.
- Why does `change { order.line_items }` pass when add pushes into the same Array object?Before and after are the same object, so `!=` is false, but RSpec records `hash` before the block runs and compares it afterwards. Pushing an element changes the Array's hash, so the matcher still reports a change.
- A receipt printer shells out to a formatting tool; why does output(...).to_stdout see nothing?`to_stdout` only replaces the `$stdout` global with a `StringIO` inside the Ruby process. A child process writes to the inherited file descriptor directly. `to_stdout_from_any_process` reopens that stream to a tempfile, so it captures the child's output at a large speed cost.
saying these in an interview costs you the question
- expect(order.add(item)).to change { order.total_cents } works without a block
- change do ... end and change { ... } are interchangeable
- change cannot detect a mutated array returned by the same method
- output(...).to_stdout captures everything any process prints
- yield_control works without passing the probe to the method