---
title: 'Behavior-Driven Testing'
source: 'https://academia.sh/en/courses/java-ecosystem/behavior-driven-testing'
course: 'Build, Testing and the Ecosystem'
language: en
updated: '2026-09-13T05:08:58+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Counts whether every step in the scenario text has an executable counterpart in the step map'
    - 'Shows that a step whose counterpart cannot be found is silently skipped in one run and halts the program in another'
    - 'Proves that a full match in step count does not show correctness against a map whose assertion has been removed'
---

# Behavior-Driven Testing

A four-line scenario text is run against a step map; a step with no counterpart in the map is silently skipped in one run and halts the program in another. Even when the same four steps match completely, running against a map whose check steps have had their assertion removed still says "passed" over a wrong value.

In the previous lesson, the system under test grew: a dependency mocked in the unit setup
came back with its real implementation, and the class path the test sees rose to two types. In
this lesson, the side that grows is different: it is not the code under test, it is the test
**itself** that moves closer to prose. A scenario is a list of steps written in ordinary
sentences — "10 units were added to the store," "the store quantity must be 7," and so on —
and these sentences are expected to have an executable counterpart in a step map. The question
is: how many of the text's four steps have a real counterpart, how does a step with no
counterpart affect the run, and does every step matching mean this scenario runs
**correctly**?

The appeal of a scenario language is that a non-technical reader can also understand the
steps — the sentence "10 units were added to the store" carries meaning even for someone who
does not know Java. But this readability carries a cost: prose is not a syntax the compiler
can check. When a method name is misspelled, the compiler stops immediately; when a scenario
line is misspelled or never implemented, only the run itself notices. This lesson shows, by
actually running, how that difference gets noticed and what it means when it does not.

The concept of behavior-driven development was established in the M21/K02 Unit Testing and
Test-Driven Development course and is not reintroduced here. The one question this lesson
adds is the **match** between the steps the scenario text declares and the steps that actually
exist in the step map.

This question is really a variation on the question this topic has been asking from the
start. In the first lesson the question was "does this type exist on the test class path,"
and the type was concrete, a compiled `.class` file. In this fifth lesson the type gives way
to a sentence: every line in the scenario text needs a behavior in the step map to answer for
it. The difference is not merely a naming issue — the compiler tells us whether a type is
present on the class path, but no one tells us whether a text line matches a behavior; this
lesson builds and counts that match by hand.

## A Scenario Is a Declaration, the Step Map Provides Its Counterpart

- **TE21.** Scenario steps are matched here as plain strings (`Map<String, ...>`). Real
  frameworks' mechanism for pattern matching, defining steps with regular expressions, and
  extracting parameters is not built here.
- **TE22.** A step "matching" means its text is found with an exact key in the map. This shows
  that the step has an executable counterpart, not that the step runs **correctly** — the two
  are separate questions.
- **TE23.** Two separate run strategies, "silent" and "halting," are written by hand here.
  Which of the two real frameworks pick as a default, or how it is configured, is not modeled
  here.

The scenario text consists of four lines and is declared as an array of `record`s — the same
method the third lesson used to declare its data set, carrying prose steps here instead of
data. The step map, in turn, binds each line's text to an executable body. The `missingMap`
below contains no entry at all for the fourth step's text — a deliberate omission. `silentRun`
skips a step whose counterpart cannot be found and continues; `haltingRun` throws an exception
in the same situation and cuts the run short.

The map itself is a `LinkedHashMap` — not an ordinary map, but one that preserves insertion
order. This preserves the map's own **registration** order, not the scenario's order; the map
itself does not decide which order the steps run in, that decision is made by the order in the
`STEPS` array. The only practical benefit of order preservation here is being able to see, when
reading the two maps side by side, in which order the steps were defined — the run itself
walks the `STEPS` array from start to end and does not look at the map's internal order.

The step map itself is a natural extension of the test class path concept the first lesson
established. `missingMap` and `blankAssertionMap` appear nowhere in production code; both are
mappings known only to the test side and run only by the test side. The scenario text itself
is the same — the `STEPS` array is not data production code reads, it is a declaration the
test reads. What is new in this lesson is that this declaration stands in **prose** form, and
its counterpart is a string key, not a type name.

```java
// Scenario.java — a scenario text is a declaration; does a step with no counterpart in the step map stay silent or halt the run
import java.util.*;
import java.util.function.Consumer;

public class Scenario {

    record Step(String text) {}

    static final Step[] STEPS = {
        new Step("10 units were added to the store"),
        new Step("3 units were removed from the store"),
        new Step("the store quantity must be 7"),
        new Step("the store quantity must not drop below zero"),
    };

    // -- step map is missing: the fourth step has no record at all -------------------------
    static Map<String, Consumer<int[]>> missingMap() {
        Map<String, Consumer<int[]>> m = new LinkedHashMap<>();
        m.put("10 units were added to the store", state -> state[0] += 10);
        m.put("3 units were removed from the store", state -> state[0] -= 3);
        m.put("the store quantity must be 7", state -> { if (state[0] != 7) throw new AssertionError("quantity is not 7: " + state[0]); });
        return m;
    }

    // -- step map is complete: all four are registered, but the check steps' assertion is removed --
    static Map<String, Consumer<int[]>> blankAssertionMap() {
        Map<String, Consumer<int[]>> m = new LinkedHashMap<>();
        m.put("10 units were added to the store", state -> state[0] += 10);
        m.put("3 units were removed from the store", state -> state[0] -= 2); // deliberate bug: removes 2, not 3
        m.put("the store quantity must be 7", state -> {});                   // assertion removed
        m.put("the store quantity must not drop below zero", state -> {});    // assertion removed
        return m;
    }

    static void silentRun(Step[] scenario, Map<String, Consumer<int[]>> map, int[] state) {
        int matched = 0;
        for (Step s : scenario) {
            Consumer<int[]> action = map.get(s.text());
            if (action == null) { System.out.println("skipping     : " + s.text()); continue; }
            action.accept(state);
            matched++;
        }
        System.out.println("matched steps: " + matched + "/" + scenario.length);
        System.out.println("result       : passed, store=" + state[0]);
    }

    static void haltingRun(Step[] scenario, Map<String, Consumer<int[]>> map, int[] state) {
        int matched = 0;
        for (Step s : scenario) {
            Consumer<int[]> action = map.get(s.text());
            if (action == null) throw new IllegalStateException("no counterpart for step: " + s.text());
            action.accept(state);
            matched++;
        }
        System.out.println("matched steps: " + matched + "/" + scenario.length);
        System.out.println("result       : passed, store=" + state[0]);
    }

    public static void main(String[] args) {
        System.out.println("-- missing map, silent run --");
        silentRun(STEPS, missingMap(), new int[]{0});

        System.out.println("-- missing map, halting run --");
        try {
            haltingRun(STEPS, missingMap(), new int[]{0});
        } catch (IllegalStateException e) {
            System.out.println("stopped      : " + e.getMessage());
        }

        System.out.println("-- complete map, assertion removed from check steps --");
        silentRun(STEPS, blankAssertionMap(), new int[]{0});
    }
}
```

```
-- missing map, silent run --
skipping     : the store quantity must not drop below zero
matched steps: 3/4
result       : passed, store=7
-- missing map, halting run --
stopped      : no counterpart for step: the store quantity must not drop below zero
-- complete map, assertion removed from check steps --
matched steps: 4/4
result       : passed, store=8
```

The first two runs use the same `missingMap`, the same four-line scenario; the only difference
is the reaction given to the step whose counterpart cannot be found. `silentRun` skips the
fourth step, runs three steps, says "3/4" and finishes with "passed" — even though the scenario
text has four lines, the run actually executed only three of them. `haltingRun`, in contrast,
turns the same gap into an exception: which step's text was left without a counterpart appears
directly in the error message, and the run stops right there, with the remaining steps never
executed.

The difference between these two strategies is the scenario-text-level counterpart of the
silent/run-failure distinction seen in the course's earlier lessons. The silent strategy lets
a missing step pass unnoticed — a three-step scenario gets reported as if it were a four-step
scenario, and this is never visible at all unless the "3/4" line is read. The halting strategy,
on the other hand, turns the gap into a run failure and writes what is missing right into its
name. Neither is the "correct" strategy; one catches a misspelled step early, the other lets
the rest of the scenario still be seen running. The choice depends on who writes the scenario
text and how often that text changes.

The risk of the silent strategy is that missing the "3/4" line is not hard at all. Once a
scenario suite accumulates dozens of lines, reading the match ratio each run produces is not
realistic; most reports show only a "passed" or "failed" summary, not the match count. The
silent strategy can then carry a step that was never implemented, unnoticed, for months — the
text looks as though it declares a behavior that has never actually run. The halting strategy
removes this risk, but in exchange turns every small spelling difference (a missing space, a
changed number) into a run failure; if the text changes often, this reports text-maintenance
mistakes more than real defects. The two strategies coexisting is therefore not accidental:
which mistake gets caught early and which can be tolerated is an engineering decision that
depends on who writes the scenario text and how often.

## A Full Step Count Does Not Show Correctness

- **TE24.** The deliberate bug — applying a reduction of 2 instead of 3 — is introduced only
  in the third run, to show the limiting measure. The first two runs use a correctly working
  reduction; only a single variable is changed so the three remain comparable.
- **TE25.** An "assertion-removed" step is modeled here with an empty body (`state -> {}`).
  The step has a counterpart in the map and the match is counted, only that counterpart
  checks nothing at all.

The third run departs from the first two in two respects. First, `blankAssertionMap` contains
all four of the four steps — no step is skipped, no exception is thrown, the matched step
count is "4/4." Second, the reduction step in this map is deliberately wrong: it reduces by 2
instead of 3. Had it worked correctly, the store quantity would be 7; in this run it stays at
8. In the first run, using `missingMap`, the step "the store quantity must be 7" carried a
real check and would have caught a wrong value. In `blankAssertionMap`, the same text stands in
the map, it matches, but its body is empty — it makes no comparison at all. The result line
again says "passed, store=8"; the word "passed" here means only that no exception was thrown,
not that the value is correct.

This third run sharing the same scenario text with the first two is a deliberate choice. The
text never changed — four lines, the same order, the same words. What changes is only the
implementation behind that text: in one version the third step carried a real numeric
comparison, in the other the same step does nothing. Someone reading the scenario text cannot
see this difference — the text carries the exact same line in both. The only way to see the
difference is to read the step map itself, or to run it and observe the real value; the text
itself never guarantees what the implementation behind it does.

This is the closing observation of the course's fifth topic: the matched step count being
full — every line of the scenario having a counterpart — does not show that the scenario
**runs correctly**. Every step of a text having a counterpart in the step map does not mean an
assertion is actually written inside that counterpart; an entry in the step map counts as a
fully valid match even with an empty body. "4/4 matched" and "the scenario ran correctly" are
two separate propositions, and this lesson shows the two apart within the same run: even when
the count is full, a bug can pass silently.

Placing these three runs side by side brings out the final form of the course's measuring
question. In the earlier lessons the question took the shape "which class path must this type
be found on," and the type was a concrete `.class` file. Here a **step** stands in place of
"type," a **step map** stands in place of "class path," but the question settles into the same
mold: does this step have a counterpart in the step map, and does that counterpart actually
check anything. A scenario text is a declaration; the step map is that declaration's
executable counterpart, and when a gap remains between the two — whether as a missing entry
or an empty body — that gap is seen not by reading the text, but only by running it.

Across this topic's five lessons, the same question was asked in five separate forms. In the
first lesson the question was whether a framework type was present on the test class path; in
the second, which of two implementations carrying the same name stood on the runtime class
path; in the third, which scope a setup ran in; in the fourth, whether a real dependency
entered the class path. In this last lesson, the question was reduced to whether a behavior
has a counterpart in the step map. What all five had in common was that the question was
answered not from the source text, but by actually running — what a type, a setup or a step
did was read not from how it was written, but from the result it produced when run.

A second pattern shared by all five lessons was that a "complete" result does not mean
sufficient. In the first, a class path carrying production and test types collapsed because
the framework type was missing; in the second, an error-free run carried a wrong number; in
the third, a passing row passed only because its position fit; in the fourth, a silent run
never saw a limit violation at all. This last lesson repeated the pattern through step count:
a "4/4" scenario could carry no real check behind it. This repetition is not accidental — it
is the course's own thesis: a compiled, running program that says "ok" cannot be read without
being told which question it answers.

## Summary

- The scenario text was written as a four-step declaration; the step map provided these
  steps' executable counterparts.
- A step whose counterpart could not be found was silently skipped in one run (3/4 matched),
  and halted the run with an exception in another.
- When all four of the four steps matched ("4/4"), this alone did not show that the scenario
  ran correctly.
- A scenario run with a map whose check steps' assertion had been removed still said "passed"
  over a wrong value (8, expected 7).
- The concept of behavior-driven development was established in M21/K02; the one question
  this lesson added was the match between the scenario text and its counterpart in the step
  map.

## Next Step

In this topic's five lessons, we built the class path the test sees ourselves: we knew from
the start which type, which step would stand on that path, because we wrote all of it with
our own hands — the side writing the test decided both the fake type's name and the step map's
keys. The next topic asks the exact opposite. A logging call never writes the name of the
implementation that will answer it — its source text addresses only a facade, and never states
which class will answer that call. How that name enters the runtime class path, who puts it
there, and which one gets chosen when two implementations are present at once, is the next
topic's question.
