skip to content

What is interaction verification in a unit test, and how does it differ from asserting on returned state?

level: juniorimportance: must knowfreq 70%

answer

  1. Two different oracles for one run
  2. Some effects leave nothing to return
  3. Assert on a record of calls
  4. Commands verified, queries asserted through results
  5. Buys visibility, costs implementation coupling

basics

~20 s

Interaction verification asserts how the unit called its collaborators - which method, how often, with which arguments. State verification only checks what came back or the state afterwards. Use interaction checks when the effect leaves nothing to return.

solid answer

~50 s

Every test needs an oracle - something that decides whether the run was right. State-based verification uses the returned value or the object's state as that oracle. Interaction verification replaces a collaborator with a stand-in that records calls, exercises the unit, then asserts on that record. It is the right tool when the behaviour under test *is* an outgoing call - dispatching a job, publishing an event, writing an audit entry - because those effects leave nothing local to inspect. A useful rule is command versus query: verify commands, which change something outside the unit; for queries, assert the result they influenced rather than re-verifying the call. The price is coupling - the test now knows how the unit works, so a behaviour-preserving refactor can still break it. Prefer state assertions where they exist, and verify interactions where there is no other way to see the effect.

code

pseudocode · 12 lines
pseudocode
# state oracle: the returned value decides
plan = sizer.planFor(sourceHeight = 2160)
assert plan.renditions == [1080, 720, 480]

# interaction oracle: the recorded call decides
queue = recordingStandIn(JobQueue)
dispatcher = Dispatcher(queue)

dispatcher.onUploadFinished(assetId = "a-8317")

assert queue.callsTo("enqueue").count == 1
assert queue.callsTo("enqueue").first.arg("assetId") == "a-8317"

go deeper

for a junior

Be ready to define both oracles in one breath: state verification checks the returned value or resulting state, interaction verification checks a record of the calls the unit made to its collaborators. Have one example of a method that returns nothing.

for a middle

An interviewer expects the mechanics: the collaborator must be injectable, the stand-in records calls, and the assertions run after the exercise. Be able to explain the command-versus-query rule and why verifying a stubbed query is redundant.

for a senior

Show the judgment call. Say out loud that interaction verification buys visibility into effects that leave the unit and costs coupling to how the unit works, and give a case where you replaced a verification with an assertion on a stand-in's resulting state.

for a principal

Own the suite-wide consequence: a codebase that reaches for call verification by default ossifies, because every refactor turns tests red for non-defects. Be ready to describe the default you would set and the seams where you would deliberately break it.

## Two oracles for the same run Every test needs an **oracle**: the thing that decides whether what happened was right or wrong. Most tests use the cheapest oracle available - call the unit, look at what comes back or at the object's state afterwards, and compare it with an expected value. That is **state-based verification**. **Interaction verification** (also called behaviour verification) uses a different oracle. You replace one of the unit's collaborators with a stand-in that records every call made to it, exercise the unit, and then assert on that record: this method was called, this many times, with these arguments, and sometimes before that other call. Neither is the better technique in the abstract; they answer different questions. State-based verification answers *"did the unit compute the right thing?"* Interaction verification answers *"did the unit ask the outside world for the right thing?"* ## Why the second oracle exists at all A large share of production code returns nothing interesting. Take a dispatcher in a video-transcoding queue: when an upload finishes, it works out which renditions are needed, narrows the caller's credential scope, and hands the job to a queue. The method returns nothing. No field on the dispatcher changes. With only state assertions available, all a test can say is that the call did not throw - which passes just as happily for an empty method body. The effect that mattered left the unit through a collaborator, so the record of that call is the only place the behaviour is visible. That record becomes the oracle. This is the honest case for interaction verification, and it is what an interviewer wants to hear first: *when the behaviour under test is an outgoing call, there is nothing else to assert on.* ## What it requires Three things. First, a **seam**: the collaborator must be replaceable from the test, usually because it is passed in rather than constructed inside the method. Second, a **recording stand-in**: an object that satisfies the collaborator's interface and keeps a log of the calls it received. Third, a **verification phase**. In the common record-then-verify style the verification sits in the assert phase, after the exercise, and reads like any other assertion. An older expect-run-verify style declares the expected calls *before* the exercise, so an unexpected call fails at the moment it happens; the visible difference is where in the test the expectation is written. ## Command versus query The most useful rule for deciding which of the two oracles to use comes from command-query separation (Bertrand Meyer): a **command** changes state somewhere and returns nothing meaningful; a **query** returns a value and changes nothing. - **Verify commands.** Enqueuing a job, publishing an event, writing an audit record, sending a notification - these have no local result, and the call is the behaviour. - **Assert the consequences of queries.** If a collaborator only hands data back, the unit's own result already depends on it. Asserting that the result is right proves the query happened and that its answer was used correctly. Adding `verify(collaborator.lookup(...))` on top says nothing new and adds a second reason for the test to break. ## The price you pay Interaction verification couples the test to *how* the unit works, not just *what* it does. Split the method in two, extract a helper, or batch two enqueue calls into one call carrying a list, and the behaviour is identical while the verification fails. A test that fails when behaviour did not change is a false alarm, and a suite full of false alarms teaches a team to "fix the test" reflexively - which is eventually how a real regression gets waved through. So the default ordering is: prefer a state assertion where one exists; reach for interaction verification when the effect leaves the unit and there is no other way to see it. ## A middle route Instead of asserting on a call log, some tests use a lightweight working implementation of the collaborator and assert *its* resulting state - the queue stand-in holds the jobs it accepted, and the test asserts it holds exactly one job for asset `a-8317`. The oracle is state again, but the state belongs to the stand-in rather than to the unit. This is usually less sensitive to how the call was made: batching two enqueues into one call with two entries leaves the same jobs in the stand-in, and the test survives. ## Verifying that something did *not* happen The mirror image is often the more valuable assertion. If an upload submitted under a viewer-level role must never reach the privileged re-encode path, the only proof is a verification that the escalating call was never made. Two cautions: an assertion that a call never happened passes trivially when the call could never have happened - a typo in the method or argument you are matching, or a test that never reaches the branch at all - so always pair it with a positive test that *does* drive the call, proving the verification can fail. ## What interviewers listen for That you can name both oracles; that you can give a concrete case where state assertion is impossible; that you know the cost is coupling to implementation; that you do not verify queries you have already stubbed; and that you treat "never called" assertions as needing a companion positive test.

  • How would you prove that your code did not make a particular call at all?
    Assert that the collaborator's method was never called. The catch is that such an assertion passes trivially when the call could never have matched - a mistyped method or argument, or a test that never reaches the branch. Pair it with a positive test that does drive the same call, so you have proof the check is capable of failing.
  • Where do the verification statements belong in the structure of the test?
    In the assert phase, after the unit has been exercised, alongside any state assertions - that keeps the test readable as arrange, act, assert. An older style declares expected calls before the exercise so an unexpected call fails the moment it happens; the trade is an earlier failure point against a test whose expectations are written before the code that triggers them.
  • If the unit returns a meaningful value and also calls a collaborator, do you need both kinds of assertion?
    Usually yes, but they should not overlap. Assert the returned value for the computation, and verify only the outgoing call that nothing else can see. If the collaborator merely supplied data used in the result, the result assertion already covers it and a verification adds a second reason to fail without adding information.

State verification is tasting the finished dish; interaction verification is reading the kitchen's order tickets to see what the cook actually asked for.

saying these in an interview costs you the question

  • Claims verifying calls is always stronger than checking state
  • Verifies a query call and never asserts the result it fed
  • Thinks a passing never-called assertion proves the path ran
  • Mirrors every line of the method in a verification
  • Cannot say what to assert when a method returns nothing
  • Treats a verification broken by a refactor as a real defect

context