Making TDD Work for You, Part 2: crossing TDD's tribal lines

Introduction
In the second part of our series around making TDD work for you we continue exploring why Test-Driven Development (TDD) is not as widely used, even though it has clear benefits. We’ve explained in part 1 that it had trade-offs as a practice and that is one factor that can discourage newcomers if applied at the wrong time or the wrong way.
This post explains another major hurdle behind mass adoption and collaboration: the schism between classic Detroit-style TDD and mockist London school TDD that we hinted at in the previous post.
We hope that a mutual understanding of the differences and commonalities between different styles will help increase adoption by allowing collaboration. At the very least, we need to agree on terminology and distinguish between matters of style versus plain bad practice.
The Schools of TDD: Classic vs. Mockist Approaches
Classic/Detroit Style TDD
This style is the “classic” in that it’s the fundamental idea on which all TDD is based and “Detroit” because that’s where it originated with Kent Beck’s work on the Chrysler comprehensive compensation system (C3) project. In this approach, you can start with domain objects or core business logic and work outward, as naturally as it comes to your head, testing components often with real collaborators. This can lead to more sociable tests that verify interactions between real instances of components and check explicitly for the end state after some operation or behaviour. Hence, tests assert outcomes (e.g., return values, system state). This snippet is adapted from the excellent mocks aren’t stubs article and is written in classic style:
private Warehouse warehouse = new WarehouseImpl();
protected void setUp() throws Exception {
warehouse.add(VOSS_WATER, 50);
warehouse.add(HIGHLAND_SPRING, 25);
}
public void testOrderIsFilledIfEnoughInWarehouse() {
Order order = new Order(VOSS_WATER, 50);
order.fill(warehouse);
assertTrue(order.isFilled());
assertEquals(0, warehouse.getInventory(VOSS_WATER));
}
Note that this test uses a real warehouse and checks its state after adding the order. You will see as we explain more that classic TDD is more flexible about how you implement a particular behaviour, on the flip side this means there is less pressure toward dependency inversion in design. It usually requires the use of “mother objects” (builder pattern) methods to generate the setup of real collaborators for a particular test quicker. It can also lead to longer unit test execution times, since now the boundary between unit test and integration test is blurred a bit, compared to the mockist school. This is less and less of a concern in the world of Docker and if you use other techniques we explain later like record/replay proxies.
Another objection that is fair to the classic style is that it would occasionally force you to expose some state (add some getters) just to allow the test a way to peek in and verify the final state after inducing a scenario on the system under test; If you are pragmatic this is usually fine.
London/Mockist Style TDD
The discovery of this mockist style happened when some Java TDD adopters in London were unhappy about breaking encapsulation (by adding getters) when doing classic TDD. This was one of the drivers that led to the invention of what’s called the “Mockist”, “outside-in”, or “London school” of TDD. This approach essentially revolves around the ideas presented in “Growing Object-Oriented Software, Guided by Tests” by Freeman and Pryce which shift from state verification to interaction verification through mocks. Where essentially tests validate interactions between objects (e.g., “Did the system under test call save()
on mocked collaborators?”).
This difference about TDD styles is illustrated in the Foreword of the GooS book, by Kent Beck:
“The style of test-driven development presented here is different from what I practice. I can’t yet articulate the difference, but I have learned from the clear, confident presentation of the authors’ techniques.”
To attempt to articulate the difference, we can lean on the work of other TDD pioneers like Martin Fowler and Meszaros, who have laid the groundwork for defining precise terminology:
Test doubles: If you are dealing with complex error cases or maybe dependencies that contact third-party APIs or anything that is hard to induce a certain scenario you will make use of test doubles of some kind. The term comes from stunt doubles used in movies.
Test doubles are the general term which can be fakes, stubs, spies or mocks. Consult the “The Difference Between Mocks and Stubs” section of the Mocks aren’t stubs article for more details.
In the classic TDD style, you would occasionally make use of fakes, stubs or even spies but not mocks!
Even though frameworks in Python for example such as unittest or request-mock will make you use the mock keyword to setup network level interceptors or monkey patching some methods to setup canned responses or force errors.
However, a developer with a preference for the London style TDD will almost always use mocks, which usually have assertions about how collaborators were called.
So although Kent Beck talks about how he does not really use mocks as much, he is technically correct from the point of view of the strict definition of mocks; You might be using the mock keyword to set up fake collaborators or induce errors but would not assert on the calls to the mocks. In other words, London school practitioners embrace mocks both as their testing tool and their verification philosophy, while the classic style might use mocking frameworks as a convenience but still focus on verifying outcomes rather than interactions.
Here’s how the previous test would have been written in Mockist TDD style:
public void testFillingRemovesInventoryIfInStock() {
//setup - data
Order order = new Order(VOSS_WATER, 50);
Mock warehouseMock = new Mock(Warehouse.class);
//setup - expectations
warehouseMock.expects(once()).method("hasInventory")
.with(eq(VOSS_WATER),eq(50))
.will(returnValue(true));
warehouseMock.expects(once()).method("remove")
.with(eq(VOSS_WATER), eq(50))
.after("hasInventory");
//exercise
order.fill((Warehouse) warehouseMock.proxy());
//verify
warehouseMock.verify();
assertTrue(order.isFilled());
}
In the above, expectations are placed on how a mock version of warehouse collaborator will be called and not the state of a real warehouse object.
Real world examples
Classic TDD
For more real world examples, it is easy to pick any tested code from the wild in the CNCF, and find it mostly uses classic TDD. Go and Kubernetes mostly follow classic TDD. Although of course in any codebase you will always find some modules or units done by an author with a different approach to TDD occasionally. As an example of what was previously mentioned about test doubles, a lot of Kubernetes controller unit testing uses a fake client that simulates the behaviour that an API Server has in that you can list and watch on resources that you update through that very same client.
For example, in Flux’s bootstrapping unit tests, a fake kubernetes client controller (and not a mock) is used. Verification is state based because we assert on the final state of the controller after the operation completes against the fake.
Mockist TDD
It is actually quite hard to find real world open-source projects practicing the mockist paradigm, I found the spring projects seem to adhere to it pretty consistently, here is a typical London school test/code example from Spring security codebase
The strength and appeal of mockist approach comes from the fact that you can start with high-level tests (often acceptance tests) and mocks out the collaborators as you go along, gradually stepping in and building the concrete implementation of mocked collaborators. Essentially discovering the interfaces the collaborators need to present as you build the tests. This is called design by “wishful thinking”. This naturally aligns with the outside-in flow of ATDD’s dual loop, which is why many practitioners of ATDD gravitate toward the London school approach. The only requirement is that you create a walking skeleton of your whole software (eg. an empty server with health endpoint only) before you start anything which can slow down the inception of a project.
Because it is rare to find, I scoured the internet for more working examples of mockist style, and aside from the auction sniper in the GOOS book, I found this resource by Codagility to be a semi-realistic worked example you can easily find that shows this in the wild: https://www.youtube.com/watch?v=XHnuMjah6ps
The fact that it is much harder to find examples in the wild is an indication that classic TDD is less prescriptive and hence more adapted to a wider set of conditions.
Nonetheless, the ATDD and Mockist style is quite a pleasant combination that works great as it offers relatively good design organically and a slightly more straightforward way to test against third-party APIs. third-party APIs, as well as things like time, the filesystem, randomness and other system boundaries are hard to deal with in any style of TDD. This article would be too long if we discussed all of them but we will elaborate on perhaps the most pressing and complex issue and how it appears in both styles in both sections which is about third-party APIs.
Third-party APIs testing
If we zoom in on the third-party API problem we can uncover one of the most pervasive sources of badly practiced TDD in “mockist” practitioners that are still mastering the craft. They often take a shortcut that the London style of TDD seems to encourage, even though this is explicitly called out by the authors of the GooS book as a pitfall.
They may approach the challenge of testing an integration against a third-party API by asserting that methods on an SDK or an HTTP client have been called with the correct arguments such as path and query params and call it a day. Supplementing the whole thing with a few end-to-end tests to cover all bases.
While one can see the appeal in taking this shortcut, the recommended way according to the GooS book is to build an anti-corruption layer, meaning an adapter/facade class that leverages the sdk. You would make “mockist” assertions against this anti-corruption layer in your interaction-based unit tests.
You would then verify that this layer works as expected through narrow integration tests, which in this context means by actually running them against a fake API server that behaves close enough to the real thing. Even better, you could make use of record/replay proxies for this purpose.
Record/replay proxies like pytest-recording (Python library based on pyVCR) are a way to capture real API requests and have fast replay for them. You can then occasionally check if there is drift. In this simple example of a file upload and shortener service, we try to illustrate how it may work:
def tiny_url_maker_adapter(long_url):
"""Anti-corruption layer that handles external URL shortening API"""
response = requests.post("http://url.make/new_url", params={"url": long_url})
if response.status_code == 200:
return response.json()["short_url"]
raise URLServiceError("Failed to create short URL")
# Narrow integration tests for the adapter (both schools could use these)
@pytest.mark.vcr
def test_tiny_url_maker_returns_short_url_with_valid_entry():
result = tiny_url_maker_adapter("https://example.com/very/long/path")
assert result.startswith("url.make")
assert len(result) < 15 # maximum length of shortened url
@pytest.mark.vcr
def test_tiny_url_maker_raises_exception_if_url_invalid():
with pytest.raises(URLServiceError):
tiny_url_maker_adapter("invalid-url")
# MOCKIST APPROACH - Mock leveraging the adapters class, mostly verify interactions
@patch("myapp.adapters.tiny_url_maker_adapter")
@patch("myapp.adapters.file_uploader_adapter")
def test_service_uploads_file_and_generates_tiny_url_mockist(mock_uploader, mock_url_maker):
# Setup mock responses
mock_uploader.return_value = "file123.pdf"
mock_url_maker.return_value = "url.make/short123"
# Exercise the business logic
result = upload_file_and_link("document.pdf")
# Verify the adapters were called correctly
mock_uploader.assert_called_once_with("document.pdf")
mock_url_maker.assert_called_once_with("https://storage.com/file123.pdf")
assert result.download_link == "url.make/short123"
The first time you run the integration tests with record-mode it will take time and record the tiny url and upload responses. It will then save the request/response cycle in cassettes and running the focused integration tests is fast except if you want to refresh the cassettes. Doing this pattern allows you to do mockist TDD in peace at the seams of the adapter layer. This fits in well with the ideas of ports and adapters and hexagonal architecture which is good design practice. A more formal version of the above can be done to test your anti-corruption layer and this can take the form of contract testing. For classicists we would be free to do the same approach but using a fake or stub instead of a mock against the facade, we also have a bit more freedom to remove the layer of indirection if we choose and we would be most interested in the end-state, so this test could be written like so:
# CLASSIC APPROACH for third-party APIs - Use real adapters with VCR, verify end state
@pytest.mark.vcr
def test_service_uploads_file_and_generates_tiny_url_classic():
result = upload_file_and_link("document.pdf")
# Focus on the final outcome, not the interactions
assert result.status_code == 201
assert result.short_link.startswith("url.make/")
assert result.original_filename == "document.pdf"
This allows you to have your cake and eat it too, to get the robustness of running against a real server with real instances of your code at the speed of a unit test. Which can blur the line of what is called unit test and integration tests. You always have to be very clear what is meant when people talk about integration tests.
If you don’t have access to a full-blown record/replay proxy you can still leverage the “Nullable” pattern. This consists in using dependency injection to an anti-corruption layer, passing in the http client and substituting it in tests with a recording client that you would manually write, like a spy. This is a little bit more laborious but achieves that higher level of reliability and makes your tests more straightforward. I learned about this from the excellent TDD evangelist and consultant James Shore who seems to have a strong preference towards classic TDD. I have seen developers approximate this pattern in Go by using a RoundTripper that has all the amenities required by testing. If you are curious about this pattern, check out these examples: 1, 2 and 3.
As a pragmatic classic TDD practitioner I have learned to use stubs or spies in some rare cases, as mentioned before these make use of the mock misnomer keyword in languages like Python but the intent and use is different: For example, if you want to force a 500 or an exception of some kind, it is ok to sometimes make use of unittest’s mock.side_effect for example to force a function to have an exception in order to induce a scenario. While I try to be as real as possible as a tester, we have to be pragmatic and make TDD work for us.
To make this point clear:
# Pragmatic use of mocks in classic TDD - using mock to force error scenarios
@patch('tiny_url_maker_adapter')
def test_service_handles_url_shortener_failure_gracefully(mock_url_maker):
# Use mock just to force the error scenario that's hard to induce naturally
mock_url_maker.side_effect = URLServiceError("Service temporarily unavailable")
result = upload_file_and_link("document.pdf")
# Still verifying meaningful business behavior/state - classic TDD style!
assert result.status == "UPLOAD_SUCCESS_URL_PENDING"
assert result.file_url # File was uploaded successfully
assert result.short_url is None # But short URL creation failed
assert "temporarily unavailable" in result.error_message
Summary of advantages and pitfalls of each approach
To summarise the main points of this article:
Aspect | Classic TDD | Mockist TDD |
---|---|---|
Dependency Inversion | Less prescriptive about using dependency inversion everywhere | Pushes toward dependency inversion by design |
Starting Approach | Unopinionated about high-level approach; can start from any unit or use double-loop TDD | Requires a “walking skeleton”; favours ATDD |
Test Double Usage | Occasional use of fakes/stubs for difficult scenarios | A lot of mocked collaborators |
Test Speed & Reliability | Can have slower tests, fewer integration surprises | Faster tests, higher integration risk |
Learning Curve | Lower initial learning curve, easier to start | Higher initial learning curve, especially for setup |
Maintenance & Refactoring | Easier refactoring | Increased maintenance cost and harder refactoring due to layers of indirection and mocks |
Design Generation | Less deterministic in generating OOP design | More deterministic in generating sensible OOP design |
Methodology Alignment | Less aligned with ATDD and BDD | ATDD and BDD encourage the mockist style |
Error Localization | Errors may be scattered when real collaborators fail | More focused error identification in isolated tests |
Implementation Risk | Using real components adds verification benefits | Poor mockist practice can yield 100% coverage with non-functional software |
State Exposure | State-based assertions might expose more state | Less likely to expose state, breaking encapsulation mildly |
Conclusion
Don’t let methodology wars discourage you. Many TDD learning resources suggest that outside-in, mockist TDD is the advanced, superior technique that you must master to be considered proficient. This hierarchical framing isn’t just misleading—it’s contradicted by real-world adoption patterns in the industry. Look at most successful open-source projects, and you’ll find pragmatic classic TDD approaches thriving.
Mockist TDD makes more sense if your application is primarily wiring together robust dependencies, where testing the interactions between components is more valuable than testing individual component behavior. This explains its prevalence in enterprise Java environments.
Classic TDD’s flexibility makes it more adaptable to different contexts and easier for teams to adopt incrementally, it usually leads to more correct software in my view and refactoring is smoother in this testing methodology in my practical experience.
I still encourage you to bear in mind all schools of thought and choose the approach and techniques that work best for your needs. If anything is unclear or you disagree with my explanations, please read the authoritative sources I quoted and make sure you are using terms carefully when discussing approaches with your fellow engineers. Your practical choices will also be influenced by your programming language’s flexibility and its testing ecosystem, though community practices often matter just as much.
If you’re doing things right, you should feel more zen and steady progress by practicing TDD throughout the lifetime of a project, with more robust code quality and delivery, and refactoring that feels blissful rather than dreadful.
Resources:
- Mocks aren’t stubs - Martin Fowler’s foundational article on the classic vs mockist divide
- 🚀 TDD, Where Did It All Go Wrong (Ian Cooper) - Classic TDD perspective on common pitfalls
- Codurance outside-in screencast - Practical mockist TDD example
- James Shore’s Nullables Pattern - Classic TDD approach to testing with dependencies
- GOOS Book google group - Great place to see previous discussions around the methodology from mockists’ viewpoint
- Part 1 of this series Books:
- Growing Object-Oriented Software, Guided by Tests by Nat Pryce and Steve Freeman - The definitive mockist TDD guide
- Test-Driven Development: By Example by Kent Beck - The classic TDD foundation
Related blogs

DevSecOps is the New DevOps

Making TDD Work for You, Part 1: When to Invest and Essential Practices
