DEV Community
Part 3 is here.
Key Takeaways
In this series, unit testing refers to replacing a component’s dependencies with test doubles.
Test doubles enable the test to control indirect inputs and observe indirect outputs.
For reactive systems, we introduce three approaches: pursuing 0-switch coverage, property-based testing, and model checking.
Unit Testing—The Heart of Shift-Left Testing
The test pyramid 1 is a well-known guideline that prescribes how many test cases should exist at each stage of the testing process.
The lower the level of coupling, the more test cases you write.
In practice, that means more component (integration) tests than end-to-end tests, and even more unit tests than component tests.
Visualize unit tests as the base of the pyramid and E2E tests as its tip.
Following this guideline yields fast, stable 2 feedback.
What Exactly Is a Unit Test?
The term “unit test” is notoriously ambiguous.
Some recent testing books avoid the muddled “unit vs. integration” dichotomy altogether and classify tests by test size instead.
That detour aside, in this series a unit test is defined as a test that targets a single component while replacing its other dependencies with test doubles 3.
In a purely functional language, a component is a single function; in an OO language, it is a single class.
The more components you combine, the larger the state space becomes and the harder it is for people to understand the resulting spec and implementation.
Because a unit test lives at the boundary between a component’s spec and its implementation, once either side grows complex, diagnosing failures becomes painfully time-consuming.
Keeping both spec and implementation small enough for humans to grasp is therefore the driving motivation behind unit tests.
Another major benefit is speed: less code executes, so the tests run faster.
A weakness of unit tests is that they cannot detect integration bugs—failures caused by inconsistencies between components’ specs.
Suppose component A expects an integer while component B returns the integer’s string representation.
Each component individually satisfies its own spec, yet wiring them together causes a type error.
A unit test would never see this.
We said unit tests replace dependencies with test doubles. Why?
To answer that we need the notion of indirect input/output.
Unit Tests and Indirect I/O
- Indirect input: input obtained via a dependency rather than a parameter.
- Indirect output: output sent to a dependency rather than returned.
Together they are indirect I/O.
We will look at examples, starting with a component that has no indirect I/O.
A Straightforward Component with No Indirect I/O
The familiar FizzBuzz function takes its input as a parameter and returns its output—nothing else.
Hence it has no indirect I/O.
function fizzBuzz(input: number): string {
if (input % 3 === 0 && input % 5 === 0) return "FizzBuzz";
if (input % 3 === 0) return "Fizz";
if (input % 5 === 0) return "Buzz";
return input.toString();
}
Testing it is trivial; no test doubles are needed:
describe("fizzBuzz", () => {
context("when given a multiple of 3 and 5", () => {
it("returns FizzBuzz", () => {
const actual = fizzBuzz(15);
assert.strictEqual(actual, "FizzBuzz");
});
});
});
A Component with Indirect Input
Indirect input is data a component fetches from a dependency, not from its parameters—current time, random numbers, database queries, etc.
In the greet
function below, Clock#getHour
supplies the indirect input:
function greet(): string {
const hour = new Clock().getHour();
if (hour < 5) return "Good evening!";
if (hour < 12) return "Good morning!";
if (hour < 18) return "Good afternoon!";
return "Good evening!";
}
Because the test cannot dictate the time, it cannot predict the expected output.
Enter the test stub: a fake that injects tester-specified indirect input.
First, refactor so that a Clock
can be injected:
class Greeter {
constructor(private clock: Clock) {}
greet(): string {
const hour = this.clock.getHour();
if (hour < 5) return "Good evening!";
if (hour < 12) return "Good morning!";
if (hour < 18) return "Good afternoon!";
return "Good evening!";
}
}
Now the test can supply a stub clock:
describe("greet", () => {
context("in the morning", () => {
it("returns Good morning!", () => {
const stub = new StubClock({ hour: 9 }); // always returns 9
const actual = new Greeter(stub).greet();
assert.strictEqual(actual, "Good morning!");
});
});
});
A Component with Indirect Output
Indirect output is data sent to a dependency—writing to a DB, file, etc.
To observe it, we use a test spy that records what was sent.
function writeHelloWorld(file: File) {
const message = "Hello, World!";
file.write(message);
}
Testing it with a spy:
describe("writeHelloWorld", () => {
it("writes Hello, World! to the file", () => {
const spy = new SpyFile();
writeHelloWorld(spy);
assert.strictEqual(spy.content, "Hello, World!");
});
});
SpyFile
stores whatever is written, so the test can assert against spy.content
.
Components with Both Indirect Input and Output
Sometimes a component has both.
A single test double would then need to both control indirect input and observe indirect output, making it bulky.
Designs such as CQS (Command-Query Separation) help avoid this by ensuring a component has only one or the other.
We will revisit CQS later in the series.
Beware Overusing Test Doubles
Stubs and spies add lines and can obscure intent.
If you can avoid them—e.g., accept parameters instead of using a stub, return a value instead of writing to a spy—do so.
Whenever you feel compelled to use one, ask whether indirect I/O is truly necessary 4.
Unit Testing Reactive Systems
All previous examples were functional: outputs depend solely on inputs.
Reactive systems instead change state in response to interactions.
Consider a Flag
that flips its boolean state when toggle
is called (see code 8 and figure 1).
class Flag {
private state = false;
get isEnabled() {
return this.state;
}
toggle() {
this.state = !this.state;
}
}
Figure 1: State-transition diagram of Flag
Tests might look like this:
describe("Flag", () => {
context("before any toggle", () => {
it("isEnabled is false", () => {
const flag = new Flag();
assert.strictEqual(flag.isEnabled, false);
});
});
context("after one toggle", () => {
it("isEnabled is true", () => {
const flag = new Flag();
flag.toggle();
assert.strictEqual(flag.isEnabled, true);
});
});
context("after two toggles", () => {
it("isEnabled is false", () => {
const flag = new Flag();
flag.toggle();
flag.toggle();
assert.strictEqual(flag.isEnabled, false);
});
});
});
That covers three scenarios: 0, 1, and 2 toggles.
Reactive systems often have vast or infinite test-case spaces, so various strategies exist to tame them.
We outline three.
0-switch coverage: Test every direct state transition.
Flag
has three: initial → false, false → true, and true → false.
Our tests above already achieve 0-switch coverage.Property-based testing: Programmatically generate many sequences of calls and assert a property holds for all.
A sketch:
// A naive implementation without a library—use a real PBT library in practice.
describe("Flag", () => {
it("isEnabled is false after an even number of toggles, true otherwise", () => {
const counterexamples: number[] = [];
for (let i = 0; i < 100; i++) { // try many inputs
const count = Math.floor(Math.random() * 10000); // random length
const flag = new Flag();
for (let n = 0; n < count; n++) flag.toggle();
if (flag.isEnabled !== (count % 2 === 1))
counterexamples.push(count);
}
assert.deepStrictEqual(counterexamples, []);
});
});
-
Model checking: Exhaustively explore states to detect deadlocks or assertion violations 5.
A deadlock is a state with no outgoing transitions 6; an assertion violation is an exception thrown by an embedded
assert
. Model checking is powerful but abstract, making results harder to interpret than 0-switch tests. (A full code sample would require an external model checker, so it is omitted.)
Summary
In this series, a unit test replaces a component’s dependencies with test doubles.
Test doubles let tests control indirect inputs and observe indirect outputs.
For reactive systems, we discussed three approaches: 0-switch coverage, property-based testing, and model checking.
-
Some prefer the test trophy, which argues that if component tests can run as fast as unit tests, you should write more component tests than unit tests. ↩
-
Stable means repeated runs of the same test always yield the same result (pass or fail). ↩
-
This definition does not draw a sharp line between unit and integration tests. In practice, you rarely stub every dependency—simple utility functions inside the component are usually left real. Conversely, integration tests may employ some doubles. The distinction is therefore gradual, based on how many dependencies are replaced. ↩
-
We will revisit this in a later article about design for testability. ↩
-
More formally, model checking also includes verifying temporal-logic properties and refinement relations. ↩
-
Strictly speaking, you must exclude normal termination states; although they also lack outgoing transitions, they are not considered deadlocks. ↩
For further actions, you may consider blocking this person and/or reporting abuse
Top comments (0)