Pliant AllianceSoftware development notes & tools

Test-driven development: a beginner's walkthrough

Test-Driven Development (TDD) is a design habit built around a deliberately short feedback loop. You turn one small requirement into an executable example, run it and watch it fail, add the least code that can make it pass, then improve the design while the test stays green. That cycle repeats instead of saving all testing for the end. The approach cannot promise bug-free software, but it exposes assumptions early, keeps changes reversible and makes regression checks grow alongside the code.

This walkthrough uses Python and pytest to build a small GST function for an Australian online shop. You will work with two files: a test file containing the desired behaviour and a production file containing the calculation. Integer values represent cents, avoiding the unpredictable results that can arise from binary floating-point numbers. The example runs from a terminal on Windows, macOS or Linux and requires only Python, pytest and a folder in which to create the files.

Start with one observable rule

Begin with a narrow business rule: a subtotal of 1000 cents, excluding GST, becomes 1100 cents after ten per cent Australian GST is added. This toy shop assumes every item is standard-rated and the supplied price never includes GST. Real applications may need exemptions, inclusive pricing and different tax treatments, but keeping the first rule simple makes its purpose clear.

Create test_pricing.py before writing the production module:

from pricing import add_gst


def test_adds_ten_percent_gst_to_a_subtotal():
    assert add_gst(10_00) == 11_00

Run the suite with python -m pytest -q. The first result should be red because pricing.py does not exist. A missing module is an acceptable initial failure, although subsequent failures should reveal missing behaviour rather than spelling mistakes or an incorrect test environment. Once the test runner can collect the test and reaches its assertion, you know the feedback loop is working.

Create pricing.py with the smallest implementation that satisfies the example:

def add_gst(amount_cents):
    return amount_cents + amount_cents // 10

Run the test again and it should pass. This implementation is intentionally crude: it calculates ten per cent by integer division and truncates the result. That is acceptable at this stage because the current test specifies only one ordinary amount. Production code written ahead of tests often contains speculative branches, configuration options and abstractions that no requirement demanded.

Build through small red-green cycles

The next rule concerns cents that do not divide evenly by ten. Add cases for zero, a fractional cent result and a subtotal close to ten dollars. Parameterisation keeps these examples compact while giving each input its own test result.

import pytest

from pricing import add_gst


@pytest.mark.parametrize(
    ("subtotal_cents", "expected_cents"),
    [
        (0, 0),
        (10_00, 11_00),
        (105, 116),
        (999, 1099),
    ],
)
def test_rounds_gst_to_nearest_cent(
    subtotal_cents, expected_cents
):
    assert add_gst(subtotal_cents) == expected_cents

The zero and whole-dollar cases still pass, while 105 cents and 999 cents expose the truncation problem. That is a useful red state because the failure comes from real behaviour that the team has decided to specify. Replace the calculation with integer arithmetic that rounds the tax to the nearest cent, using a half-cent upwards:

GST_RATE_PERCENT = 10


def add_gst(amount_cents):
    gst_cents = (
        amount_cents * GST_RATE_PERCENT + 50
    ) // 100
    return amount_cents + gst_cents

All parameterised examples now pass. The next behaviour should reject a negative subtotal rather than returning a negative total that looks superficially plausible. Add the test first:

def test_rejects_a_negative_subtotal():
    with pytest.raises(ValueError):
        add_gst(-1)

The test remains red until a guard clause enforces the contract:

GST_RATE_PERCENT = 10


def add_gst(amount_cents):
    if amount_cents < 0:
        raise ValueError(
            "subtotal_cents must be zero or greater"
        )

    gst_cents = (
        amount_cents * GST_RATE_PERCENT + 50
    ) // 100
    return amount_cents + gst_cents

The suite now describes valid input, rounding and invalid input. That is a more trustworthy contract than a collection of tests added after the function happened to be written.

Keep tests focused and trustworthy

A useful unit test follows three steps: arrange known inputs, perform one operation and assert an observable result. The test name should explain the rule, while the body should remain easy to scan. In this project, test_rounds_gst_to_nearest_cent communicates more than test_pricing_2. Tests should also be deterministic, so avoid dependence on the current time, a random network response or an uncontrolled value such as today’s date. Replace those dependencies with fixed clocks, generated data or explicit parameters.

Keep unit tests fast and local to the code under examination. They are not intended to prove that a database, payment gateway and tax service all work together. Mocking every dependency can also create a false sense of security: a test may merely confirm that the code called a configured mock. Prefer real values for simple calculations and reserve test doubles for slow or destructive boundaries. A fake repository can verify saved orders without polluting a development database, while a narrowly scoped mock can confirm that an event was requested.

Integration behaviour needs its own layer. For Java and enterprise stacks, the Oracle middleware discussion provides useful background on application servers, messaging and integration concerns before deciding where a test boundary belongs. Contract tests can check the data exchanged with a broker or external service, while a small end-to-end test can prove that the assembled checkout process stores an order. Keep these tests separate so a slow integration failure does not hide the immediate feedback provided by the tax calculation.

Test data must also respect Australian privacy obligations. A captured production payload can contain customer names, addresses, phone numbers or payment-related information. Synthetic fixtures should replace real records, and access to exceptional debugging data should be controlled. Teams handling personal information may need to consider the Privacy Act 1988 and the Australian Privacy Principles, although legal and compliance teams should determine the exact obligations that apply.

Refactor without changing behaviour

Refactoring happens after the tests are green. At that point, improve names, remove duplication, extract constants and clarify control flow without changing the agreed output. Type hints can make the integer-cent contract visible:

GST_RATE_PERCENT = 10


def add_gst(amount_cents: int) -> int:
    if amount_cents < 0:
        raise ValueError(
            "subtotal_cents must be zero or greater"
        )

    gst_cents = (
        amount_cents * GST_RATE_PERCENT + 50
    ) // 100
    return amount_cents + gst_cents

Run the suite after each small adjustment. If a refactor turns the tests red, either the behaviour changed accidentally or the existing tests misunderstood the requirement. Both discoveries are valuable, but they need to be resolved before new functionality is added.

Older code requires a gentler entry point. A module from a 2005 or 2006 Windows application may have no automated tests and may combine calculation, formatting and database access. Start with characterisation tests that record current behaviour, including quirks that nobody wants to preserve. Introduce a boundary around one responsibility, write a failing test for the desired change and move through another short cycle. Pair programming, a practice associated with early Agile teams, remains useful because one developer can challenge assumptions while the other keeps the experiment small.

Communication matters when a team is distributed across Sydney, Melbourne and Perth. Perth is usually two hours behind the eastern states, although daylight-saving changes can alter that difference temporarily. A casual message such as “Can you have a look when you get a sec?” fits many Australian workplace conversations, but it does not identify the exact failing test or expected result. Pair the request with the command, error message and definition of done. Sharing a reproducible test is more valuable than relying on tone or proximity.

Carry the habit into team workflows

A local test cycle should finish before the change is pushed, and the same command should run automatically in continuous integration. A practical pipeline installs locked dependencies, runs the fast unit suite and then executes separately tagged integration tests. Linting, type checks and formatting checks can accompany those stages. Running the suite on both Windows and Ubuntu can reveal path, encoding or platform assumptions, while a concise failure report helps the developer reproduce the problem without reconstructing the original workstation.

Security checks belong beside functional tests. An archived Norton Antivirus review provides a historical perspective on endpoint protection, but software from that era should not be treated as a current security guarantee. Development machines and CI runners need supported patches, current endpoint protection, secret scanning and dependency vulnerability checks. Historical applications can be examined in an isolated virtual machine using harmless samples, never by connecting an obsolete system to live customer data.

Measure the suite by the confidence it provides rather than by a coverage percentage alone. A high percentage can include tests that execute code without checking meaningful results, while a smaller suite may protect every important business rule. Mutation testing offers another check by deliberately altering production code and seeing whether a test detects the change. Slow tests, repeated failures and untested production paths often reveal more than a headline metric.

The example grows naturally from a GST rule to edge cases, validation and team automation, just as a larger product grows one observable behaviour at a time. Australian businesses operating online may also need to account for Australian dollars, ten per cent GST and .com.au domains, but the engineering method remains the same. A clear test states the contract, a controlled failure identifies the missing work and a green result creates a safe point for refactoring. That red-green rhythm turns requirements into evidence and makes each subsequent change easier to trust.