software-engineering testing

Definition

Test Double

A Test Double is a generic term for any case where you replace a production object for testing purposes. It is analogous to a “stunt double” in a movie.

Goal: Isolate the system under test from its dependencies to ensure tests are fast, deterministic, and focused.

Motivation

Real software often depends on infrastructure that is unsuitable for unit testing:

  1. Speed: External dependencies (Databases, Web Services) are slow.
  2. Determinism: Some behaviours are hard to reproduce (e.g., NetworkTimeout, specific timestamps).
  3. Control: It is difficult to force a real external system into an error state.

Types

There are five common types of doubles, increasing in complexity:

Dummy Object

Dummy Object

Objects that are passed around but never actually used. They are usually just placeholders to fill parameter lists.

  • Purpose: Satisfy API signatures.
  • Behaviour: None.
def test_customer_count():
    # The email_service is a dummy;
    # it's required by the constructor
    # but not used in this test.
    dummy_email_service = None
    customer_repo = CustomerRepository(dummy_email_service)
    assert customer_repo.count() == 0

2. Fake Objects

Fake Object

Objects that have working implementations, but take shortcuts which make them unsuitable for production (e.g., using an in-memory HashMap instead of a real database).

  • Purpose: Simulate the real system behaviour quickly.
  • Behaviour: Functional but simplified.
class FakeDatabase:
    def __init__(self):
        self.users = {}
 
    def save(self, user):
        self.users[user.id] = user
 
    def find(self, user_id):
        return self.users.get(user_id)

Stub

Stub

Objects that provide canned answers to calls made during the test. They generally do not respond to anything outside what’s programmed for the specific test.

  • Purpose: Provide specific inputs to the system under test.
  • Behaviour: Hard-coded return values.
class StubWeatherService:
    def get_temperature(self, city):
        return 25  # Canned answer
 
def test_temperature_display():
    controller = WeatherController(StubWeatherService())
    assert controller.display_temp() == "25°C"

Mock

Mock

Objects pre-programmed with expectations which form a specification of the calls they are expected to receive. They throw exceptions if they receive a call they don’t expect.

  • Purpose: Verify the interaction between objects.
  • Behaviour: Logs calls and validates them against specific expectations.
from unittest.mock import Mock
 
def test_order_emails_customer():
    mailer = Mock()
    order = Order(mailer)
    order.checkout()
 
    # Verify interaction
    mailer.send_email.assert_called_with("Thanks for your order!")

Spy

Spy

Wrappers around real (or stubbed) objects that log interactions (arguments, call counts) for later verification.

  • Purpose: verify side effects without mocking the entire behaviour.
  • Behaviour: Acts like the real object but records usage.
class SpyLogger:
    def __init__(self):
        self.messages = []
 
    def log(self, message):
        self.messages.append(message)
 
def test_service_logs_error():
    spy = SpyLogger()
    service = Service(spy)
    service.do_something_risky()
 
    assert "Error occurred" in spy.messages

Trade-offs

AspectProsCons
ControlAllows testing of edge cases (errors, timeouts) impossible with real systems.
SpeedSimulations are orders of magnitude faster than I/O.
CouplingTests may become coupled to implementation details (interaction testing).
FidelityDoubles are not real; passing tests does not guarantee production success (validation gap).
MaintenanceDoubles must be updated whenever the real interfaces change.