‹ Blogs

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

Featured Image
Published on August 26, 2025
Author Houssem El Fekih

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:

AspectClassic TDDMockist TDD
Dependency InversionLess prescriptive about using dependency inversion everywherePushes toward dependency inversion by design
Starting ApproachUnopinionated about high-level approach; can start from any unit or use double-loop TDDRequires a “walking skeleton”; favours ATDD
Test Double UsageOccasional use of fakes/stubs for difficult scenariosA lot of mocked collaborators
Test Speed & ReliabilityCan have slower tests, fewer integration surprisesFaster tests, higher integration risk
Learning CurveLower initial learning curve, easier to startHigher initial learning curve, especially for setup
Maintenance & RefactoringEasier refactoringIncreased maintenance cost and harder refactoring due to layers of indirection and mocks
Design GenerationLess deterministic in generating OOP designMore deterministic in generating sensible OOP design
Methodology AlignmentLess aligned with ATDD and BDDATDD and BDD encourage the mockist style
Error LocalizationErrors may be scattered when real collaborators failMore focused error identification in isolated tests
Implementation RiskUsing real components adds verification benefitsPoor mockist practice can yield 100% coverage with non-functional software
State ExposureState-based assertions might expose more stateLess 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:

Related blogs