All posts

Testing Basics · Part 5

Your first automated test in 15 minutes (Python + pytest)

ProvaLab team · · 5 min read

In Part 3 we saw why teams automate the tests they run again and again. Now let's write some. By the end of this post you will have eight automated tests running in under a second, and you will know how to read a failing one.

We will use Python and pytest, the most popular way to write tests in Python. pytest needs almost no setup, and its tests are plain Python functions.

What you need: Python 3.10 or newer, and a terminal. Check your version with python3 --version (on Windows: python --version). No testing experience required.

Step 1: Set up a project (2 minutes)

Create a folder and a virtual environment: a private space for this project's Python packages, so they don't mix with anything else on your computer.

mkdir first-test
cd first-test
python3 -m venv .venv

Turn the environment on:

# macOS and Linux
source .venv/bin/activate

# Windows (PowerShell)
.venv\Scripts\Activate.ps1

Your prompt now starts with (.venv). Install pytest:

pip install pytest

Step 2: Write the code we want to test (2 minutes)

We will keep using the food delivery app from earlier parts. Create a file called shop.py with one function that adds up an order and applies a discount:

def order_total(prices, discount_percent=0):
    """Add up the prices and apply a percentage discount."""
    if not 0 <= discount_percent <= 100:
        raise ValueError("discount must be between 0 and 100")
    subtotal = sum(prices)
    return subtotal - subtotal * discount_percent / 100

This is exactly the kind of small, important piece of code that unit tests (from Part 2) are made for: if the total is wrong, every customer pays the wrong price.

Step 3: Write your first tests (3 minutes)

Create a second file called test_shop.py:

from shop import order_total


def test_total_adds_up_prices():
    assert order_total([200, 200, 50]) == 450


def test_discount_is_applied():
    assert order_total([200, 200, 50], discount_percent=10) == 405


def test_empty_cart_is_zero():
    assert order_total([]) == 0

That's it. Three things to notice:

  • The file name starts with test_, and so does each function. That is how pytest finds your tests automatically.
  • Each test checks one thing, and its name says what. When it fails, the name alone tells you what broke.
  • assert is plain Python: "this must be true". If it isn't, the test fails.

Each test follows the pattern from Part 1: a starting point (some prices), an action (call order_total) and an expected result (the number after ==).

Step 4: Run the tests (1 minute)

pytest -v

You should see:

collected 3 items

test_shop.py::test_total_adds_up_prices PASSED                           [ 33%]
test_shop.py::test_discount_is_applied PASSED                            [ 66%]
test_shop.py::test_empty_cart_is_zero PASSED                             [100%]

============================== 3 passed in 0.01s ===============================

Three tests, a hundredth of a second. The -v (verbose) flag lists each test by name; without it, pytest prints one dot per passing test.

Step 5: Break something on purpose (3 minutes)

A test you have never seen fail proves very little. Let's plant a bug. In shop.py, change the last line so the discount is subtracted as a flat amount instead of a percentage:

    return subtotal - discount_percent   # bug!

Run pytest again:

collected 3 items

test_shop.py .F.                                                         [100%]

=================================== FAILURES ===================================
___________________________ test_discount_is_applied ___________________________

    def test_discount_is_applied():
>       assert order_total([200, 200, 50], discount_percent=10) == 405
E       assert 440 == 405
E        +  where 440 = order_total([200, 200, 50], discount_percent=10)

test_shop.py:9: AssertionError
=========================== short test summary info ============================
FAILED test_shop.py::test_discount_is_applied - assert 440 == 405
========================= 1 failed, 2 passed in 0.02s ==========================

How to read it:

  • .F.: the first and third tests passed (.), the second failed (F).
  • The > line shows exactly which check failed.
  • assert 440 == 405: the code returned 440, but the test expected 405. A 10% discount on 450 should be 45, not 10.
  • test_shop.py:9: the file and line number to look at.

The other two tests still pass, because the bug only affects discounts. That's why small, focused tests are so useful: the failing test's name points straight at the problem.

Now put the correct line back, run pytest again, and you're green.

Step 6: Test many cases at once (2 minutes)

Writing a new function for every number gets repetitive. pytest's parametrize runs one test with many inputs. Add this to the end of test_shop.py:

import pytest


@pytest.mark.parametrize(
    "prices, discount, expected",
    [
        ([100], 0, 100),
        ([100], 50, 50),
        ([100], 100, 0),
        ([99, 1], 25, 75),
    ],
)
def test_order_totals(prices, discount, expected):
    assert order_total(prices, discount_percent=discount) == expected

Each row is its own test: if one row fails, you see exactly which inputs broke.

Step 7: Test that bad input is refused (1 minute)

Good tests check what the code shouldn't do, too. Our function must refuse a discount over 100%:

def test_discount_over_100_is_refused():
    with pytest.raises(ValueError):
        order_total([100], discount_percent=150)

This test passes only if the code raises a ValueError. If someone removes that check one day, this test will catch it.

Run everything:

collected 8 items

test_shop.py::test_total_adds_up_prices PASSED                           [ 12%]
test_shop.py::test_discount_is_applied PASSED                            [ 25%]
test_shop.py::test_empty_cart_is_zero PASSED                             [ 37%]
test_shop.py::test_order_totals[prices0-0-100] PASSED                    [ 50%]
test_shop.py::test_order_totals[prices1-50-50] PASSED                    [ 62%]
test_shop.py::test_order_totals[prices2-100-0] PASSED                    [ 75%]
test_shop.py::test_order_totals[prices3-25-75] PASSED                    [ 87%]
test_shop.py::test_discount_over_100_is_refused PASSED                   [100%]

============================== 8 passed in 0.01s ===============================

Eight automated tests, done. Every time you change shop.py, one command tells you in a second whether anything broke.

Handy pytest commands

Command What it does
pytest Run all tests, one character per test
pytest -v List every test by name
pytest -k discount Run only tests whose name contains "discount"
pytest -x Stop at the first failure
pytest test_shop.py::test_empty_cart_is_zero Run one specific test
pytest --lf Re-run only the tests that failed last time

Five habits of good tests

  1. One behaviour per test, with a name that says what it checks.
  2. Test the edges: empty lists, zero, the maximum, and invalid input.
  3. Watch every new test fail once, so you know it can actually catch a bug.
  4. Keep tests fast, so people run them all the time.
  5. Run them automatically on every code change, in a CI pipeline.

What's next

You have written unit tests. In Part 6 we step back to the craft behind every test: how to write good test cases, with a template, examples and the techniques testers use to choose them.

When your test suite grows, ProvaLab runs it for you on every change, on real browsers and devices, keeps every result next to its test case, and lets your coding agent read the failures and fix them. See it in the two-minute product tour.

Prefer to read offline? The whole Testing Basics series is one free PDF: get the PDF.

See ProvaLab working on a real app

Your coding agent runs the tests, reads the failures and fixes them.