Catch the Checkout Bug with pytest

Expose and fix a checkout bug with pytest tests and an ADR.

Introduction

30 Second Summary

A checkout total can look believable while hiding a costly mistake at the exact point a discount begins. Without repeatable checks, that mistake can return after the next code change.

In this project, you will build a checkout calculator that applies a 10 percent discount at 10,000 cents. You will use pytest to expose its boundary bug and turn the fix into a repeatable suite of unit tests plus an integration test.

What You'll Build

Picture running one test command and seeing five checks confirm ordinary totals, every discount boundary, and a saved receipt before your checkout code reaches a customer.

By the end of this project, you'll have:

  • A working checkout calculator that prints a formatted total for a regular order.
  • A five-test safety net that catches the exact-threshold bug and protects the values around it.
  • An accepted Architecture Decision Record that explains why the project uses pytest and records the trade-offs.
  • Secret Mission: Write the tests first. Then make the checkout reject negative prices and quantities.

Are there any prerequisites?

You'll need basic Python coding knowledge plus a Linux computer with terminal access. The project guides you through setting up Visual Studio Code, uv, Python, and pytest from scratch.

Before We Start

A checkout total affects both the customer and the business. Take a moment to commit to building a testing safety net that can catch an overcharge or lost revenue before any hands-on work begins.

Set Up the Testing Workshop

A checkout test suite needs a stable workshop before it can produce trustworthy evidence. This step isolates the project from your operating system's Python packages.

You'll use Visual Studio Code to keep the project files beside the terminal. uv 0.13.6 will manage the development environment.

Python 3.15.2 will run inside a virtual environment. pytest 9.2.0 will provide the testing tools used throughout the project.

In this step, get ready to:
  • Install Visual Studio Code for your Linux distribution.
  • Create an isolated Python environment inside checkout-testing.
  • Install pytest and verify the complete workshop.
Install Visual Studio Code

Visual Studio Code keeps the checkout files within reach while its integrated terminal runs the test commands. First, check whether the editor is already installed.

  • Use your Linux application launcher to search for Visual Studio Code.
  • Select Visual Studio Code if it appears in the results.
  • Choose the tab that matches what happens.

✔️ Visual Studio Code opens

Your editor is installed. The application window confirms it can launch successfully.

  • Keep Visual Studio Code open while you prepare the project folder.

ⓧ Visual Studio Code is not installed

Visual Studio Code provides official Debian, RPM, and Snap packages. Choose the package path that matches your Linux distribution.

Debian or Ubuntu

cd ~/Downloads
sudo apt install ./<file>.deb
What do these commands do?
  • The first command moves the terminal into your Downloads folder.
  • The second command asks the Debian package manager to install the downloaded Visual Studio Code package.
  • Use your Linux application launcher to search for Visual Studio Code.
  • Select Visual Studio Code to confirm the installation.
Package installation failed?

Confirm that the filename ends in .deb. Check that you replaced <file> with the complete downloaded filename.

Still stuck? Help me troubleshoot the Visual Studio Code Debian package installation.

Fedora or RHEL

cd ~/Downloads
sudo dnf install <file>.rpm
What do these commands do?
  • The first command moves the terminal into your Downloads folder.
  • The second command asks the Fedora package manager to install the downloaded Visual Studio Code package.
  • Use your Linux application launcher to search for Visual Studio Code.
  • Select Visual Studio Code to confirm the installation.
RPM installation failed?

Confirm that the filename ends in .rpm. Check that you replaced <file> with the complete downloaded filename.

Still stuck? Help me troubleshoot the Visual Studio Code RPM installation.

Snap is available

  • Install the official Visual Studio Code Snap package by running this command:
sudo snap install --classic code
What does this command do?

Snap downloads the Visual Studio Code package. The --classic option grants the editor the file access needed for local development.

  • Use your Linux application launcher to search for Visual Studio Code.
  • Select Visual Studio Code to confirm the installation.
Snap command unavailable?

This installation path requires Snap to exist on your Linux distribution. Use the Debian or RPM tab when your terminal cannot find the Snap command.

Need help choosing a package? Help me match my Linux distribution to a Visual Studio Code package.

Your editor now opens successfully. That confirms the first part of the workshop is ready.

Install uv and create the environment

The uv installer uses cURL or Wget to download its installation script. A quick availability check tells you which installer path to use.

  • Check whether cURL or Wget is available by running these commands:
command -v curl
command -v wget

What do these commands do?

Each command prints the path to the matching download tool without running it. One printed path is enough to continue.

No path printed?

Use your Linux distribution's software manager to install cURL. Rerun the availability check after the installation finishes.

Need help with your distribution? Help me install cURL on my Linux device.

  • Check the installed uv version by running this command:
uv --version

What does this command do?

This command reports the installed uv release. The result determines whether you can continue or need the pinned installer.

✔️ I see uv 0.13.6

The required uv release is already available. Continue below to create the isolated project environment.

ⓧ I see an older uv version

Install the pinned uv release with the download tool that produced a path.

cURL is available

  • Install uv 0.13.6 by running the pinned cURL installer:
curl --proto '=https' --tlsv1.2 -LsSf https://releases.astral.sh/github/uv/releases/download/0.13.6/uv-installer.sh | sh
What does this command do?

cURL downloads the installer for uv 0.13.6. The shell runs that installer for your Linux account.

  • Close the terminal after the installer finishes.
  • Open a new terminal to refresh its command path.

Wget is available

  • Install uv 0.13.6 by running the pinned Wget installer:
wget -qO- https://astral.sh/uv/0.13.6/install.sh | sh
What does this command do?

Wget downloads the installer for uv 0.13.6. The shell runs that installer for your Linux account.

  • Close the terminal after the installer finishes.
  • Open a new terminal to refresh its command path.

ⓧ The uv command is not found

Install uv with the download tool that produced a path.

cURL is available

  • Install uv 0.13.6 by running the pinned cURL installer:
curl --proto '=https' --tlsv1.2 -LsSf https://releases.astral.sh/github/uv/releases/download/0.13.6/uv-installer.sh | sh
What does this command do?

cURL downloads the installer for uv 0.13.6. The shell runs that installer for your Linux account.

  • Close the terminal after the installer finishes.
  • Open a new terminal to refresh its command path.

Wget is available

  • Install uv 0.13.6 by running the pinned Wget installer:
wget -qO- https://astral.sh/uv/0.13.6/install.sh | sh
What does this command do?

Wget downloads the installer for uv 0.13.6. The shell runs that installer for your Linux account.

  • Close the terminal after the installer finishes.
  • Open a new terminal to refresh its command path.

Before you check again, which uv version do you expect the refreshed terminal to report?

  • Verify the uv installation by running this command:
uv --version

What should I see?

The command reads the uv executable available to your refreshed shell. You should see uv 0.13.6 in the output.

  • Move to your Desktop by running this command:
cd ~/Desktop

What does this command do?

This command places the terminal on your Desktop. The project folder created next will be easy to find.

The Python download can take a few minutes on the first run. A quiet pause during the download is expected.

  • Create the project folder and activate its isolated environment by running these commands:
mkdir checkout-testing
cd checkout-testing
uv python install
uv venv
source .venv/bin/activate

What do these commands do?

  • The first two commands create checkout-testing on your Desktop. They move the terminal inside it.
  • The uv python install command installs the latest available Python release.
  • The uv venv command creates the environment at .venv.
  • The final command activates that environment for the current terminal.

Before you check Python, predict which version the activated environment should report.

  • Check the Python version inside the activated environment by running this command:
python --version

What does this command do?

This command reports the Python interpreter selected by the active .venv environment.

✔️ I see Python 3.15.2

The environment now has the required Python release. Your operating system's Python packages remain separate.

ⓧ I see an older Python version

  • Recreate the virtual environment with Python 3.15.2 by running these commands:
uv venv --python 3.15.2
source .venv/bin/activate
python --version

What do these commands do?

uv requests Python 3.15.2 for .venv. The final command confirms the interpreter selected by the refreshed environment.

ⓧ The Python command is not found

  • Create the virtual environment with Python 3.15.2 by running these commands:
uv venv --python 3.15.2
source .venv/bin/activate
python --version

What do these commands do?

uv downloads Python 3.15.2 when necessary. It creates .venv before activating its Python command.

Environment not activated?

Confirm that your terminal is inside the checkout-testing folder. Run the activation command again if your prompt does not identify .venv.

Need help? Help me activate the uv virtual environment.

Install pytest and verify the workshop

The virtual environment is the boundary around this project's dependencies. Installing pytest here gives the checkout project its own testing version.

  • Install pytest 9.2.0 inside the active environment by running this command:
uv pip install pytest==9.2.0

What does this command do?

uv finds the active .venv environment. It installs the exact pytest release required by this project.

pytest installation failed?

Check that the terminal is inside checkout-testing. Confirm that .venv is active before repeating the installation.

Still stuck? Help me troubleshoot the pytest installation in my uv environment.

  • Open the current checkout-testing folder in Visual Studio Code by running this command:
code .

What does this command do?

The code . command launches Visual Studio Code with the current folder as its workspace.

The code command is unavailable?

Return to Visual Studio Code. Use File followed by Open Folder to select the checkout-testing folder on your Desktop.

Need help opening the folder? Help me open checkout-testing in Visual Studio Code.

Before the final check, predict whether the integrated terminal will find the same Python and pytest versions.

  • Select Terminal from the Visual Studio Code menu bar.
  • Select New Terminal.
  • Verify both installed versions in the integrated terminal by running these commands:
python --version
pytest --version

What should I see?

The first line should report Python 3.15.2. The second line should report pytest 9.2.0.

✔️ I see pytest 9.2.0

That's the workshop ready. Visual Studio Code now has the project folder plus an integrated terminal connected to the isolated testing environment.

ⓧ I see an older pytest version

  • Install the required pytest release in the integrated terminal by running these commands:
uv pip install pytest==9.2.0
pytest --version

What do these commands do?

The first command installs pytest 9.2.0 in the discovered virtual environment. The second command confirms the replacement.

ⓧ The pytest command is not found

  • Activate the project environment before installing pytest by running these commands:
source .venv/bin/activate
uv pip install pytest==9.2.0
pytest --version

What do these commands do?

The first command connects the integrated terminal to .venv. The remaining commands install pytest 9.2.0 before confirming it.

Your testing workshop is ready. Next, you'll run the checkout calculator and inspect its first visible total.

Run the Checkout Before You Trust It

Your testing workshop is ready inside Visual Studio Code. The activated .venv gives this project an isolated place to run Python.

A checkout total can look believable while a business rule is wrong. This step gives you one visible result that later tests can challenge.

In this step, get ready to:
  • Build a checkout calculator that stores money as integer cents.
  • Run a regular order through the calculator.
  • Separate observed output from proven behaviour.
Build the checkout calculator

Integer cents make the subtotal easy to inspect. The calculator applies a 10 percent discount according to a threshold comparison.

  • Select the new-file control in the file sidebar.
  • Name the file checkout.py.
  • Add the checkout calculation to checkout.py by pasting this code:
DISCOUNT_THRESHOLD_CENTS = 10_000
DISCOUNT_PERCENT = 10


def calculate_total(items):
    subtotal = sum(
        unit_price_cents * quantity
        for unit_price_cents, quantity in items
    )

    if subtotal > DISCOUNT_THRESHOLD_CENTS:
        discount = subtotal * DISCOUNT_PERCENT // 100
        return subtotal - discount

    return subtotal

What does this code do?

  • The DISCOUNT_THRESHOLD_CENTS constant stores the order value used by the discount rule.
  • The DISCOUNT_PERCENT constant stores the discount percentage.
  • The calculate_total(items) function receives the order lines.
  • The sum(...) expression calculates the subtotal from each unit price multiplied by its quantity.
  • The conditional returns a discounted total when its comparison succeeds.
  • Save checkout.py.
  • Confirm checkout.py appears inside the checkout-testing folder in the file sidebar.

Can't find checkout.py?

Check that you created the file inside the open checkout-testing folder. A file created outside that folder does not appear in the project tree.

Still stuck? Help me find the checkout.py file in my VS Code project.

✔️ Awesome, I've got everything!

Your calculator function now matches the starting version for this project.

ⓧ I'd like to double check the full code

DISCOUNT_THRESHOLD_CENTS = 10_000
DISCOUNT_PERCENT = 10


def calculate_total(items):
    subtotal = sum(
        unit_price_cents * quantity
        for unit_price_cents, quantity in items
    )

    if subtotal > DISCOUNT_THRESHOLD_CENTS:
        discount = subtotal * DISCOUNT_PERCENT // 100
        return subtotal - discount

    return subtotal

What should the full file contain?

This reference contains the two discount constants plus the complete calculate_total(items) function. Its spacing also matches the project artifact.

Create the visible checkout demo

A demo sends one regular order through the calculator. Its formatted output gives you a concrete result to inspect.

  • Select the new-file control in the file sidebar.
  • Name the file demo.py.
  • Add the regular checkout example to demo.py by pasting this code:
from checkout import calculate_total

items = [(2_500, 2), (1_500, 1)]
total_cents = calculate_total(items)

print(f"Checkout total: ${total_cents / 100:.2f}")

What does this code do?

  • The import makes calculate_total available to the demo.
  • The items list represents two units priced at 2,500 cents plus one unit priced at 1,500 cents.
  • The total_cents variable holds the calculated result.
  • The final line converts the integer-cent result into a dollar-formatted message.
  • Save demo.py.
  • Confirm demo.py appears beside checkout.py in the file sidebar.

Don't see both files?

Confirm the file names are exactly checkout.py and demo.py. A different extension prevents Python from treating the file as the intended module.

Need a hand? Help me check why checkout.py or demo.py is missing from my project.

✔️ Awesome, I've got everything!

Your demo now supplies one regular order to the checkout calculator.

ⓧ I'd like to double check the full code

from checkout import calculate_total

items = [(2_500, 2), (1_500, 1)]
total_cents = calculate_total(items)

print(f"Checkout total: ${total_cents / 100:.2f}")

What should the full file contain?

This reference contains the calculator import plus the regular-order example. It finishes with the formatted checkout message.

Decide what the output proves

What is the smallest useful testing target?

A unit test is a fast check of one small behaviour. Here the smallest useful target is calculate_total(items) with a known order.

The system under test is the code currently being checked. In this project the checkout calculation becomes that system.

What can one demo prove?

The demo shows what happens for one ordinary order. Discount boundaries remain unchecked.

Automated tests turn more expectations into repeatable evidence. That evidence becomes useful when a rule changes or a hidden case behaves differently.

Test behaviour that matters

Start with the smallest business rule that would hurt if it were wrong. A focused test gives you a clear signal when that behaviour breaks.

Professional test suites focus on observable behaviour. Private implementation details can change while the promised result stays protected.

Before you run the final check, what formatted total do you expect from this regular order?

  • Run the checkout demo from the activated terminal by using this command:
python demo.py

What should I see?

The terminal prints Checkout total: $65.00.

Good work. Your checkout now produces a visible result that you can challenge with tests.

Don't see the checkout total?

Confirm the terminal is inside the checkout-testing folder. Python needs both files in the current project folder for the import to work.

Check that the saved function name is calculate_total in both files. A spelling mismatch prevents the import or function call from succeeding.

Still blocked? Help me troubleshoot why python demo.py does not print the expected checkout total.

Your calculator now produces a believable total for an ordinary order. Next, you will turn that result into a testable expectation that can expose the hidden boundary mistake.

Turn Expectations into a Failing Test

Your checkout calculator now prints a formatted total for a regular order. You have a visible result to challenge with automated evidence.

A plausible total still leaves the discount boundary unproven. In this step, you use pytest to check an ordinary order before testing the exact point where the discount rule changes.

In this step, get ready to:
  • Protect the regular-order calculation with a focused unit test.
  • Challenge the exact discount threshold with a boundary test.
  • Read a controlled failure as evidence about the checkout rule.
Write a focused passing unit test

A unit test checks one small behaviour with a known input. The system under test is the code being checked, which is calculate_total() here.

  • In the Visual Studio Code file sidebar, create a folder named tests inside checkout-testing.
  • Inside tests, create test_checkout.py with the file creation control.
  • Add the import plus the regular-order test to tests/test_checkout.py by copying this code:
from checkout import calculate_total


def test_regular_order_returns_full_total():
    # Arrange
    items = [(2_500, 2), (1_500, 1)]

    # Act
    total_cents = calculate_total(items)

    # Assert
    assert total_cents == 6_500

What does this test do?

  • The import makes calculate_total() available to the test file.
  • Arrange creates the regular order used in the earlier checkout demo.
  • Act calls the system under test with that order.
  • The assertion compares the observed total with the expected total of 6_500 cents.
  • Save tests/test_checkout.py.
  • Check the regular-order behaviour by running this command in the activated terminal:
pytest

What should you see?

You should see one passing test. That result proves the regular-order expectation can now be checked whenever the suite runs.

Good progress. Your first automated check now protects the ordinary checkout total.

Is the test missing or failing?

Confirm that test_checkout.py sits inside the tests folder under checkout-testing.

Check that the terminal is still inside checkout-testing before running the suite.

Still stuck? Help me diagnose why pytest cannot find or pass my regular-order test.

Challenge the exact discount boundary

A boundary case checks the point where behaviour changes. An order worth exactly 10_000 cents should receive the 10 percent discount.

  • Return to tests/test_checkout.py from the previous check.
  • Move your cursor to the end of the file.
  • Add the exact-threshold scenario by copying this code:
def test_order_at_discount_threshold_gets_discount():
    items = [(10_000, 1)]

    assert calculate_total(items) == 9_000

What does this boundary test check?

  • The test creates one item worth exactly 10_000 cents.
  • The expected result is 9_000 cents after the discount.
  • The descriptive test name records the scenario plus its expected outcome.
  • Save tests/test_checkout.py.

Before you run the suite, ask yourself whether both checkout scenarios will pass.

  • Test both scenarios by running this command in the activated terminal:
pytest

What does the failure prove?

You should see the regular-order test pass. You should see the exact-threshold test fail.

pytest reports an actual total of 10000 against an expected total of 9000. The failing test has exposed the hidden boundary mistake.

Did you get a different result?

If both tests pass, confirm that checkout.py still uses subtotal > DISCOUNT_THRESHOLD_CENTS.

If both tests fail, compare the regular-order items with the values in the first test.

Need help reading the result? Help me understand why my checkout boundary test produced a different outcome.

Your test file should now contain one ordinary-order scenario plus one exact-threshold scenario.

✔️ Awesome, I've got everything!

  • Keep tests/test_checkout.py saved with both tests.

ⓧ I'd like to double check the full code

from checkout import calculate_total


def test_regular_order_returns_full_total():
    # Arrange
    items = [(2_500, 2), (1_500, 1)]

    # Act
    total_cents = calculate_total(items)

    # Assert
    assert total_cents == 6_500


def test_order_at_discount_threshold_gets_discount():
    items = [(10_000, 1)]

    assert calculate_total(items) == 9_000

What should match?

Compare the import plus both test functions with your saved file. Keep the test names plus expected totals exactly aligned with this reference.

Read the evidence and lock in the habit

The failing test is valuable because it fails for the expected reason. It proves the suite can detect the checkout rule that needs attention.

Name the behaviour clearly

A name such as test_order_at_discount_threshold_gets_discount() identifies the scenario plus the expected outcome. A future failure points directly to the broken behaviour.

Professionals keep each unit test focused on one observable result. Narrow tests make failures faster to understand.

Before one final run, predict which test should pass and which test should fail.

  • Confirm the controlled result by running the full suite again:
pytest

Your checkpoint

You should still see the regular-order test pass. You should still see the exact-threshold test fail with an actual total of 10000 plus an expected total of 9000.

Leave checkout.py unchanged for now. The controlled failure is the evidence this step set out to create.

Has the controlled failure changed?

Confirm that both files are saved before rerunning the suite.

Compare tests/test_checkout.py with the full-code reference if pytest reports a different pair of outcomes.

Still seeing an unexpected result? Help me restore the intended passing and failing pytest results.

You now have evidence that catches the checkout bug on demand. Next, you will make the smallest production change and protect the values around the threshold.

Fix the Bug and Cover the Boundary

The failing exact-threshold test has made the hidden checkout bug visible. You now have evidence that points to the comparison responsible for the wrong total.

This step follows Red-Green-Refactor to make the smallest useful fix. You will then use pytest parameterization to protect the full edge of the discount rule.

In this step, get ready to:
  • Correct the discount comparison with the smallest useful change.
  • Replace the single threshold test with parameterized boundary cases.
  • Run the full suite to confirm four passing cases.
Fix the threshold comparison

The failure points to one comparison in checkout.py. Including equality makes an order worth exactly 10,000 cents eligible for the discount.

  • Switch back to checkout.py in Visual Studio Code.
  • Find the line if subtotal > DISCOUNT_THRESHOLD_CENTS: inside calculate_total().
  • Replace that line with the corrected comparison below:
    if subtotal >= DISCOUNT_THRESHOLD_CENTS:

Why Does This Fix the Bug?

The >= comparison includes equality. The discount now applies when subtotal reaches DISCOUNT_THRESHOLD_CENTS.

The calculation below the comparison remains unchanged. This keeps the fix focused on the failing behaviour.

  • Save checkout.py.

Before you rerun the suite, predict whether the exact-threshold test stays red or turns green.

  • Test the corrected comparison by running this command in the activated terminal:
pytest

What Does This Command Check?

The command discovers both existing tests. The regular-order test checks that an ordinary subtotal remains unchanged.

The exact-threshold test now passes. This proves that the corrected comparison includes the discount boundary.

Good progress. Both tests now pass because the focused comparison change repaired the exact-threshold behaviour.

Is the Threshold Test Still Failing?

  • Confirm that the comparison in checkout.py uses >=.
  • Save checkout.py before running the suite again.

Need another pair of eyes? Help me inspect the corrected threshold comparison.

How Red-Green-Refactor Works

  • Red was the threshold test failing for the expected reason.
  • Green is the corrected comparison making both tests pass.
  • Refactor improves the test structure without changing checkout behaviour.
Protect all three boundary values

A boundary case checks a value where behaviour changes. Covering the values around that point reveals comparison mistakes such as an incorrect greater-than operator.

Test Around the Edge

The value below the threshold protects the no-discount side. The exact threshold protects the rule's turning point.

The value above the threshold protects the discounted side. Narrow assertions keep each failure tied to the checkout total.

  • Switch back to tests/test_checkout.py in Visual Studio Code.
  • Find the existing from checkout import calculate_total import.
  • Replace that import with the import group below:
import pytest

from checkout import calculate_total

What Do These Imports Provide?

The pytest import makes the parameterization decorator available. The existing checkout import keeps calculate_total available to both test functions.

  • Save tests/test_checkout.py.
  • Check that the new import has not disrupted the existing tests by running:
pytest

What Does This Check Prove?

Both existing tests still pass. This confirms that adding the import preserved the current checkout coverage.

Does Test Collection Stop?

  • Check that import pytest appears on the first line.
  • Check that from checkout import calculate_total remains below it.

Still stuck? Help me diagnose the import problem.

  • Find the test_order_at_discount_threshold_gets_discount() function.
  • Replace that function with the parameterized test below:
@pytest.mark.parametrize(
    "items, expected_total_cents",
    [
        ([(9_999, 1)], 9_999),
        ([(10_000, 1)], 9_000),
        ([(10_001, 1)], 9_001),
    ],
)
def test_discount_boundary_returns_expected_total(items, expected_total_cents):
    assert calculate_total(items) == expected_total_cents

What Does This Code Do?

  • The @pytest.mark.parametrize decorator supplies several inputs to one test function.
  • The first row checks a subtotal one cent below the threshold.
  • The second row checks the exact threshold.
  • The third row checks a subtotal one cent above the threshold.
  • The expected_total_cents parameter gives each input its own expected result.
  • Save tests/test_checkout.py.

✔️ Awesome, I've got everything!

Your comparison fix and parameterized boundary test are ready for the final run.

ⓧ I'd like to double check the full code

DISCOUNT_THRESHOLD_CENTS = 10_000
DISCOUNT_PERCENT = 10


def calculate_total(items):
    subtotal = sum(
        unit_price_cents * quantity
        for unit_price_cents, quantity in items
    )

    if subtotal >= DISCOUNT_THRESHOLD_CENTS:
        discount = subtotal * DISCOUNT_PERCENT // 100
        return subtotal - discount

    return subtotal
from checkout import calculate_total

items = [(2_500, 2), (1_500, 1)]
total_cents = calculate_total(items)

print(f"Checkout total: ${total_cents / 100:.2f}")
import pytest

from checkout import calculate_total


def test_regular_order_returns_full_total():
    # Arrange
    items = [(2_500, 2), (1_500, 1)]

    # Act
    total_cents = calculate_total(items)

    # Assert
    assert total_cents == 6_500


@pytest.mark.parametrize(
    "items, expected_total_cents",
    [
        ([(9_999, 1)], 9_999),
        ([(10_000, 1)], 9_000),
        ([(10_001, 1)], 9_001),
    ],
)
def test_discount_boundary_returns_expected_total(items, expected_total_cents):
    assert calculate_total(items) == expected_total_cents
Verify the boundary coverage

The suite now has one regular-order test. The parameterized function contributes three separate boundary cases.

Before you run the full suite, predict how many passing cases pytest reports.

  • Verify the complete checkout suite by running this command in the activated terminal:
pytest

What Does the Final Run Check?

Pytest runs the regular-order test once. It runs the parameterized boundary test once for each input row.

The suite checks the values below the threshold. It also checks the values at and above the threshold.

Pytest reports 4 passed. The result covers one regular order plus three discount-boundary cases.

That is the checkout safety net working. The discount rule is now protected on every side of its threshold.

Not Seeing Four Passing Cases?

  • Confirm that tests/test_checkout.py imports pytest.
  • Confirm that the parameterized list contains three input rows.
  • Confirm that checkout.py uses the >= comparison.

Need help tracing the mismatch? Help me diagnose the failing boundary suite.

Your checkout calculator now has a tested discount boundary. Next, you will protect receipt files with an integration test and record why the project uses pytest.

Test the Receipt and Record the Decision

Your four passing tests now protect ordinary totals plus every side of the discount boundary. The checkout rule has a repeatable safety net.

A unit test does not prove that formatted receipt text reaches a file. Future teammates also need a record of why pytest was chosen for this project.

In this step, get ready to:
  • Cover receipt writing with an isolated integration test.
  • Record the testing-tool choice in an accepted decision record.
  • Prove that all five automated tests pass.
Add isolated receipt coverage

An integration test checks whether separate parts work together. This test connects receipt formatting to file-system behavior.

What is a fixture?

A fixture supplies reliable setup data or an isolated environment to a test. The tmp_path fixture gives each test its own temporary directory.

  • Switch back to checkout.py from earlier.
  • Place the cursor above DISCOUNT_THRESHOLD_CENTS = 10_000.
  • Add the path import by pasting this line:
from pathlib import Path

What does this import do?

The Path class represents the destination for a receipt. It provides the text-writing method used by the new function.

  • Move to the bottom of checkout.py.
  • Add the receipt-writing function by pasting this code below return subtotal:
def save_receipt(path: Path, total_cents: int) -> None:
    path.write_text(
        f"Total: ${total_cents / 100:.2f}\n",
        encoding="utf-8",
    )

What does this code do?

  • The path parameter identifies the receipt file.
  • The formatted string converts integer cents into a dollar total with two decimal places.
  • The write_text call saves the receipt with UTF-8 encoding.
  • Save checkout.py.

Before you run the demo, predict whether the existing checkout total stays unchanged.

  • Confirm that the calculator still runs by using this command:
python demo.py

What should I see?

You should see Checkout total: $65.00. This confirms that adding receipt behavior preserved the existing calculation.

Is the demo failing?

Check that from pathlib import Path is the first line of checkout.py.

Check that save_receipt starts after the final return subtotal line.

Still stuck? Help me diagnose the error after adding save_receipt to checkout.py.

✔️ Awesome, I've got everything!

Great. Save checkout.py before you continue.

ⓧ I'd like to double check the full code

from pathlib import Path

DISCOUNT_THRESHOLD_CENTS = 10_000
DISCOUNT_PERCENT = 10


def calculate_total(items):
    subtotal = sum(
        unit_price_cents * quantity
        for unit_price_cents, quantity in items
    )

    if subtotal >= DISCOUNT_THRESHOLD_CENTS:
        discount = subtotal * DISCOUNT_PERCENT // 100
        return subtotal - discount

    return subtotal


def save_receipt(path: Path, total_cents: int) -> None:
    path.write_text(
        f"Total: ${total_cents / 100:.2f}\n",
        encoding="utf-8",
    )

The production behavior now exists. The next test proves that it writes the exact receipt text to an isolated file.

  • Switch back to tests/test_checkout.py from earlier.
  • Find this import line:
from checkout import calculate_total

Why change this import?

The test file currently imports only calculate_total. The receipt test also needs direct access to save_receipt.

  • Replace the existing import with this line:
from checkout import calculate_total, save_receipt

What changed?

The test module now imports both public behaviors from checkout.py. Existing calculation tests continue to use the same import statement.

  • Move to the bottom of tests/test_checkout.py.
  • Add the receipt integration test by pasting this code:
def test_save_receipt_writes_formatted_total(tmp_path):
    receipt_path = tmp_path / "receipt.txt"

    save_receipt(receipt_path, 4_500)

    assert receipt_path.read_text(encoding="utf-8") == "Total: $45.00\n"

What does this test prove?

  • The tmp_path fixture supplies a temporary directory unique to this test.
  • The receipt_path value points to an isolated receipt.txt file.
  • The assertion reads the file back with UTF-8 encoding.
  • The expected string checks the user-visible receipt format.
  • Save tests/test_checkout.py.

Before you run the suite, predict how many tests should pass after adding one receipt test.

  • Run the complete test suite with this command:
pytest

What should I see?

You should see 5 passed. The fifth result proves that receipt formatting reaches an isolated file.

Is the receipt test failing?

Check that the import includes save_receipt.

Compare the expected text carefully. The dollar sign plus the final newline must match the text written by checkout.py.

Need help? Help me understand why my receipt integration test is failing.

✔️ Awesome, I've got everything!

Your receipt behavior now has isolated integration coverage.

ⓧ I'd like to double check the full code

import pytest

from checkout import calculate_total, save_receipt


def test_regular_order_returns_full_total():
    # Arrange
    items = [(2_500, 2), (1_500, 1)]

    # Act
    total_cents = calculate_total(items)

    # Assert
    assert total_cents == 6_500


@pytest.mark.parametrize(
    "items, expected_total_cents",
    [
        ([(9_999, 1)], 9_999),
        ([(10_000, 1)], 9_000),
        ([(10_001, 1)], 9_001),
    ],
)
def test_discount_boundary_returns_expected_total(items, expected_total_cents):
    assert calculate_total(items) == expected_total_cents


def test_save_receipt_writes_formatted_total(tmp_path):
    receipt_path = tmp_path / "receipt.txt"

    save_receipt(receipt_path, 4_500)

    assert receipt_path.read_text(encoding="utf-8") == "Total: $45.00\n"

That closes the file-system gap. Your checkout suite now verifies calculation rules plus a saved receipt.

Write the Architecture Decision Record

An Architecture Decision Record captures the context behind an important technical choice. It preserves the decision plus its consequences for future teammates.

Why pytest for this project?

Python's built-in unittest framework uses TestCase plus assertion methods. It remains a valid testing option.

This project uses pytest 9.2.0 because its plain assertions keep the tests readable. Its parameterization plus the tmp_path fixture support the exact techniques used in this suite.

  • Create a docs folder inside checkout-testing by using the new-folder control in the Visual Studio Code Explorer sidebar.
  • Create an adr folder inside docs by using the same control.

You should now see docs/adr nested beneath the project files in the Explorer sidebar.

  • Create 0001-use-pytest.md inside docs/adr by using the new-file control.
  • Add the ADR title plus its decision context by pasting this first section:
# Use pytest for checkout tests

Status: Accepted
Date: 2026-10-28

## Context

The checkout project needs readable tests for ordinary totals, discount boundaries, and receipt files. The learner should be able to run the suite with one command and understand failures without a large amount of testing boilerplate.

## Considered Options

- Python unittest
- pytest 9.2.0

## Decision

We use pytest 9.2.0 for the checkout test suite.

What does this section record?

  • The status records that the project has accepted the decision.
  • The context names the testing needs behind the choice.
  • The considered options show that unittest was evaluated.
  • The decision states the selected tool in direct language.
  • Save docs/adr/0001-use-pytest.md.

You should see the Context, Considered Options, plus Decision sections in the editor.

Is the ADR file in the wrong place?

Use the Explorer sidebar to confirm that 0001-use-pytest.md sits inside checkout-testing/docs/adr.

Need help? Help me place my ADR in the correct project folder.

  • Place the cursor after the sentence that selects pytest for the checkout suite.
  • Press Enter twice to leave one blank line.
  • Add the rationale plus consequences by pasting this remaining section:
It supports plain assertions, parameterized boundary cases, and the isolated `tmp_path` fixture used by the receipt test.

## Consequences

- Good: Tests stay short and readable.
- Good: One test function can cover several boundary values.
- Good: File tests receive an isolated temporary directory.
- Bad: pytest is an external development dependency that must be installed.

## Confirmation

Run `pytest`. The decision is implemented when the checkout suite is discovered and all five tests pass.

What does the completed ADR explain?

The rationale connects the decision to plain assertions, parameterized boundary cases, plus isolated temporary files.

The consequences capture benefits plus one trade-off. The confirmation section gives the team a concrete way to verify that the decision is implemented.

  • Save docs/adr/0001-use-pytest.md.

You should now see an Accepted ADR with Context, Considered Options, Decision, Consequences, plus Confirmation sections.

Is an ADR section missing?

Compare each heading with the full-file reference below. Check the spacing around each heading if the Markdown layout looks crowded.

Still stuck? Help me compare my pytest ADR with the required structure.

✔️ Awesome, I've got everything!

Your accepted ADR now records the testing decision plus its trade-offs.

ⓧ I'd like to double check the full code

# Use pytest for checkout tests

Status: Accepted
Date: 2026-10-28

## Context

The checkout project needs readable tests for ordinary totals, discount boundaries, and receipt files. The learner should be able to run the suite with one command and understand failures without a large amount of testing boilerplate.

## Considered Options

- Python unittest
- pytest 9.2.0

## Decision

We use pytest 9.2.0 for the checkout test suite.

It supports plain assertions, parameterized boundary cases, and the isolated `tmp_path` fixture used by the receipt test.

## Consequences

- Good: Tests stay short and readable.
- Good: One test function can cover several boundary values.
- Good: File tests receive an isolated temporary directory.
- Bad: pytest is an external development dependency that must be installed.

## Confirmation

Run `pytest`. The decision is implemented when the checkout suite is discovered and all five tests pass.
Check your testing judgment

The final knowledge check covers integration testing, isolated temporary data, plus the purpose of an ADR.

Keep evidence focused

Keep each integration test focused on one user-visible result. This receipt test proves that the formatted total reaches a file.

Professionals keep a decision log for important choices. A later change should receive a new ADR that replaces the accepted record.

Before you run the full suite, predict whether the receipt change preserved every earlier checkout behavior.

  • Run the final test suite with this command:
pytest

What should I see?

You should see 5 passed. This confirms that the regular order, three discount boundaries, plus saved receipt behavior all pass together.

  • Return to the docs/adr/0001-use-pytest.md tab from earlier.
  • Confirm that its status reads Accepted.
  • Confirm that all five ADR sections appear beneath the title.

You should see Context, Considered Options, Decision, Consequences, plus Confirmation. Together, the passing suite plus accepted ADR provide technical evidence and decision context.

Missing a pass or an ADR section?

For a failing test, compare checkout.py plus tests/test_checkout.py with their full-file references.

For a missing ADR section, compare docs/adr/0001-use-pytest.md with its full-file reference.

Need another pair of eyes? Help me verify my final checkout test suite and ADR.

You have closed the final testing gap. Your checkout project now protects calculation rules, verifies receipt output, plus explains its testing decision.

Secret mission

Reject Impossible Orders with TDD

Extend the checkout safety net to reject negative prices and quantities. Follow the test-driven development cycle from missing behaviour to seven passing tests.

Clean Up Your Resources

Clean Up Your Resources

Everything in this project runs locally without ongoing costs. Choose whether to keep the project ready, pause your environment, or delete the folder entirely.

Resources you used:

  • Local checkout-testing folder containing the .venv environment, checkout.py, demo.py, tests/test_checkout.py, and docs/adr/0001-use-pytest.md.

Keep everything running

No action needed. Choose this if you want to keep improving the checkout rules or adding tests.

  • Keep the checkout-testing folder so your calculator, tests, virtual environment, and ADR remain available.
  • Rerun your pytest suite whenever you change the checkout behaviour.
  • Keep Visual Studio Code and uv installed for later Python projects.

Pause - I'll come back to this later

Shut down the active environment to leave your terminal in its normal state. Your local files remain ready for another session.

  • Deactivate .venv by running this command:
deactivate

What does this command do?

The deactivate command leaves the current virtual environment.

Your installed packages remain inside .venv for your next session.

  • Close Visual Studio Code.

Your testing workshop is now paused. The calculator, seven passing tests, and accepted ADR remain together in checkout-testing.

When you return, reopen the same project folder before reactivating its environment.

  • Use your Linux application launcher to reopen Visual Studio Code.
  • Click File in the top menu.
  • Click Open Folder.
  • Select the checkout-testing folder.
  • Reactivate .venv from a terminal inside checkout-testing by running this command:
source .venv/bin/activate

What does this command do?

This command activates the saved virtual environment. Your terminal can use the installed testing tools again.

  • Confirm that your terminal prompt includes .venv.

Delete - I don't want to use this again

Remove all local project resources to start fresh. This option deletes the calculator, tests, virtual environment, and ADR together.

Deletion is permanent

The cleanup command removes the checkout-testing folder plus every file inside it. Recovery depends on having a separate copy.

  • Review checkout.py, demo.py, tests/test_checkout.py, and docs/adr/0001-use-pytest.md in the open Visual Studio Code window.
  • Copy any files you want to keep to another folder.
  • Deactivate .venv by running this command:
deactivate

Why deactivate first?

The deactivate command disconnects the terminal from the environment you are about to remove.

  • Remove only the project folder plus verify the result by running these commands:
cd ..
rm -rf checkout-testing
ls

What do these commands do?

  • The cd .. command moves the terminal to the folder above checkout-testing.
  • The rm -rf checkout-testing command permanently removes the project folder.
  • The ls command lists the remaining folders. You should no longer see checkout-testing.
  • Close Visual Studio Code.

That's the cleanup complete. Your local checkout project is gone without leaving any cloud resources or ongoing charges.

Nice Work!

Nice Work!

You did it. Your checkout calculator now has seven passing pytest tests that turn its business rules into repeatable evidence.

You've learned how to:

  • Turn checkout expectations into focused unit tests. Use descriptive test names plus Arrange-Act-Assert structure to make each scenario readable.
  • Expose the exact-threshold bug with a planned failing test. Protect the discount rule with parameterized boundary cases below the boundary. Add cases at the boundary plus above it.
  • Verify receipt output with an isolated integration test using the tmp_path fixture. Record the testing choice plus its trade-offs in an Accepted Architecture Decision Record.
  • Secret Mission: Use test-driven development to define two negative-value cases before adding validation. The full suite now rejects impossible orders without losing earlier checkout behavior.

Ready to quiz yourself?