Isolating Setup with Builders and Test DSL
Part 2 of 9 in the series: Unit Testing — A Behavior-First Approach Each post in this series stands on its own, but together they tell the story of how my teams and I went from dreading test maintenance to genuinely productive TDD. Last week I covered the problem — brittle tests that break on every change. This week: the first concrete solution.
Reading Time: ~5 minutes
If you read the previous post, you saw how adding a single priority property to our Task class broke 50+ tests across 18 files. The problem was clear: our tests were tightly coupled to implementation details. Every test knew exactly how to construct a Task, which meant every test broke when the constructor changed.
If you're joining the series here, the short version is this: when every test file directly instantiates your domain objects, a single constructor change cascades into dozens of broken files — even when the behavior you're testing hasn't changed at all.
The solution we discovered is to isolate setup and assertions from test logic. This is where Builders and Test DSL patterns come in. Let me show you how we transformed our brittle tests into maintainable ones.
The Core Principle
Remember the principle from Clean Architecture: code that changes at the same rate for the same reasons should be co-located. When we add a priority property to Task, that's an implementation change. If it doesn't change the behavior we're testing, our test files shouldn't need to change.
The key insight: test files should only change when behavior or requirements change.
I know some developers hear "Clean Architecture" and immediately think "too many files, too many interfaces." I felt the same way at first. But here's the thing — the interfaces exist to isolate things that change for different reasons. And the builder pattern I'm about to show you is a perfect example of that principle paying off in a very concrete, time-saving way.
Before: Direct Instantiation Everywhere
Let's revisit the problem. We had tests like this scattered across multiple files:
// task-service.test.ts
it('should retrieve a task by id', async () => {
const task = new Task(
'task-123',
'Fix bug in login',
'IN_PROGRESS',
'user-456'
);
const result = await taskService.getTaskById('task-123');
expect(result.id).toBe(task.id);
expect(result.title).toBe(task.title);
expect(result.status).toBe(task.status);
expect(result.assignedTo).toBe(task.assignedTo);
});
Every test file had this same pattern: direct instantiation with all constructor parameters. When we added priority, every single one broke. Modern IDEs handle these refactors elegantly, but the change itself isn't the real issue — it's the noise it creates in the PR for reviews. Each test file the reviewer needs to look at and ask, "Did this test change or just the setup?" In my experience, each file in a PR adds to fatigue through increased cognitive load and reduces the overall quality of a review. At some point we start to rubber stamp things.
And here's the thing about diffs as communication: if I see a test file changing in a PR, I assume the public interface or requirements changed. That's the signal I'm looking for. When test files change because of setup noise rather than real behavior changes, that signal is worthless.
After: Centralized Builders
Now, let's see how Builders solve this. First, we create a TaskBuilder:
// tests/test-dsl/generate/task.builder.ts
export class TaskBuilder {
private id: string = 'task-default-id';
private title: string = 'Default Task';
private status: 'PENDING' | 'IN_PROGRESS' | 'COMPLETED' = 'PENDING';
private assignedTo: string = 'user-default';
private priority: 'LOW' | 'MEDIUM' | 'HIGH' = 'MEDIUM';
public withId(id: string): this {
this.id = id;
return this;
}
public withTitle(title: string): this {
this.title = title;
return this;
}
public withStatus(status: 'PENDING' | 'IN_PROGRESS' | 'COMPLETED'): this {
this.status = status;
return this;
}
public withAssignedTo(userId: string): this {
this.assignedTo = userId;
return this;
}
public withPriority(priority: 'LOW' | 'MEDIUM' | 'HIGH'): this {
this.priority = priority;
return this;
}
public build(): Task {
return new Task(
this.id,
this.title,
this.status,
this.assignedTo,
this.priority
);
}
}
Notice the builder has sensible defaults for all properties, including the new priority field. Tests only need to specify what's relevant to the behavior they're testing.
"But this is more code!" — Yes, it is. And that's a fair objection. I've found that the upfront investment in a builder pays for itself by the second or third test that uses it. The complexity here is essential — it exists to protect every test file from changes that don't concern them. That's a trade-off I'll make every single time.
Transforming Our Tests
Now our test becomes:
describe('Feature: Retrieve Task by ID', () => {
it('Scenario: Task exists', async () => {
const testDsl = TaskTestDsl();
/**
* Given an existing task in the system
*/
const task = testDsl.generate.task().withId('task-123').build();
/**
* When an employee retrieves the task by its ID
*/
const result = await taskService.getTaskById('task-123');
/**
* Then the existing task will be returned
*/
expect(result.id).toBe(task.id);
expect(result.title).toBe(task.title);
expect(result.status).toBe(task.status);
expect(result.assignedTo).toBe(task.assignedTo);
});
});
The test now only specifies what's relevant: the ID we're retrieving. Everything else uses sensible defaults.
The Magic: Adding Priority Doesn't Break Tests
Remember: when we added priority, we had to update 50+ tests. Now, let's see what happens:
Before adding priority:
public build(): Task {
return new Task(this.id, this.title, this.status, this.assignedTo);
}
After adding priority:
private priority: 'LOW' | 'MEDIUM' | 'HIGH' = 'MEDIUM';
public build(): Task {
return new Task(this.id, this.title, this.status, this.assignedTo, this.priority);
}
That's it. One file changed. All 50+ tests continue to work because the builder provides a sensible default for priority.
If a test specifically needs to test priority behavior, it can opt in:
describe('Feature: Filter Tasks by Priority', () => {
it('Scenario: High and low priority tasks exist', async () => {
const testDsl = TaskTestDsl();
const highPriorityTask = testDsl.generate.task().withPriority('HIGH').build();
const lowPriorityTask = testDsl.generate.task().withPriority('LOW').build();
const results = await taskService.getHighPriorityTasks();
expect(results).toContainEqual(highPriorityTask);
expect(results).not.toContainEqual(lowPriorityTask);
});
});
Organizing with Test DSL
As you may have noticed, we're not calling the builder directly. Instead we create a Test DSL for the feature. The Test DSL pattern organizes builders and scenarios in one place:
// tests/test-dsl/test-dsl.ts
import { TaskBuilder } from './generate/task.builder';
import { configureTaskAssertionChain } from './assert/task.assertion';
export const TaskTestDsl = () => {
return {
generate: {
task: () => new TaskBuilder(),
},
assert: {
task: configureTaskAssertionChain,
},
ports: {
// Mock adapters for external services
},
};
};
The Test DSL provides:
generate: Builders and scenario functions for creating test dataassert: Assertion chains for verifying outcomes (we'll cover this in the next post)ports: Mock adapters for ports that expose instances and allow for configuring and asserting interactions with the mock
One File Per Behavior, No beforeEach
The Test DSL unlocks something else: you can organize tests by feature or behavior instead of by class. When setup is portable and self-contained, there's no need for beforeEach blocks that silently wire up shared state. Each test constructs exactly what it needs through the DSL, making every test readable in isolation.
Without this pattern, teams tend to end up with one giant test file per class:
.
├── task.ts
├── task.test.ts ← every behavior crammed into one file
That single task.test.ts grows and grows. It needs beforeEach to set up shared state. Tests start depending on each other's setup. The file becomes hard to navigate, hard to review, and a merge conflict magnet.
With the Test DSL, you can split tests by behavior:
.
├── task.ts
├── prioritize-task.test.ts
├── assign-task.test.ts
├── block-task.test.ts
├── complete-task.test.ts
├── test-dsl
│ ├── assert
│ │ ├── task.assertion.ts
│ │ └── task-list.assertion.ts
│ ├── generate
│ │ ├── task.builder.ts
│ │ ├── high-priority-task-list.scenario.ts
│ │ └── blocked-task-with-dependencies.scenario.ts
│ └── test-dsl.ts
Each test file maps to a specific behavior: prioritizing, assigning, blocking, completing. There's no task.test.ts mega-file testing every method on a class. The test-dsl folder sits right next to the test files, co-located with the feature it supports. Scenarios in the generate folder describe meaningful starting states, not raw constructor calls. And assertion chains in the assert folder verify outcomes, not properties.
Because the Test DSL handles all setup and assertions, there's no beforeEach block coupling tests to shared state. Every test reads top to bottom: create what you need, execute the behavior, assert the outcome. If a test file changes in a PR, you know that behavior changed.
Real-World Impact
In our startup, this pattern transformed our testing experience:
Before:
- Adding a property: 30 minutes updating 50+ tests
- Developers hesitant to refactor
- PR reviews cluttered with test updates
- Increased risk of merge conflicts in test files
After:
- Adding a property: 5 minutes updating the builder
- Developers confident to refactor
- PR reviews focus on behavior changes
- No merge conflicts in test files
The tests went from being a liability to being an asset. They reduced stress instead of creating it. They documented behavior instead of implementation. They increased velocity instead of slowing us down.
What's Next
We've solved the setup problem with Builders and Test DSL. But there's another piece: assertions. In the next post, we'll explore assertion chains and how they let us think in behaviors, not implementation details. We'll see how the normalize function handles both model instances and data representations, making our assertions flexible and resilient.