Skip to content
intermediate Phase 15 · SDET Coding Patterns

Design Patterns in Test Code

Apply builder, strategy, and arrange-act-assert structure to keep tests readable.

1h 10m
0 problems
Topic Progress 0%

Arrange-Act-Assert: Rewriting a Test That Does Nine Things in One Body

A test named test_checkout fails in CI at 02:00. The trace runs twelve frames deep, the last visible line is assert r.status_code == 200, and reading the body shows it logs in a user, seeds three rows, adds two items, applies a coupon, calls the payment stub, asserts twice, and then deletes data. Which of those nine steps was the assertion actually about? You cannot tell without re-reading everything. Arrange-Act-Assert fixes that by giving each test three explicit sections, and it is the first pattern an interviewer will ask you to apply to a messy test on the whiteboard.

Here is the version most teams inherit:

def test_checkout(db, client):
    db.execute("INSERT INTO users(email) VALUES ('kim@example.com')")
    token = client.post('/login', json={'email': 'kim@example.com'}).json['token']
    client.post('/cart/items', json={'sku': 'B07X', 'qty': 2}, headers=auth(token))
    client.post('/cart/coupon', json={'code': 'PRIME10'}, headers=auth(token))
    r = client.post('/orders', headers=auth(token))
    assert r.status_code == 200
    assert r.json['total'] == 42.00
    db.execute('DELETE FROM orders WHERE user_id = 1')

Nine statements, two behaviors, cleanup wedged after the asserts, and a failure at line 6 tells you nothing about which precondition was wrong. Rewrite it so each phase is visible:

def test_invoice_total_when_coupon_applies(db, client):
    # Arrange: valid cart whose only variation is the coupon
    order = OrderBuilder(db).with_item(sku='B07X', qty=2, price=25.00).build()
    client.post('/cart/coupon', json={'code': 'PRIME10'}, headers=auth(order.owner))

    # Act: exactly one call
    r = client.post('/orders', headers=auth(order.owner))

    # Assert: one observable, with the numbers in the message
    assert r.status_code == 200
    assert r.json['total'] == 42.00, f"expected 42.00 after PRIME10, got {r.json['total']}"

The three phases answer three different questions:

Phase Question it answers Typical violation
Arrange what must be true before the action? setup buried mid-test, or seeded in a file nobody opens
Act what is the single behavior under test? two calls, so the second masks the first one's effect
Assert which observable changed? assertions interleaved with cleanup and print debugging

Two discipline points make the pattern pay off. First, one logical behavior per test: a test asserting at lines 10, 40, and 90 stops at the first failure and hides the other two, so split it into three tests that share the arrange through a builder — chapter 2 covers that. Cleanup is not a fourth section: it belongs in a fixture teardown so a failed assert never strands data. Expensive arrange is a scoping question rather than a reason to inline setup: a seeded catalogue reused by twenty tests belongs in a module-scoped fixture, while a login that must stay isolated stays function-scoped — Fixtures, Plugins, and Extensibility covers that mechanism. When the arrange fits in three visible lines, a reader knows exactly where to look if the starting data was wrong. Second, the name states the behavior — test_invoice_total_when_coupon_applies reads in the failure list as a complete sentence, so a red build explains itself before anyone opens the file. Teams writing Given/When/Then are using the same shape with different labels; the mechanics are identical.

In an interview, the question is "what does AAA actually buy you?" — three concrete answers: failures localize to a phase (a broken assert means the behavior, a broken arrange means the data), tests become independently runnable and reorderable because nothing depends on execution order, and reviewers can judge intent without simulating the whole flow. From Auth and Session Security Checks you already know how to verify behavior; this pattern is about how the test itself stays readable while hundreds of them accumulate. The arrange section is where most tests rot, and the builder pattern in the next chapter is the cure.

Builder for Test Data, Strategy for Swappable Behavior

The arrange phase rots first because test data is usually built with a constructor that grew one optional parameter per release: Order(customer, items, discount, currency, channel, notes, loyalty). Every call site passes None, None, 0.10, 'USD' positionally, reviewers cannot tell which slot is which, and adding an eighth parameter means editing forty tests. The builder pattern replaces positional soup with named, chainable steps.

const order = new OrderBuilder()
  .withCustomer({ tier: 'prime' })
  .withItem({ sku: 'B07X', qty: 2, price: 25.00 })
  .withCoupon('PRIME10')
  .build();   // total 42.00 — the invariant is the builder's job

Only the lines that express this test's actual variation appear; everything else falls back to defaults that produce a valid object every time. That last property is the real point: build() refuses to construct an order with no items or a non-positive total, so a test that fails at arrange fails with build() requires at least one item instead of a confusing 500 from the API. Contrast this with a shared factory that silently patches the database — a builder keeps construction in the test file where the reviewer is already looking.

The second pattern answers a different question: how does one test run against several implementations? A checkout test should pass against a mock gateway in CI, Stripe's sandbox on demand, and PayPal's sandbox before release. Make the variation a policy object:

interface PaymentGateway { charge(amount: number): Promise<Receipt>; }
// implementations: MockGateway, StripeSandbox, PayPalSandbox

test('order flips to paid once the gateway confirms', async ({ gateway, order }) => {
  await order.checkout(gateway);
  expect(order.status).toBe('paid');
});

The test names the behavior and never the implementation; a fixture selects the strategy from GATEWAY=stripe or from the test project. Adding a fourth backend means writing one new class — no test file changes. That is the open-closed payoff interviewers want you to name: behavior tests stay stable while the set of implementations grows.

The template pattern is the third tool, for tests that share a skeleton with a few varying steps: a CheckoutTestBase class whose setUpCart() hook subclasses override, or Python's unittest.TestCase.setUp doing the same job. Use it when the sequence itself is fixed (login, seed, act, verify) and only the contents of a step differ. Its failure mode is deep hierarchies — a base class three levels up mutating state that the bottom test reads is how "where does this value come from?" becomes a meeting.

One restraint keeps these patterns from becoming decoration: the smell decides, not fashion. A data object with three fields deserves a plain literal; a builder earns its place the day call sites start passing positional None to skip arguments. A strategy is ceremony while only one implementation exists, and a template with a single subclass is a renamed helper. Reach for a pattern when the duplication or the unreadability is measurable — then give it a guard, an invariant inside build(), a fixture that owns selection, so the pattern enforces something instead of merely naming it.

Pattern The question it answers The smell it removes
Builder how do tests express just the data they vary? telescoping constructors and positional None chains
Strategy how does one test cover many implementations? copy-pasted tests differing only in the client they instantiate
Template how do tests share a fixed sequence? duplicated setup sprawl across a test class

Prefer composition over inheritance depth: fixtures and injected builders give you the same reuse with the wiring visible in the test signature, which is exactly where Custom Matchers and Assertions picks up — once data and behavior are structured, the assertions themselves become the last unreadable part.

Small Pure Helpers Versus the God Utility Class Every Test Imports

Every test suite eventually grows a CommonUtils class: forty static methods, imported by two hundred test files, mixing format_price, wait_for_spinner, reset_database, and send_slack_alert. It compiles, everyone depends on it, and nobody will refactor it because changing one line risks sixty tests. The escape route is to split it by property: pure functions stay, side effects move to fixtures. Migration is incremental and survivable: list which helper methods each test file actually imports, move the pure ones into a helpers/ module with direct unit tests, re-home the side-effecting ones as fixtures, and leave CommonUtils behind a deprecation warning until its import count reaches zero. Each step keeps the suite green, so the cleanup happens in small pull requests instead of a frozen branch.

A helper is pure when the same input always returns the same output and it touches nothing outside its arguments — no database, no browser, no clock, no global state:

def cents_to_display(cents: int) -> str:      # 1999 -> '$19.99'
    return f'${cents // 100}.{cents % 100:02d}'

def normalize_email(value: str) -> str:       # ' Kim@Example.COM ' -> 'kim@example.com'
    return value.strip().lower()

def nth_business_day(base: date, n: int) -> date:
    ...

Because they are pure, you can test them with the runner alone — no browser, no database, no fixtures — and that gives you something rare: unit tests for the test code itself. A bug in cents_to_display currently shows up as a hundred mysterious expected 42.00, got 4200 failures scattered across the suite; with a direct test it shows up as one red test in helpers_test.py naming the function.

Purity is also what kills a large share of flakiness. Two helpers deserve special mention because they are where determinism usually dies:

from freezegun import freeze_time

@freeze_time('2026-01-15T09:00:00Z')
def test_invoice_uses_date_at_render():
    assert invoice_date() == date(2026, 1, 15)   # not whatever today is

Faker.seed(7)          # same fake names and addresses on every machine
random.seed(7)          # reproducible order shuffles

A helper reading datetime.now() makes the suite timezone-dependent; a helper writing to a shared row makes tests order-dependent. Both failures look like flaky infrastructure and both disappear when the clock and the seed become inputs. When a test fails at 02:00, "same input, same output" is the property you lean on to reproduce it locally on the first try.

Review rigor is the pattern that ties it together, and the argument is short: tests gate every release, so they are production code. The concrete version looks like this:

Rule Enforcement
tests get reviewed CODEOWNERS on tests/**; no self-merge for test changes
tests get linted ruff check tests/, eslint tests/ in the same CI job
no unexplained sleeps time.sleep(10) fails review unless justified in a comment; wait on state instead
deletions are deliberate removing a test requires a note on what coverage is lost and why that is acceptable
helpers stay small and pure any helper needing a driver or a connection moves to a fixture, not CommonUtils

None of these rules is exotic; each removes a class of "works locally, red on CI" reports. The engineer who added the sleep is rarely the one debugging the timeout at 02:00, which is precisely why the standard has to live in review rather than in a wiki nobody reopens.

Interviewers probe this with "how do you keep five hundred tests maintainable?" The answer that lands has structure: AAA for readability, builders for data, strategies for variation, pure helpers for determinism, and review parity so none of it decays. That review discussion is the bridge to Custom Matchers and Assertions, where the assertion line itself gets the same treatment.

Quiz

1. A test logs in, seeds rows, makes two API calls, and asserts on both responses. Which restructure best applies Arrange-Act-Assert?

Question 1 options

2. Forty tests call Order(customer, items, discount, currency, channel, notes) with positional None values. Which pattern removes the problem, and what does it buy beyond readability?

Question 2 options

3. Your shared TestHelpers.format_date calls datetime.now() and several tests fail only on CI agents in another timezone. What is the root fix?

Question 3 options

4. A teammate adds time.sleep(10) after clicking Submit to 'make sure the order saves' and self-merges the tests/** change. Which two review rules does this violate?

Question 4 options

Flashcards

Question

What are the three Arrange-Act-Assert sections, and what is the most common violation in each?

Answer

Arrange establishes preconditions (violated by setup buried mid-test or hidden in another file), Act performs the single behavior under test (violated by two calls where the second masks the first), Assert checks one observable with numbers in the message (violated by assertions interleaved with cleanup and debug prints). Cleanup belongs in fixture teardown, not as a fourth section.

Question

When do you reach for a builder instead of a factory or a constructor with defaults?

Answer

When tests must vary several fields of one object and positional arguments have become unreadable. A builder gives named chainable with* steps, returns a valid object by default, and can enforce invariants in build() (throwing when items are missing or a total is non-positive) so bad arrange data fails immediately with a clear message.

Question

What does the strategy pattern do for a test suite, and what changes when a new implementation arrives?

Answer

It hides an interchangeable behavior (mock gateway, Stripe sandbox, PayPal sandbox) behind a small interface the test depends on, selected by fixture or environment. Adding an implementation means writing one new class — no test files change — because the tests assert behavior, not the concrete collaborator.

Question

Define a pure helper and state why purity reduces flaky tests.

Answer

Pure: same input, same output, no database, browser, clock, or global state. Purity removes two flakiness sources — helpers reading datetime.now() (timezone/time-of-day dependent) and helpers mutating shared state (test-order dependent) — and lets you unit-test the test code itself with the runner alone.

Question

Why treat tests as production code in review, and what does that standard look like concretely?

Answer

Tests gate every release, so a defect in them blocks or fakes quality for everyone. Concretely: CODEOWNERS on tests/** with no self-merge, linting in the same CI job (ruff/eslint), unexplained time.sleep() rejected in favor of state-based waits, and deletions accompanied by a note on the coverage lost.

Revision Notes

Key Takeaways

  • 1. Arrange-Act-Assert localizes failures: a broken arrange means the data, a broken act means the call, a broken assert means the behavior — interleaved code gives none of that
  • 2. One logical behavior per test; two API calls are two tests, and cleanup lives in fixture teardown so a failed assert never strands data
  • 3. Builders make valid-by-default test data with named variation, and build() enforces invariants so impossible objects fail at arrange, not at the API
  • 4. Strategy swaps implementations behind one interface, so a new backend adds a class instead of editing forty tests; template shares a fixed sequence but must not grow deep hierarchies
  • 5. Pure helpers (no I/O, frozen clock, seeded randomness) make failures reproducible and let you unit-test the test code itself — the practical defense against flakiness

Interview Tips

  • • "What does AAA buy you?" — three answers: failures localize to a phase, tests become independently runnable and reorderable, and reviewers judge intent without simulating the flow
  • • "How do you handle test data for 200 tests?" — builder with valid defaults and invariants in build(); each test overrides only its variation; contrast it with telescoping constructors and shared mutating factories
  • • "One test, three payment providers — how?" — strategy behind a small interface, selected by fixture or environment variable; adding a provider means writing one class, not touching tests
  • • "Why is my suite flaky on CI?" — trace impurity: a helper reading the ambient clock or mutating shared rows; fix by freezing time, seeding data, and waiting on state instead of sleeping, then note the review rule that catches it next time

Cheat Sheet

Design Patterns in Test Code

AAA shape: Arrange (preconditions) → Act (one call) → Assert (one observable, numbers in the message). Cleanup goes to fixture teardown.

def test_invoice_total_when_coupon_applies(db, client):
    order = OrderBuilder(db).with_item(sku='B07X', qty=2, price=25.00).build()  # Arrange
    r = client.post('/orders', headers=auth(order.owner))                        # Act
    assert r.json['total'] == 42.00, f"expected 42.00, got {r.json['total']}"     # Assert

Naming: test_<behavior>_when_<condition> — the name reads as a sentence in the failure report.

Pattern picker:

Pattern Question Smell removed
Builder which data does this test vary? telescoping constructors, positional None chains
Strategy how does one test cover many implementations? duplicated tests differing only in the client
Template how do tests share a fixed sequence? setup sprawl across a class — keep hierarchies ≤ 2 levels

Purity rules for helpers:

cents_to_display(1999)      # '$19.99'  — no I/O
time.freeze('2026-01-15')   # clock becomes input
Faker.seed(7)               # data becomes reproducible

Signs a helper belongs in a fixture instead: takes a driver, a connection, or a logger; reads now(); writes shared rows.

Review bar for tests/**: CODEOWNERS + no self-merge · same lint job as src · no unexplained time.sleep · deletions documented · coverage change justified.

Interview line: AAA for readability, builders for data, strategies for variation, pure helpers for determinism, review parity so none of it decays.

Continue Learning