Addaly is in open beta. Things will change, and AI answers can be wrong — check anything that matters.

Python, From Zero, For AI

From your first line of code to your first API call.

Lesson 36 of 899 min

Your first test, and what to test first

A test is a function that would fail if you broke something

bash
pip install pytest

Put this in tests/test_money.py:

python
from billing.money import with_tax

def test_adds_eighteen_percent():
    assert with_tax(100) == 118.0

def test_rounds_to_two_places():
    assert with_tax(99.99) == 117.99

def test_zero_stays_zero():
    assert with_tax(0) == 0

Run it from the project root:

bash
pytest
tests/test_money.py ...                                          [100%]
3 passed in 0.02s

That is the entire framework. Files named test_*.py, functions named test_*, and the plain assert statement. No classes, no self, no special assertion methods.

What a failure looks like

    def test_rounds_to_two_places():
>       assert with_tax(99.99) == 117.99
E       assert 117.98999999999999 == 117.99
E        +  where 117.98999999999999 = with_tax(99.99)

tests/test_money.py:8: AssertionError

pytest rewrites your assertions so the failure shows both sides and the call that produced them. That output tells you the function forgot to round, and it also tells you something from an earlier lesson: comparing floats with == is fragile, and pytest.approx(117.99) is the right comparison for money that is not stored as integers.

What to test first

Not everything. In order of value for the effort:

  1. The pure functions. Parsing, calculating, formatting. No setup, no mocking, milliseconds to run. This is why the earlier lesson pushed logic out of the I/O.
  2. The bug you just fixed. Write the test that reproduces it, watch it fail, then apply the fix and watch it pass. This is the single highest-value test in any codebase, because that bug has already proved it can happen.
  3. The edge cases you thought about while writing. Empty list, zero, one item, negative, missing key, a name with an apostrophe.
  4. The boundary with the outside world, with a fake standing in for it. That is the next lesson.

Test names should say what is true, not what is called: test_negative_quantity_is_rejected, not test_split_bill_2. When it fails at midnight, the name is the first thing you read.

The same test with many inputs

python
import pytest

@pytest.mark.parametrize("amount,expected", [
    (0, 0),
    (100, 118.0),
    (99.99, 117.99),
    (1, 1.18),
])
def test_with_tax(amount, expected):
    assert with_tax(amount) == pytest.approx(expected)

Four separate tests from one function, each reported by name, each failing independently. When you find a fifth interesting input, it is one line. This is the feature that makes writing tests fast enough to actually do.

Testing that something raises

python
def test_rejects_zero_people():
    with pytest.raises(ValueError, match="at least 1"):
        split_bill(100, 0)

The test passes only if that exception is raised and its message matches. Errors are part of the interface, and this is how you hold them still.

Fixtures, briefly

When several tests need the same starting data:

python
@pytest.fixture
def rows():
    return [{"id": 1, "amount": 100}, {"id": 2, "amount": 250}]

def test_total(rows):
    assert total(rows) == 350

Ask for the fixture by name in the test's parameters and pytest supplies it, freshly built for each test. Fresh is the point: tests that share one mutable object pass or fail depending on the order they run in, which is a miserable class of bug.

tmp_path is a built-in fixture giving each test its own empty directory, which is how you test file writing without leaving rubbish behind or clobbering real data.

Coverage, and what it does not mean

bash
pip install pytest-cov
pytest --cov=billing

This reports the percentage of lines your tests executed. It is useful for finding whole modules nobody tested. It is not a measure of quality: a test that calls every line and asserts nothing scores 100 per cent. Coverage tells you what was run, never what was checked.

Chasing a number above roughly 80 per cent usually buys tests of trivial code while the hard logic stays under-tested. Look at which lines are uncovered, not at the total.

Where the tests live, and how pytest finds your code

Put tests in a tests/ folder at the project root, as the layout lesson described, and run pytest from that root. pytest adds the root to sys.path when it finds a tests folder without an __init__.py, which is why from billing.money import with_tax resolves.

When it does not — ModuleNotFoundError: No module named 'billing' — the cause is almost always that you ran pytest from inside tests/, or that the package is not importable from where you are. python3 -m pytest instead of pytest puts the current directory on the path and fixes most cases, and it also guarantees you are using the interpreter of the active virtual environment rather than whichever pytest the shell found first.

The habit that makes it stick

Run pytest before every commit. It takes two seconds on a small project. A test suite that is only run occasionally rots, and a rotted suite is worse than none, because a red run stops meaning anything.

The one thing to keep

pytest turns plain assert statements into detailed failure reports, and the highest-value test you will ever write is the one that reproduces a bug you just fixed.

Before you move on

A team reports 94 per cent line coverage and is surprised when a release breaks the discount calculation. What does the coverage figure actually establish?

Pick the one you would defend. Nobody sees your answer.

No ads. No data sale. No public scores on people. Ever.

© 2026 Addaly