What you'll learn
Quick Answer
Install pytest, put a file named test_calc.py next to your code, write a function starting with test_ that uses a plain assert, and run pytest. Fixtures give each test fresh setup with no copy-paste, and parametrize runs the same test over many inputs. The first thing that catches people is discovery: pytest only collects files named test_*.py or *_test.py and functions whose names begin with test.
Your first test, and why pytest finds nothing
Start with a function worth testing. Save this as calc.py:
# calc.py
def apply_discount(price, percent):
if not 0 <= percent <= 100:
raise ValueError('percent must be between 0 and 100')
return round(price - price * percent / 100, 2)
Now the test, in a file called test_calc.py in the same folder:
# test_calc.py
from calc import apply_discount
def test_flat_discount():
assert apply_discount(1000, 10) == 900.0
pip install pytest
pytest -q
That is the whole framework for your first week. No class to inherit from, no self.assertEqual, no boilerplate. A test is a function that starts with test and contains an assert.
Which brings us to the thing that wastes the first hour for most people. pytest finds tests by name. Files must match test_*.py or *_test.py, and functions inside them must start with test. Name your file calc_tests.py or your function check_discount and pytest collects nothing, printing something like no tests ran in 0.01s. It looks close enough to a pass that people move on, and the suite quietly protects nothing. The exit code is 5 in that case, not 0, which is what your CI should be checking.
The second common blocker is an import error rather than a test failure:
ModuleNotFoundError: No module named 'calc'
This happens because the folder your code lives in is not on the import path. The quickest fix is to run python -m pytest instead of pytest, since that form adds the current directory to sys.path. For a real project, install it in editable mode with pip install -e . so imports work the same way in tests as they do everywhere else.
assert, reading failures, and testing for errors
pytest rewrites your assert statements while importing the test module, which is why a failure shows you the actual values rather than just "assertion failed":
def test_flat_discount():
assert apply_discount(1000, 10) == 950.0
# E assert 900.0 == 950.0
# E + where 900.0 = apply_discount(1000, 10)
That rewriting only applies to test files pytest collects, so an assert inside a helper module gives you the bare message. If you have shared assertion helpers, register them with pytest.register_assert_rewrite or keep them inside conftest.py.
Error paths deserve tests as much as happy paths. pytest.raises asserts that a block raises a particular exception, and fails the test if it does not:
import pytest
from calc import apply_discount
def test_rejects_percent_above_100():
with pytest.raises(ValueError):
apply_discount(1000, 150)
def test_error_message_is_useful():
with pytest.raises(ValueError, match='between 0 and 100'):
apply_discount(1000, -5)
There is a trap inside that block. The with block ends as soon as something raises, so any line after the raising call never runs. This test passes while only checking one of the two cases:
def test_two_cases_but_only_one_runs():
with pytest.raises(ValueError):
apply_discount(1000, 150)
apply_discount(1000, 200) # never executed
Put exactly one call inside a pytest.raises block, and use parametrize when you have several cases.
Floats need care, because binary floating point cannot represent most decimal fractions exactly. Comparing them with == produces tests that fail for reasons unrelated to your code. pytest.approx compares with a tolerance:
def test_float_maths():
assert 0.1 + 0.2 != 0.3 # true, and surprising
assert 0.1 + 0.2 == pytest.approx(0.3) # this is what you want
Fixtures: setup without copy-paste
A fixture is a function that produces something a test needs. You mark it with @pytest.fixture and then simply name it as a parameter of the test; pytest matches by name and passes the value in.
import pytest
@pytest.fixture
def student_file(tmp_path):
path = tmp_path / 'students.csv'
path.write_text('Asha,88\nRavi,71\n', encoding='utf-8')
return path
def test_reads_all_rows(student_file):
rows = student_file.read_text(encoding='utf-8').strip().splitlines()
assert len(rows) == 2
tmp_path there is a fixture pytest gives you for free: a fresh empty directory, unique per test, as a pathlib.Path. Using it means your tests never write into your project folder and never collide with each other. monkeypatch and capsys are two other built-ins worth knowing, for temporarily replacing attributes and for capturing printed output.
When a fixture needs to clean up afterwards, use yield instead of return. Everything after the yield runs once the test finishes, even if the test failed:
@pytest.fixture
def connection():
conn = open_database()
yield conn
conn.close()
Fixtures shared across several test files go in a file named conftest.py in the same directory or above it. pytest picks it up automatically with no import, which is both convenient and slightly magical the first time you see it.
Now the trap. By default a fixture runs once per test, which is what makes tests independent. Change the scope and it does not:
@pytest.fixture(scope='module')
def cart():
return [] # the same list for every test in this file
Any test that appends to that cart changes what the next test sees. The suite passes when run in order and fails when you run a single test with -k, or when tests are reordered or run in parallel. That is one of the most demoralising bugs to chase, so keep the default function scope unless the setup is genuinely expensive and genuinely read-only, such as loading a large fixture file or starting one container for the whole session.
parametrize: one test, many cases
Copying a test five times to change one number is the usual way suites become unmaintainable. @pytest.mark.parametrize takes a list of input tuples and runs the test once per tuple, reporting each as a separate test:
import pytest
from calc import apply_discount
@pytest.mark.parametrize('price,percent,expected', [
(1000, 0, 1000.0),
(1000, 10, 900.0),
(1000, 100, 0.0),
(999, 50, 499.5),
])
def test_discounts(price, percent, expected):
assert apply_discount(price, percent) == expected
The important detail is that these are four independent tests, not one test with four assertions. If the third case fails, the fourth still runs and you see both results. Written as four asserts in one function, the first failure hides everything after it.
You can name the cases so the output reads clearly, which matters when a CI log is all you have to work from:
@pytest.mark.parametrize(
'percent', [0, 10, 100], ids=['no-discount', 'ten-percent', 'free']
)
def test_valid_percentages(percent):
assert apply_discount(1000, percent) >= 0
Parametrize is the natural home for boundary values, and boundaries are where bugs live. For the discount function the interesting inputs are 0 and 100, which must be accepted, and anything just outside that range, which must raise. Stacking two parametrize decorators on one test produces every combination of the two lists, which is occasionally useful and very easy to overdo.
One caution: the argument list is built once when the module is imported, so a mutable object such as a list or dict in that list is shared by every case that receives it. If a test mutates it, later cases see the change. Pass immutable values, or build the mutable object inside a fixture where each test gets its own.
What to test, what to skip, and running tests in CI
Beginners usually write too many tests of the wrong kind. Test behaviour that could plausibly be wrong: calculations, boundaries, error handling, parsing, anything with an if in it. A GST calculation, a date parser and a discount rule all earn tests. A getter that returns a field does not.
Things to leave alone. Do not test the standard library or a third party package, since you are testing someone else's code and your test breaks when they change an implementation detail. Do not assert on private internals, because then any refactor breaks the suite even though the behaviour is unchanged; that is the difference between a test that protects you and one that just makes changes expensive. And do not chase a coverage percentage, because coverage measures which lines ran, not whether you checked the result. A test that calls a function and asserts nothing counts as full coverage of that function.
Tests must not touch the real world. No live payment gateway, no production database, no email being sent, no network call to an API that might be down when you run the suite. Replace those boundaries with monkeypatch:
import payments
def test_creates_order_without_network(monkeypatch):
def fake_create(amount_paise):
return {'id': 'ord_test_1', 'amount': amount_paise}
monkeypatch.setattr(payments, 'create_remote_order', fake_create)
order = payments.create_order(19900)
assert order['id'] == 'ord_test_1'
Then run the suite automatically on every push. pytest exits non-zero when anything fails, so CI needs no special integration. A minimal GitHub Actions workflow at .github/workflows/tests.yml:
name: tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: pip install -r requirements.txt pytest
- run: pytest -q
Useful flags while you work: -x stops at the first failure, -k discount runs only tests whose names match, -q shortens the output and -vv lengthens it. On your CV, a repository with a tests folder and a green workflow badge says more about how you work than another project with a longer README, because most graduates arrive having never written a test at all.
