TDD is, one of the most effective software development methodologies. It allows you to break down problems into small steps with low cognitive load, iteratively build value guided by unit tests to design code that’s friendly to its users.
Once you start working this way, it just “makes sense” to follow these practices, and it’s really hard to go back to any other methodology.
However, there’s no silver bullet. There are situations where applying TDD is not that easy, and you’ll have to be attentive not to fall into these pitfalls that will hurt your work.
These are some situations where TDD may bite you back, and how to navigate through them like an experienced engineer.
When over-mocking creates coupling to implementation
The London school of TDD suggests a outside-in strategy that helps you iteratively discover the best design for your interface.
It also makes you very dependent on contracts, specially when working on internal units in your system.
That can leave your code full of abstractions that aren’t valuable, because they are used in only one place.
Let me give you an example. Let’s say you’re working on a scheduler that will require a certain configuration to know what to do. If you’re driving this outside-in, you can find yourself working on the scheduler first and then on the configuration implementation, so you decide to create an interface for the configuration so you can insure test isolation.
interface ConfigurationInterface {
doSomething(): void;
}
class ConcreteConfiguration implements ConfigurationInterface {
...
}
Then, you will write a test around your scheduler that mocks the configuration:
describe('scheduler', () => {
it('should behave a certain way', () => {
const mockConfiguration = mock<ConfigurationInterface>();
const expectedResult = {}; // Whatever you expect
const scheduler = new Scheduler(mockConfiguration);
const result = scheduler.doSomething();
assert(result).toEqual(expectedResult);
});
});
This may be harmless at first, but eventually your test code will be bloated with mock that are not really necessary. What if you default to a concrete implementation of the Configuration? Or maybe create a test implementation that you can control in case you need to, without needing to define the Configuration?
class ConcreteConfiguration {
...
}
class TestConfiguration extends ConcreteConfiguration {
...
}
// tests.spec.ts
describe('scheduler', () => {
it('should behave a certain way', () => {
const configuration = new TestConfiguration();
const expectedResult = {}; // Whatever you expect
const scheduler = new Scheduler(configuration);
const result = scheduler.doSomething();
assert(result).toEqual(expectedResult);
});
});
It’s always a good practice to identify these internal abstractions and get rid of them to simplify our code. Our unit tests will benefit of this because the amount of test doubles to set up will be reduced massively.
When TDD's discipline conflicts with exploratory work
Junior and Mid-level engineers find comfort in practicing TDD. It gives them a safe pattern to follow.
Some discovery activities just don’t match the practice. For example, when working on a scratch refactor to understand what the required changes are, non-seniors can feel a bit overwhelmed with the speed at which the trade-offs are considered, decisions are made, and changes are introduced.
This may make them feel like they are dragging, or that they are not moving fast enough for the team at that point. It can really undermine their enthusiasm to contribute.
Keep an eye on these situations, and act fast. From my experience, it’s always best to acknowledge these situations with the people involved, and get feedback from their perspective. Ask them how they feel under these situations, and identify together what you can do to create a thriving environment to everyone. Don’t lose the chance to help someone grow in an area they are not very comfortable at.
When TDD doesn’t work with your legacy codebase
Working on legacy systems is the bread-and-butter of our profession. It’s more likely that you’ll be working on legacy systems more than building greenfield projects in your career.
Legacy systems usually grow so entangled in their practices and architecture—or lack of—that it makes it really hard to start working on these codebases following TDD without refactoring beforehand. That investment can be big—sometimes it’s outside of your budget of time and resources.
There’s a lot to talk about this scenario in particular. I’ll dig into some of these aspects in the following blog posts.
What I’d like you to take home now is: Advice will really depend on the context. I like following the scout ethos of continuous improvement, taking responsibility over overall quality and thinking about small steps you can contribute with towards better modularity and separation of concerns. Try identifying boundaries that you can introduce in your legacy system, and using abstractions to separate those different areas in ways you can control them in your unit tests.
It’s also a good practice—although a hard one—to use this exploration to identify the different functionality your module needs to serve, and potentially discovering some parts of its interface that can be simplified. Working with domain experts also help with this, although it’s usually the case where these experts are not available in the company anymore.
This is one of the hardest tasks in our profession, but you got this! Start pulling the first thread you see and sooner than later, you’ll start untangling the whole thing.
