Advanced TDD Patterns: Mastering Mocks and Complex Logic
Move beyond simple TDD by mastering mocks and complex logic. Learn to apply advanced TDD patterns to handle dependencies and edge cases with confidence.

Previously in this course, we mastered the Red-Green-Refactor cycle and learned to write failing unit tests first. In this lesson, we level up. You'll learn how to apply these principles to complex logic that requires external dependencies, using mocks to keep your tests fast and deterministic.
The Challenge of Complexity
As our project grows, functions rarely live in isolation. A "PaymentProcessor" function, for instance, might need to talk to a database, a Stripe API, and a notification service. If you try to test this by spinning up all those services, your tests will be slow, flaky, and hard to maintain.
Advanced TDD requires us to decouple our business logic from these external systems. We do this by injecting dependencies and using mocks to simulate them.
TDD with Mocks: A Worked Example
Let’s evolve our project. We need a CheckoutService that processes payments. The core requirement is: If the payment gateway returns a success, update the order status to 'PAID' and send a confirmation email.
Instead of hitting a real API, we will mock the PaymentGateway and EmailService.
PYTHON# The interface our mock will implement class PaymentGateway: def charge(self, amount): pass # The class we are testing class CheckoutService: def __init__(self, gateway, email_service): self.gateway = gateway self.email_service = email_service def process(self, order, amount): result = self.gateway.charge(amount) if result == "SUCCESS": order.status = CE9178">'PAID' self.email_service.send(order.email, "Payment Received") return True return False
Step 1: Write the failing test
We define the requirement as a test. We don't care how gateway.charge works; we just need it to return "SUCCESS" so we can test our logic.
PYTHONfrom unittest.mock import Mock def test_process_updates_order_on_success(): # Setup mock_gateway = Mock() mock_gateway.charge.return_value = "SUCCESS" mock_email = Mock() service = CheckoutService(mock_gateway, mock_email) order = {CE9178">'status': CE9178">'PENDING', CE9178">'email': CE9178">'test@example.com'} # Execute result = service.process(order, 100) # Assert assert result is True assert order[CE9178">'status'] == CE9178">'PAID' mock_email.send.assert_called_once_with(CE9178">'test@example.com', "Payment Received")
Step 2: Handle Edge Cases
What if the gateway fails? This is where Equivalence Partitioning helps us identify the "FAILURE" state.
PYTHONdef test_process_does_not_update_on_failure(): mock_gateway = Mock() mock_gateway.charge.return_value = "DECLINED" mock_email = Mock() service = CheckoutService(mock_gateway, mock_email) order = {CE9178">'status': CE9178">'PENDING', CE9178">'email': CE9178">'test@example.com'} result = service.process(order, 100) assert result is False assert order[CE9178">'status'] == CE9178">'PENDING' mock_email.send.assert_not_called()
Advanced TDD Guidelines
When you're working with complex systems, keep these three rules in mind:
| Pattern | Goal | When to use |
|---|---|---|
| Dependency Injection | Decoupling | Always pass dependencies into the constructor. |
| Mocking | Isolation | Use for external calls (APIs, Databases, Files). |
| Stubbing | Determinism | Use for fixed data returns (e.g., datetime.now()). |
Practice Exercise
Take the CheckoutService code above and add a new requirement: If the payment gateway throws a connection exception, the system should catch it and return False, without crashing.
- Write the test first (it should fail because you haven't handled the exception).
- Update
processto use atry-exceptblock. - Verify the test passes.
Common Pitfalls
- Over-mocking: Don't mock your own domain objects. Mocking too much leads to "brittle tests" that break whenever you change implementation details, even if the result is correct.
- Ignoring the Refactor: As we discussed in Refactoring with Confidence, TDD isn't just about passing tests. If your
processmethod becomes too large, use the tests to safely break it into smaller, more readable private methods. - Testing Mocks instead of Behavior: Your test should describe what the system does, not how it talks to a mock. If you find your test code is twice as long as your implementation, you might be over-specifying.
Frequently Asked Questions
Q: When should I use a stub instead of a mock? A: Use a stub when you just need the dependency to provide data (like a configuration value). Use a mock when you need to verify that a method was actually called (like sending an email).
Q: Do these tests count as unit tests?
A: Yes, because you are isolating the CheckoutService from the actual implementation of the PaymentGateway. This is a classic unit test pattern for complex logic.
Recap
Advanced TDD is about controlling the environment. By injecting dependencies and mocking external systems, you turn unpredictable, complex logic into a series of predictable, verifiable steps. This approach ensures your code is not only correct but also modular and easy to implement with minimal code.
Up next: Debugging Complex State — how to track how your variables change over time when things go wrong.


