Test Driven Development Is Not About Tests. It Is About Design.
by Arif Ikhsanudin, Backend Developer
The Misunderstanding That Makes TDD Look Like Overhead
Most developers who reject TDD do so after experiencing it as a slower way to produce the same code with tests attached. Write the test first, then write the code to pass it — compared to write the code, then write the test — the output seems identical except for the order of operations and a few extra minutes.
This framing misses the point entirely. TDD is not a different order for producing the same code. It is a design discipline that uses tests as a medium for working out the design before committing to an implementation.
The tests are evidence that the design process happened. They are not the goal.
What TDD Actually Produces
When you write a test before the implementation, you are forced to use the interface before you have built it. This is the entire point. Using an interface before building it reveals problems that are invisible when you design and implement in the same motion.
Consider designing a notification service. Implementation-first, you might start with the data model, work outward to a service class, and end up with something like:
public class NotificationService {
public void send(NotificationRequest request) {
NotificationTemplate template = templateRepository.findById(request.getTemplateId());
String rendered = templateEngine.render(template, request.getVariables());
Channel channel = channelFactory.getChannel(request.getChannelType());
channel.dispatch(rendered, request.getRecipient());
auditLog.record(request.getUserId(), request.getTemplateId(), LocalDateTime.now());
}
}
This looks reasonable. But the design decisions were made while building, not before. The caller has to construct a NotificationRequest with a templateId, variables, channelType, recipient, and userId — five pieces of information bundled into a request object — before knowing whether that is a natural grouping.
TDD-first, you write the test first:
@Test
void sendsEmailNotificationWithRenderedTemplate() {
// What does the caller naturally provide?
String userId = "user_123";
String email = "user@example.com";
Map<String, String> vars = Map.of("name", "Alice", "resetUrl", "https://...");
notificationService.sendPasswordReset(userId, email, vars);
verify(emailChannel).send(eq(email), contains("Alice"));
verify(auditLog).record(eq(userId), any(), any());
}
Writing the test first reveals that sendPasswordReset is a more natural interface than send(NotificationRequest) for this use case. The caller knows they are sending a password reset — they should not have to know about template IDs and channel types. The TDD process surfaced a better abstraction before a line of production code was written.
The Design Benefits That Accumulate
Lower coupling. When you write tests first, you naturally inject dependencies rather than constructing them inside the class. You cannot test a class that creates its own database connection without hitting a real database, so you inject the connection. This discipline, applied consistently, produces codebases where dependencies are explicit and exchangeable.
Smaller, more focused classes. Hard-to-write tests are the first signal that a class is doing too much. When a test requires five mocks and forty lines of setup to test one behavior, TDD practitioners respond by decomposing the class — not by writing the complicated test. The test difficulty constrains the design toward simplicity.
Better naming. Writing the test first requires naming the behavior before implementing it. This forces the kind of precise naming that is easy to skip when you are focused on making something work. The test reads like a specification; the production code names follow from that specification.
Emergent design. In implementation-first development, the design is usually decided before coding starts. In TDD, the design emerges from the tests. Each test shapes the interface slightly. The final interface is the one that was most natural to call across all the tests — which is typically the interface that is most natural to call in production code too.
The Refactor Phase Is Where the Design Lives
The red-green-refactor cycle is often described as: write a failing test, make it pass by any means, then clean up the code. The refactor phase is undersold. It is not cleanup — it is design.
When the test is green, you have verified behavior. The refactor phase is where you ask: is this the right abstraction? Is this method in the right class? Are these concerns properly separated? You can refactor confidently because the test will catch any behavioral regression.
Without the test, this refactoring is risky. With the test, it is safe to restructure significantly. The combination — behavioral verification plus design freedom — is what produces the design quality that TDD advocates point to.
The tests produced along the way are genuinely valuable. A fully TDD'd codebase typically has excellent coverage of the behaviors that matter, because every behavior was specified in a test before it was implemented. But that coverage is the output of a design process, not the goal of one. Treat TDD as a design tool that produces tested code, not as a testing tool that requires writing tests first.