Back to Blog
Lesson 29 of the Software Testing & Debugging: Testing & Debugging Foundations (QA) course
TestingAugust 16, 20264 min read

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.

TDDtestingunit testingsoftware architecturemockingquality assurance
Close-up of a hand holding a colorful Rubik's Cube against a neutral background.

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.

PYTHON
from 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.

PYTHON
def 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:

PatternGoalWhen to use
Dependency InjectionDecouplingAlways pass dependencies into the constructor.
MockingIsolationUse for external calls (APIs, Databases, Files).
StubbingDeterminismUse 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.

  1. Write the test first (it should fail because you haven't handled the exception).
  2. Update process to use a try-except block.
  3. 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 process method 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.

Similar Posts