Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Low-Level Design (Object-Oriented Design)  Module 7 – Testing your design

Test doubles and seams

Choose between fakes, stubs, mocks and spies, build seams for time, ids and outside services, and keep tests from breaking on every refactoring.

  • Intermediate
  • 30 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free core, Pro depth: 11 of 30 minutes free
Free: Five kinds of test double · Seams: where a test double gets in · One test, three doubles, one refactoring · Practise: fakes for a checkout service. With a pass: 2 in-depth parts and 3 interview answers, about 19 minutes.

What you will learn

  • Choose between dummies, stubs, spies, mocks and fakes for each collaborator of a class
  • Create seams for time, randomness, ids and external services with constructor parameters and defaults
  • Avoid over-mocking that locks tests to implementation details
  • Apply unittest.mock.patch where the code looks a name up, and recognise a patch that misses
  • Verify that a fake stays honest by running the real implementation's contract tests against it

Before you start

What you’ll learn · Before you start

What you will learn

  • Choose between dummies, stubs, spies, mocks and fakes for each collaborator of a class
  • Create seams for time, randomness, ids and external services with constructor parameters and defaults
  • Avoid over-mocking that locks tests to implementation details
  • Apply unittest.mock.patch where the code looks a name up, and recognise a patch that misses
  • Verify that a fake stays honest by running the real implementation's contract tests against it

Before you start

On this page

Test doubles are objects that stand in for a class’s collaborators during a test. An order repository that lives in memory, an SMS gateway that delivers to a list and a clock that the test sets by hand are all test doubles. Seams are the places where code lets its caller choose those collaborators without editing it, most often constructor parameters. Together they let a unit test run in milliseconds and give the same answer on every run, with no database, network or real clock. The choice of double matters as much as the seam. For repositories and gateways, a fake, a small working implementation, is usually the best choice, because the test can check what happened rather than how. Mocks check the calls they receive; they fit outgoing side effects, such as a message that must be sent, and used everywhere else they make tests fail after harmless refactorings.

Five kinds of test double

Gerard Meszaros named five kinds of test double in xUnit Test Patterns, and Martin Fowler’s article Mocks Aren’t Stubs uses the same names. They differ in what the double does and in what the test checks afterwards:

  • Dummy: an object passed only to fill a parameter that this test never uses.
  • Stub: answers the calls the test expects with values the test chose, and does nothing else.
  • Spy: a stub that also records how it was called, so the test can inspect the calls afterwards.
  • Mock: an object told in advance which calls to expect, which fails the test when the calls differ.
  • Fake: a working implementation with a shortcut, such as a repository that keeps its rows in a dictionary.
Double What the test checks It breaks when
Dummy Nothing about it Rarely
Stub The result of the code under test The code asks for something new
Spy The calls it recorded The calls change
Mock The calls it received, against its expectations The calls change, even if the outcome does not
Fake The state it holds afterwards It drifts from the real implementation

Fowler’s article also names the two styles these doubles lead to. State verification checks the outcome, such as what a fake repository holds after the call; behaviour verification checks the calls themselves. The second style suits calls whose only effect is outside the program, and it is the style that ties a test to the code’s current shape.

Seams: where a test double gets in

Michael Feathers, who named the idea in Working Effectively with Legacy Code, defines a seam as a place where a program’s behaviour can change without editing the code at that place. Every seam has an enabling point, where someone picks the behaviour. In the shipping service of this lesson the seams are constructor parameters, and there are two enabling points: main.py in production and the test that builds the service.

A shipping service with four constructor parameters: main.py passes the real collaborators, a test passes test doubles.main.py, in productionSqlOrders, ProviderSmsSystemClock, uuid4ShipmentService(orders, sms, clock, new_id)four constructor parameters: four seamsa testInMemoryOrders (fake), SmsOutbox (fake)FixedClock (stub), ids from a counter (stub)passes the real collaboratorspasses test doubles

Four constructor parameters are four seams

Text description of the diagram

The diagram has three boxes in one column. In the middle is the class under test, ShipmentService, whose constructor takes four parameters: orders, sms, clock and new_id. Each parameter is a seam, a place where whoever builds the service decides which collaborator it gets.

  1. The box at the top, main.py, in production, lists the real collaborators: SqlOrders, ProviderSms, SystemClock and uuid4. An arrow labelled "passes the real collaborators" leads from it to the service.
  2. The box at the bottom, "a test", lists the test doubles: InMemoryOrders and SmsOutbox, which are fakes, and FixedClock and ids from a counter, which are stubs. An arrow labelled "passes test doubles" leads from it to the service.

The service's own code is the same in both cases; only the place that builds it changes.

Four kinds of collaborator need seams in almost every design: the clock, randomness, id generation, and anything outside the process, such as a database, a payment gateway or an SMS provider. Constructor parameters and method parameters are the plainest seams, because anyone reading the signature sees them. Replacing a module’s name with unittest.mock.patch also works, but the test then depends on how the code imports what it uses; keep it for code whose signature you cannot change.

One test, three doubles, one refactoring

ShipmentService.mark_shipped() marks a paid order as shipped and texts the customer the tracking number. Here is the same test written three times, with a fake, a stub and a mock of the order repository, run against the service before and after a refactoring that writes only the status column through set_status() instead of saving the whole order:

The same test with a fake, a stub and a mock, before and after a refactoring

three/run_three.py

"""Runs the same test, written with a fake, a stub and a mock, against ShipmentService before and after a refactoring
that changes how it talks to its repository but nothing a customer could notice. Then it shows what the two versions
do, with the fake, to check that the refactoring really is harmless.
"""

import importlib
import sys
import unittest

import shipment
import shipment_refactored
from doubles import InMemoryOrders, SmsOutbox

SUITES = [
    ("fake: InMemoryOrders", "test_with_fake"),
    ("stub: StubOrders", "test_with_stub"),
    ("mock: unittest.mock.Mock", "test_with_mock"),
]
VERSIONS = [("before", shipment), ("after", shipment_refactored)]


def outcome(test_module, version):
    """Runs a test module with `from shipment import ...` resolving to this version of the service."""
    sys.modules["shipment"] = version
    try:
        sys.modules.pop(test_module, None)
        tests = unittest.defaultTestLoader.loadTestsFromModule(importlib.import_module(test_module))
        result = unittest.TestResult()
        tests.run(result)
    finally:
        sys.modules["shipment"] = shipment
    for label, problems in (("error", result.errors), ("fail", result.failures)):
        if problems:
            last_line = problems[0][1].strip().splitlines()[-1]
            return f"{label:<6}{last_line}"
    return "pass"


print("The same test with three doubles, before and after the refactoring:\n")
print(f"{'double':<26}{'before':<8}after")
for name, module in SUITES:
    before, after = (outcome(module, version) for _, version in VERSIONS)
    print(f"{name:<26}{before:<8}{after}")

print("\nWhat the shop and the customer see, both versions run with the fake:")
for label, version in VERSIONS:
    orders = InMemoryOrders([shipment.Order(42, "+91 98450 12345", "paid")])
    sms = SmsOutbox()
    version.ShipmentService(orders, sms).mark_shipped(42, "TRK-7781")
    print(f"  {label + ':':<8}order 42 is {orders.get(42).status}; SMS: {sms.sent[0][1]}")

Output

The same test with three doubles, before and after the refactoring:

double                    before  after
fake: InMemoryOrders      pass    pass
stub: StubOrders          pass    error AttributeError: 'StubOrders' object has no attribute 'set_status'
mock: unittest.mock.Mock  pass    fail  AssertionError: Expected 'save' to be called once. Called 0 times.

What the shop and the customer see, both versions run with the fake:
  before: order 42 is shipped; SMS: Order 42 has shipped. Tracking number: TRK-7781
  after:  order 42 is shipped; SMS: Order 42 has shipped. Tracking number: TRK-7781

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 run_three.py

three/test_with_fake.py

import unittest

from doubles import InMemoryOrders, SmsOutbox
from shipment import Order, ShipmentService

PHONE = "+91 98450 12345"


class WithFakes(unittest.TestCase):
    def test_paid_order_is_shipped_and_the_customer_is_told(self):
        orders = InMemoryOrders([Order(42, PHONE, "paid")])
        sms = SmsOutbox()

        ShipmentService(orders, sms).mark_shipped(42, "TRK-7781")

        self.assertEqual(orders.get(42).status, "shipped")  # the state the repository now holds
        self.assertEqual(sms.sent, [(PHONE, "Order 42 has shipped. Tracking number: TRK-7781")])

three/test_with_stub.py

import unittest

from doubles import SmsOutbox, StubOrders
from shipment import Order, ShipmentService

PHONE = "+91 98450 12345"


class WithAStub(unittest.TestCase):
    def test_paid_order_is_shipped_and_the_customer_is_told(self):
        order = Order(42, PHONE, "paid")
        sms = SmsOutbox()

        ShipmentService(StubOrders(order), sms).mark_shipped(42, "TRK-7781")

        self.assertEqual(order.status, "shipped")  # the object the stub handed out was changed
        self.assertEqual(sms.sent, [(PHONE, "Order 42 has shipped. Tracking number: TRK-7781")])

three/test_with_mock.py

import unittest
from unittest.mock import Mock

from shipment import Order, ShipmentService

PHONE = "+91 98450 12345"


class WithMocks(unittest.TestCase):
    def test_paid_order_is_shipped_and_the_customer_is_told(self):
        orders = Mock()
        orders.get.return_value = Order(42, PHONE, "paid")
        sms = Mock()

        ShipmentService(orders, sms).mark_shipped(42, "TRK-7781")

        orders.save.assert_called_once_with(Order(42, PHONE, "shipped"))  # the calls the code made
        sms.send.assert_called_once_with(PHONE, "Order 42 has shipped. Tracking number: TRK-7781")

three/shipment.py

"""The shipping step of an online shop: a paid order is marked as shipped, and the customer gets an SMS.

ShipmentService receives its two collaborators through its constructor, the seams that let a test choose them.
"""

from dataclasses import dataclass
from typing import Protocol


@dataclass
class Order:
    id: int
    phone: str
    status: str  # "paid", then "shipped"


class OrderError(Exception):
    """A request that the rules refuse."""


class Orders(Protocol):
    def get(self, order_id: int) -> Order | None: ...
    def save(self, order: Order) -> None: ...
    def set_status(self, order_id: int, status: str) -> None: ...


class Sms(Protocol):
    def send(self, to: str, text: str) -> None: ...


class ShipmentService:
    def __init__(self, orders: Orders, sms: Sms):
        self._orders = orders
        self._sms = sms

    def mark_shipped(self, order_id: int, tracking: str) -> None:
        order = self._orders.get(order_id)
        if order is None:
            raise OrderError(f"no order {order_id}")
        if order.status != "paid":
            raise OrderError(f"order {order_id} is {order.status}, not paid")
        order.status = "shipped"
        self._orders.save(order)
        self._sms.send(order.phone, f"Order {order_id} has shipped. Tracking number: {tracking}")

three/shipment_refactored.py

"""The same service after a refactoring: it writes only the status column, through Orders.set_status, instead of
saving the whole order. Nothing a customer or the shop could notice has changed.
"""

from shipment import Order, OrderError, Orders, Sms  # noqa: F401  (the same types as before)


class ShipmentService:
    def __init__(self, orders: Orders, sms: Sms):
        self._orders = orders
        self._sms = sms

    def mark_shipped(self, order_id: int, tracking: str) -> None:
        order = self._orders.get(order_id)
        if order is None:
            raise OrderError(f"no order {order_id}")
        if order.status != "paid":
            raise OrderError(f"order {order_id} is {order.status}, not paid")
        self._orders.set_status(order_id, "shipped")  # one column instead of the whole row
        self._sms.send(order.phone, f"Order {order_id} has shipped. Tracking number: {tracking}")

three/doubles.py

"""Hand-written test doubles for ShipmentService."""

from dataclasses import replace


class InMemoryOrders:
    """A fake: a working Orders repository that keeps its rows in a dict and hands out copies, as a database does."""

    def __init__(self, orders=()):
        self._rows = {o.id: replace(o) for o in orders}

    def get(self, order_id):
        row = self._rows.get(order_id)
        return replace(row) if row else None

    def save(self, order):
        self._rows[order.id] = replace(order)

    def set_status(self, order_id, status):
        self._rows[order_id] = replace(self._rows[order_id], status=status)


class StubOrders:
    """A stub: get() answers with the order the test chose; save() stores nothing."""

    def __init__(self, order):
        self.order = order

    def get(self, order_id):
        return self.order

    def save(self, order):
        pass


class SmsOutbox:
    """A fake SMS gateway: every message is delivered to a list that the test can read."""

    def __init__(self):
        self.sent = []

    def send(self, to, text):
        self.sent.append((to, text))

All three tests passed before the change. Afterwards only the fake’s test passed, although the last two lines of the run show that the shop and the customer see exactly the same outcome. The stub had no set_status(), because it was written for the calls the code made at the time. The mock expected a call to save() that no longer happens. Neither red result found a bug; each one only reported that the code had changed shape.

Practise: fakes for a checkout service

A checkout service charges a card through a payment gateway and keeps orders in a repository. Write a fake of each, in Python or JavaScript: a gateway that declines the cards it is told to and never charges a retried key twice, and a repository that hands out copies, as a database does. The sample tests run checkout scenarios with your fakes.

Exercise · Medium · Python, JavaScript

Write the fakes that a checkout service is tested with

A checkout service charges a card through a payment gateway and keeps its orders in a repository. Both arrive through its constructor, so a test can pass in fakes: small working versions that run in memory. Write the two fakes in fakes.py (fakes.mjs in JavaScript). The comment at the top of the file gives every method; these are the rules they must keep:

  • The gateway numbers new charges ch_1, ch_2 and so on. A declined card raises PaymentDeclined (throws, in JavaScript) and is charged nothing. A key that was used before returns the first charge's id and charges nothing more, so a retried checkout never charges twice. total_charged(card) (totalCharged) adds up what a card paid.
  • The repository hands out ids 1, 2, 3 and so on from next_id() (nextId()). It stores a copy of every order it saves and returns a copy from get() and for_customer() (forCustomer()), the way a database does: changing an order you got changes nothing until you save it. An unknown id gives None (null), and for_customer() lists one customer's orders sorted by id.

The sample tests are checkout scenarios that use your fakes: a paid checkout, a declined card, a retry with the same key, two separate checkouts, copies in and out, and a cancellation. The service itself is in the test file; it does not change.

Python · Starter code · fakes.py

"""Fakes for the checkout service: a payment gateway and an order repository that work, in memory.

FakePaymentGateway(declined_cards=())
  charge(card, amount, key)   the charge id, "ch_1", "ch_2", … in the order new charges are made. A card in
                              declined_cards raises PaymentDeclined and charges nothing. A key that was used before
                              returns the first charge's id and charges nothing more.
  total_charged(card)         the sum of the amounts charged to the card (0 for a card never charged)

InMemoryOrders()
  next_id()                   1, then 2, 3, … : a new order id on every call
  save(order)                 stores a copy of the order (orders are dataclasses: dataclasses.replace copies one)
  get(order_id)               a copy of the stored order, or None
  for_customer(customer)      copies of the customer's orders, sorted by id
"""

from dataclasses import replace  # noqa: F401  (you will need it)


class PaymentDeclined(Exception):
    """The card was declined."""


class FakePaymentGateway:
    def __init__(self, declined_cards=()):
        raise NotImplementedError

    def charge(self, card, amount, key):
        raise NotImplementedError

    def total_charged(self, card):
        raise NotImplementedError


class InMemoryOrders:
    def __init__(self):
        raise NotImplementedError

    def next_id(self):
        raise NotImplementedError

    def save(self, order):
        raise NotImplementedError

    def get(self, order_id):
        raise NotImplementedError

    def for_customer(self, customer):
        raise NotImplementedError
The sample tests · test_fakes.py
"""Checkout scenarios run with your fakes. CheckoutService is the code under test; it gets its gateway and its order
repository through its constructor, so these tests can pass in yours.
"""

from dataclasses import dataclass

from fakes import FakePaymentGateway, InMemoryOrders, PaymentDeclined


@dataclass
class Order:
    id: int
    customer: str
    amount: int  # paise
    charge_id: str
    status: str  # "paid" or "cancelled"


class CheckoutError(Exception):
    pass


class CheckoutService:
    def __init__(self, gateway, orders):
        self._gateway = gateway
        self._orders = orders

    def checkout(self, customer, card, amount, key):
        """Charges the card and stores a paid order; a retry with the same key returns the same order."""
        charge_id = self._gateway.charge(card, amount, key)
        for order in self._orders.for_customer(customer):
            if order.charge_id == charge_id:
                return order.id
        order = Order(self._orders.next_id(), customer, amount, charge_id, "paid")
        self._orders.save(order)
        return order.id

    def cancel(self, order_id):
        order = self._orders.get(order_id)
        if order is None or order.status != "paid":
            raise CheckoutError(f"order {order_id} cannot be cancelled")
        order.status = "cancelled"
        self._orders.save(order)


def make(declined_cards=()):
    gateway, orders = FakePaymentGateway(declined_cards), InMemoryOrders()
    return CheckoutService(gateway, orders), gateway, orders


def test_checkout_charges_once_and_stores_a_paid_order():
    """a checkout charges the card once and stores a paid order"""
    shop, gateway, orders = make()
    order_id = shop.checkout("asha", "card-1", 49_900, key="k1")
    assert gateway.total_charged("card-1") == 49_900
    assert orders.get(order_id) == Order(order_id, "asha", 49_900, "ch_1", "paid")


def test_declined_card_stores_no_order():
    """a declined card raises PaymentDeclined, charges nothing and stores no order"""
    shop, gateway, orders = make(declined_cards={"card-9"})
    try:
        shop.checkout("ravi", "card-9", 12_000, key="k1")
        raised = False
    except PaymentDeclined:
        raised = True
    assert raised
    assert gateway.total_charged("card-9") == 0
    assert orders.for_customer("ravi") == []


def test_retry_with_the_same_key_charges_once():
    """a retry with the same key charges once and returns the same order"""
    shop, gateway, orders = make()
    first = shop.checkout("asha", "card-1", 49_900, key="k1")
    again = shop.checkout("asha", "card-1", 49_900, key="k1")
    assert again == first
    assert gateway.total_charged("card-1") == 49_900
    assert len(orders.for_customer("asha")) == 1


def test_two_keys_make_two_charges_and_two_orders():
    """two checkouts with different keys make charges ch_1 and ch_2 and orders 1 and 2"""
    shop, gateway, orders = make()
    assert shop.checkout("asha", "card-1", 10_000, key="k1") == 1
    assert shop.checkout("asha", "card-1", 25_000, key="k2") == 2
    assert gateway.total_charged("card-1") == 35_000
    assert [o.charge_id for o in orders.for_customer("asha")] == ["ch_1", "ch_2"]


def test_get_returns_a_copy():
    """changing an order you got does not change the stored one until it is saved"""
    _, _, orders = make()
    orders.save(Order(1, "asha", 10_000, "ch_1", "paid"))
    order = orders.get(1)
    order.status = "cancelled"
    assert orders.get(1).status == "paid"
    orders.save(order)
    assert orders.get(1).status == "cancelled"


def test_save_keeps_a_copy():
    """changing an order after saving it does not change the stored one"""
    _, _, orders = make()
    order = Order(1, "asha", 10_000, "ch_1", "paid")
    orders.save(order)
    order.amount = 1
    assert orders.get(1).amount == 10_000


def test_cancel_and_list_by_customer():
    """cancel stores the new status; for_customer lists only that customer's orders, sorted by id"""
    shop, _, orders = make()
    a1 = shop.checkout("asha", "card-1", 10_000, key="k1")
    shop.checkout("ravi", "card-2", 20_000, key="k2")
    a2 = shop.checkout("asha", "card-1", 30_000, key="k3")
    shop.cancel(a1)
    assert [(o.id, o.status) for o in orders.for_customer("asha")] == [(a1, "cancelled"), (a2, "paid")]
    assert orders.get(99) is None

JavaScript · Starter code · fakes.mjs

/**
 * Fakes for the checkout service: a payment gateway and an order repository that work, in memory.
 *
 * new FakePaymentGateway(declinedCards = [])
 *   charge(card, amount, key)   the charge id, "ch_1", "ch_2", … in the order new charges are made. A card in
 *                               declinedCards throws PaymentDeclined and charges nothing. A key that was used before
 *                               returns the first charge's id and charges nothing more.
 *   totalCharged(card)          the sum of the amounts charged to the card (0 for a card never charged)
 *
 * new InMemoryOrders()
 *   nextId()                    1, then 2, 3, … : a new order id on every call
 *   save(order)                 stores a copy of the order (orders are plain objects: { ...order } copies one)
 *   get(orderId)                a copy of the stored order, or null
 *   forCustomer(customer)       copies of the customer's orders, sorted by id
 */
export class PaymentDeclined extends Error {}

export class FakePaymentGateway {
  constructor(declinedCards = []) {
    throw new Error('not written yet');
  }

  charge(card, amount, key) {
    throw new Error('not written yet');
  }

  totalCharged(card) {
    throw new Error('not written yet');
  }
}

export class InMemoryOrders {
  constructor() {
    throw new Error('not written yet');
  }

  nextId() {
    throw new Error('not written yet');
  }

  save(order) {
    throw new Error('not written yet');
  }

  get(orderId) {
    throw new Error('not written yet');
  }

  forCustomer(customer) {
    throw new Error('not written yet');
  }
}
The sample tests · fakes.test.mjs
// Checkout scenarios run with your fakes. CheckoutService is the code under test; it gets its gateway and its order
// repository through its constructor, so these tests can pass in yours.
import { test, assert } from 'mysmartcopilot:test';
import { FakePaymentGateway, InMemoryOrders, PaymentDeclined } from './fakes.mjs';

class CheckoutError extends Error {}

class CheckoutService {
  constructor(gateway, orders) {
    this.gateway = gateway;
    this.orders = orders;
  }

  /** Charges the card and stores a paid order; a retry with the same key returns the same order. */
  checkout(customer, card, amount, key) {
    const chargeId = this.gateway.charge(card, amount, key);
    for (const order of this.orders.forCustomer(customer)) {
      if (order.chargeId === chargeId) return order.id;
    }
    const order = { id: this.orders.nextId(), customer, amount, chargeId, status: 'paid' };
    this.orders.save(order);
    return order.id;
  }

  cancel(orderId) {
    const order = this.orders.get(orderId);
    if (order === null || order.status !== 'paid') throw new CheckoutError(`order ${orderId} cannot be cancelled`);
    order.status = 'cancelled';
    this.orders.save(order);
  }
}

function make(declinedCards = []) {
  const gateway = new FakePaymentGateway(declinedCards);
  const orders = new InMemoryOrders();
  return { shop: new CheckoutService(gateway, orders), gateway, orders };
}

test('a checkout charges the card once and stores a paid order', () => {
  const { shop, gateway, orders } = make();
  const id = shop.checkout('asha', 'card-1', 49900, 'k1');
  assert.equal(gateway.totalCharged('card-1'), 49900);
  assert.deepEqual(orders.get(id), { id, customer: 'asha', amount: 49900, chargeId: 'ch_1', status: 'paid' });
});

test('a declined card throws PaymentDeclined, charges nothing and stores no order', () => {
  const { shop, gateway, orders } = make(['card-9']);
  assert.throws(() => shop.checkout('ravi', 'card-9', 12000, 'k1'), PaymentDeclined);
  assert.equal(gateway.totalCharged('card-9'), 0);
  assert.deepEqual(orders.forCustomer('ravi'), []);
});

test('a retry with the same key charges once and returns the same order', () => {
  const { shop, gateway, orders } = make();
  const first = shop.checkout('asha', 'card-1', 49900, 'k1');
  const again = shop.checkout('asha', 'card-1', 49900, 'k1');
  assert.equal(again, first);
  assert.equal(gateway.totalCharged('card-1'), 49900);
  assert.equal(orders.forCustomer('asha').length, 1);
});

test('two checkouts with different keys make charges ch_1 and ch_2 and orders 1 and 2', () => {
  const { shop, gateway, orders } = make();
  assert.equal(shop.checkout('asha', 'card-1', 10000, 'k1'), 1);
  assert.equal(shop.checkout('asha', 'card-1', 25000, 'k2'), 2);
  assert.equal(gateway.totalCharged('card-1'), 35000);
  assert.deepEqual(orders.forCustomer('asha').map((o) => o.chargeId), ['ch_1', 'ch_2']);
});

test('changing an order you got does not change the stored one until it is saved', () => {
  const { orders } = make();
  orders.save({ id: 1, customer: 'asha', amount: 10000, chargeId: 'ch_1', status: 'paid' });
  const order = orders.get(1);
  order.status = 'cancelled';
  assert.equal(orders.get(1).status, 'paid');
  orders.save(order);
  assert.equal(orders.get(1).status, 'cancelled');
});

test('changing an order after saving it does not change the stored one', () => {
  const { orders } = make();
  const order = { id: 1, customer: 'asha', amount: 10000, chargeId: 'ch_1', status: 'paid' };
  orders.save(order);
  order.amount = 1;
  assert.equal(orders.get(1).amount, 10000);
});

test("cancel stores the new status; forCustomer lists only that customer's orders, sorted by id", () => {
  const { shop, orders } = make();
  const a1 = shop.checkout('asha', 'card-1', 10000, 'k1');
  shop.checkout('ravi', 'card-2', 20000, 'k2');
  const a2 = shop.checkout('asha', 'card-1', 30000, 'k3');
  shop.cancel(a1);
  assert.deepEqual(orders.forCustomer('asha').map((o) => [o.id, o.status]), [[a1, 'cancelled'], [a2, 'paid']]);
  assert.equal(orders.get(99), null);
});
A hint

Keep three things in the gateway: the declined cards as a set, a dictionary from each key to the charge id it got, and a list of the charges made. Check the key first: a retry returns the old id before anything else happens. For the repository, keep a dictionary from id to a copy of each order (dataclasses.replace(order) in Python, { ...order } in JavaScript), and copy again on the way out.

The sample tests run on this device, in your browser (Pyodide, QuickJS): nothing is sent to mysmartcopilot.com. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Check yourself

5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 5 A service under test reads orders with get() and writes them with save(). Which test double usually serves it best?

    Choose one answer.

    Show the answer to question 1

    Answer: A fake, such as an in-memory repository that really stores and returns orders

    A fake behaves like the real repository, so the test can check the outcome (what the repository holds after the call) instead of the calls that led to it. It keeps passing when the service changes how it reads or writes, and it catches a service that forgets to store a change.

  2. Question 2 of 5 This test passes today. Which change to mark_shipped(), with no effect a customer could notice, makes it fail?

    Read the code, then choose one answer.

    orders = Mock()
    orders.get.return_value = Order(42, PHONE, "paid")
    ShipmentService(orders, sms).mark_shipped(42, "TRK-7781")
    orders.save.assert_called_once_with(Order(42, PHONE, "shipped"))
    Show the answer to question 2

    Answer: Writing only the status column with orders.set_status(42, "shipped") instead of save()

    The assertion checks a call, not an outcome. After the change the order still ends up shipped, but save() is never called, so assert_called_once_with fails. The other three changes leave every call to the repository as it was.

  3. Question 3 of 5 After the same refactoring, the test written with StubOrders failed with AttributeError. Why?

    Choose one answer.

    Show the answer to question 3

    Answer: The stub implements only the calls the code made when the test was written, and set_status was not one of them

    A stub answers the calls a test expects and nothing else. When the code starts asking for something new, the stub has no answer, even though the behaviour is unchanged. A fake implements the whole interface, so it has set_status() already.

  4. Question 4 of 5 For which collaborator is checking the interaction, with a mock or a spy, the right test?

    Choose one answer.

    Show the answer to question 4

    Answer: An SMS gateway, when the behaviour is that exactly one message goes to the customer

    An outgoing message is a side effect nobody else can observe in a unit test, so the call is the behaviour: one send() with the right number and text. Reads from a repository or a clock are queries; give them answers with a fake or a stub and check the result instead.

  5. Question 5 of 5 Which of these give a test a way to choose a collaborator without editing the code under test?

    Choose every answer that is right.

    Show the answer to question 5

    Answer:

    • A constructor parameter, such as ShipmentService(orders, sms)
    • A parameter with a default, such as clock=time.monotonic
    • A method parameter, such as enter(plate, kind, at) with the time passed in

    Constructor parameters, method parameters and defaults are seams: the caller decides, and the class's code stays the same. A local variable gives the test no say at all, and overwriting a private attribute is a test that depends on how the class is built inside.

Python Online Compiler Paste run_three.py and its files, then change the stub so that it survives the refactoring, and see what it now has to know.

Seams for time, chance and outside services, with the patching traps reproduced Pro: opens with a Pro, Premium or Ultimate pass

A clock or a random code can reach a test through a constructor parameter or through a patched module name. Here one login-code service is tested both ways, a patch that misses its target is reproduced, and six kinds of seam are compared with their costs.

In this part:

  • A patched clock next to an injected one, test for test
  • A patch that misses its target, reproduced
  • Seams for ids, randomness and outside services
  • Six kinds of seam, compared

2 runnable examples (compare.py, otp.py, otp_fromimport.py, otp_hardwired.py, patch_misses.py, test_otp_injected.py, test_otp_patched.py) · about 4 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Read a complete sample first: Low-level design: parking lot (the model)

Over-mocking, measured: false alarms, missed bugs and honest fakes Pro: opens with a Pro, Premium or Ultimate pass

Doubles that check calls fail when the code changes how it works, and they can stay green when the outcome is wrong. This part counts both for four suites over eight changed versions of one service, then shows a mock that hides an API change and a fake that hides a bug.

In this part:

  • Four suites, eight changes: the numbers
  • A mock that accepts calls the real client refuses
  • A fake that lies, and the contract that catches it
  • Choosing a double, with the trade-offs

3 runnable examples (Python, matrix.py, orders_contract.py, repos.py, run_contract.py, suites.py, variants.py) · about 4 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Interview questions

  1. Fresher to mid · Warm-up

    What is the difference between a stub and a mock, and which would you use for an order repository?

    A stub answers the calls a test expects with values the test chose and checks nothing, so the test asserts on the result of the code under test. A mock is told in advance which calls to expect and fails the test when the calls it receives differ, so the test asserts on the interaction itself. For an order repository neither is the first choice: a fake, an in-memory repository that really stores and returns orders, lets the test check the state the repository ends with, and it keeps passing when the code changes how it reads and writes. A stub suits a single read when no fake exists. Interaction checks, with a fake outbox, a spy or an autospec mock, belong to outgoing side effects such as an SMS, where the call is the behaviour and nothing else can observe it.

  2. Mid to senior

    After a refactoring that changed no behaviour, 40 of your team's 120 service tests failed. They mock the repository and assert every call it receives. What do you change, and what stays mocked?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. Fresher to mid

    A coupon class calls time.time() and uuid.uuid4() inside its methods, and its 15-minute expiry rule has no tests. How do you make it testable without patching, and what do you do if you may not change its constructor?

    Model answer, Pro. First step and follow-ups

  4. Mid to senior

    Every test passes with your in-memory fake repository, but in production some status changes are lost. How could the fake be hiding the bug, and how do you keep fakes honest?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A test double takes a collaborator’s place in a test; a seam is where the code lets its caller choose that collaborator.
  • Dummies fill parameters, stubs answer, spies record, mocks check calls, and fakes work, with a shortcut.
  • Prefer fakes for repositories and gateways: they let tests check outcomes, which survive refactorings.
  • Keep interaction checks for outgoing side effects such as an SMS, where the call is the behaviour.
  • Make seams for the clock, randomness, ids and outside services with constructor parameters and defaults.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.