Tests as Living Documentation
Part 5 of 9 in the series: Unit Testing — A Behavior-First Approach
This is week five of a series where I share the testing practices my teams and I discovered through years of trial and error. Each post is designed to stand alone, so you can read them in any order.
Reading Time: ~4 minutes
In our previous posts, we've covered how to write resilient tests and what to test. But there's another critical aspect of testing that often gets overlooked: tests as living documentation.
When I first started writing tests, I thought of them as a safety net. But I've learned that well-written tests are so much more: they're executable specifications that document what your code does, for whom, and why.
The Documentation Problem
Early in my career, I wrote code comments to explain "why" something existed. But comments lie. They get out of date, they're ignored during refactoring, and they don't execute — so there's no way to verify they're still accurate.
Tests, on the other hand, are executable. If the behavior changes, the test breaks. This makes tests a form of living documentation that stays in sync with your code.
Documenting the "Why": Use Case Comments
One of the most powerful patterns I've adopted is the use case comment:
/**
* In Order To: Update the priority of tasks in a Kanban board
*
* As A: Project Manager
*
* I Want: To be able to reorder tasks by dragging them to new positions
*/
describe('Feature: Prioritize Task', () => {
// ... tests ...
});
This answers three critical questions: Why does this code exist? Who benefits from it? What capability does it provide?
Notice that "In Order To" comes first. By starting with the value, we force ourselves to answer "Why should this code exist?" before we describe what it does.
Documenting the "What": Given/When/Then Structure
Within each test, we use Given/When/Then to document the scenario:
test('Scenario: Task is prioritized after another task', () => {
const testDsl = TaskTestDsl();
/**
* Given a task list with three tasks in order
*/
const taskA = testDsl.generate.task().withId('task-a').prioritizedToTopOfList().build();
const taskB = testDsl.generate.task().withId('task-b').prioritizedAfter(taskA).build();
const taskC = testDsl.generate.task().withId('task-c').prioritizedAfter(taskB).build();
/**
* When task C is prioritized after task A
*/
taskC.prioritize({ below: taskA, above: 'END_OF_LIST' });
/**
* Then the tasks should be ordered as A, C, B
*/
testDsl.assert
.tasks([taskA, taskB, taskC])
.sortWith(Task.PRIORITY_COMPARATOR, (c) =>
c.toExpectedOrder([taskA, taskC, taskB])
);
});
Anyone reading this test can understand the scenario without understanding the implementation. A product manager could read this.
Feature vs. Technical Prefixes
Not all tests are created equal. We use prefixes to make the distinction clear:
- Feature tests document user-facing behaviors — things that users care about
- Technical tests document behaviors that are necessary for the system to work but aren't directly related to business value (serialization, data marshalling, etc.)
This distinction helps developers understand why code deserves to be here and helps make better decisions on future evolutions.
Who Benefits from Test Documentation?
- Developers: Tests explain intent and provide examples
- Product Managers: Tests document features in readable format
- New Team Members: Tests onboard quickly with business context
- AI Agents: Clear test structure helps AI understand what to generate
What's Next
In the next post, we'll explore the behavior-first mindset — how focusing on outcomes over implementation changes the way we think about testing and development.