Unit Testing in Python with pytest: A Beginner's Guide

A beginner's guide to pytest built on a small real module: writing tests as plain functions with assert, reading pytest's failure reports, parametrising edge cases, testing that errors are raised, and using fixtures, tmp_path and monkeypatch so tests never touch real files or settings.

GloryTecks Team

Training & Placement Faculty

6 May 2026 6 min read

Last updated: 25 September 2026

Share:

Most people test their code by running it and looking at the output. That works once. It does not tell you, three months later, whether a small change broke a calculation in a corner you did not look at. Automated tests do: short functions that call your code with known inputs and check the results, run in seconds, every time anything changes. pytest is the tool most Python projects use to write and run them (pytest: get started).

The code under test

A small pricing module, the kind of logic that must be right every time:

python
# pricing.py
from decimal import Decimal, ROUND_HALF_UP


def apply_discount(price: Decimal, percent: int) -> Decimal:
    """Return the price after a percentage discount, rounded to paise."""
    if not 0 <= percent <= 100:
        raise ValueError(f"percent must be between 0 and 100, got {percent}")
    discounted = price * (100 - percent) / 100
    return discounted.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)


def gst(amount: Decimal, rate_percent: int = 18) -> Decimal:
    return (amount * rate_percent / 100).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)

Your first tests

A pytest test is a function whose name starts with test_, in a file whose name starts with test_. It checks results with a plain assert:

python
# test_pricing.py
from decimal import Decimal

import pytest

from pricing import apply_discount, gst


def test_ten_percent_off():
    assert apply_discount(Decimal("1499.00"), 10) == Decimal("1349.10")


@pytest.mark.parametrize(
    ("price", "percent", "expected"),
    [
        ("100.00", 0, "100.00"),
        ("100.00", 100, "0.00"),
        ("99.99", 33, "66.99"),
    ],
)
def test_discount_edges(price, percent, expected):
    assert apply_discount(Decimal(price), percent) == Decimal(expected)


def test_rejects_impossible_percent():
    with pytest.raises(ValueError, match="between 0 and 100"):
        apply_discount(Decimal("100"), 120)


def test_gst_on_discounted_price():
    assert gst(apply_discount(Decimal("1000"), 10)) == Decimal("162.00")

Run pytest in the project folder. It finds the test files, runs every test function, and reports:

text
$ pytest -q
......                                                                   [100%]
6 passed in 0.03s

Four test functions produced six results, because the parametrised test ran once for each of its three cases (pytest: parametrize). Each dot is a passing test.

What each test is doing

  • test_ten_percent_off is the typical case: known input, known answer.
  • test_discount_edges covers the boundaries, 0% and 100%, plus a rounding case. parametrize turns a table of inputs into separate tests, so adding a case is one line, and a failure names the exact case.
  • test_rejects_impossible_percent checks that bad input fails loudly. pytest.raises passes only if the block raises that exception, and match checks its message too.
  • test_gst_on_discounted_price checks two functions working together.

Good tests are small, independent and specific: each checks one behaviour, and none depends on another having run first.

Reading a failure

Suppose someone "fixes" the expected rounding and writes 67.00 instead of 66.99. pytest's report shows exactly what went wrong (pytest: assertions):

text
___________ test_discount_edges[99.99-33-67.00] ___________

price = '99.99', percent = 33, expected = '67.00'

    def test_discount_edges(price, percent, expected):
>       assert apply_discount(Decimal(price), percent) == Decimal(expected)
E       AssertionError: assert Decimal('66.99') == Decimal('67.00')
E        +  where Decimal('66.99') = apply_discount(Decimal('99.99'), 33)

The test ID names the failing case, the > marks the failing line, and the E lines show the actual value, the expected value and the call that produced it. That detail comes from plain assert statements; pytest rewrites them to report intermediate values, so there is no need for special assertion methods. Here the test was wrong, not the code: 99.99 Γ— 0.67 is 66.9933, which rounds to 66.99.

Fixtures: shared setup, and no real files

A fixture is a function that provides something a test needs, such as sample data, a temporary folder or a database connection. A test requests it by naming it as a parameter (pytest: fixtures). pytest also ships built-in fixtures: tmp_path is a fresh temporary directory for each test, and monkeypatch changes environment variables or attributes for one test and undoes the change afterwards (pytest: monkeypatch).

python
# test_reports.py
import pytest

from reports import report_email, write_summary


@pytest.fixture
def sample_rows():
    return [{"region": "south", "total": 1650}, {"region": "west", "total": 900}]


def test_summary_file_has_header_and_rows(tmp_path, sample_rows):
    path = write_summary(sample_rows, tmp_path)
    assert path.read_text(encoding="utf-8").splitlines() == ["region,total", "south,1650", "west,900"]


def test_email_comes_from_environment(monkeypatch):
    monkeypatch.setenv("REPORT_EMAIL", "finance@example.com")
    assert report_email() == "finance@example.com"


def test_email_default(monkeypatch):
    monkeypatch.delenv("REPORT_EMAIL", raising=False)
    assert report_email() == "reports@example.com"
text
$ pytest -q
.........                                                                [100%]
9 passed in 0.03s

The file test writes into a temporary folder instead of the project, and the environment tests cannot leak a setting into other tests or into your shell. write_summary and report_email are two short functions in a reports.py module: one writes a CSV with csv.DictWriter, the other reads an environment variable with a default.

Running tests efficiently

A few command-line options make the daily loop fast:

  • pytest -x stops at the first failure, useful when one broken thing causes many failures.
  • pytest -k discount runs only tests whose names contain "discount".
  • pytest --lf reruns only the tests that failed last time, so you can fix and recheck quickly.
  • pytest -v lists every test by name with its result, instead of dots.

Run the whole suite before every commit, and let a continuous integration service run it again on every push, so nobody merges code with failing tests.

Test names are documentation

A test's name appears in every report, so make it describe the behaviour: test_rejects_impossible_percent tells a reader what the code must do before they read a line of the test. When a suite is named this way, the list of tests doubles as a specification of the module, and a failure message explains itself.

What to test first

  1. Calculations and rules where a wrong answer costs money or trust: prices, taxes, eligibility.
  2. Boundaries: empty input, zero, maximum values, the first and last day of a month.
  3. Error handling: invalid input should raise the error you intend, with a clear message.
  4. Bugs you have fixed: write a test that reproduces the bug before fixing it, so it can never return.

Code that talks to networks, databases or the clock is easier to test when those are passed in as arguments rather than created inside the function; then a test can pass a fake. Keep tests in a tests/ folder next to your package, as described in how to structure a Python project, and run them on every change, locally and in continuous integration.

pytest or unittest?

unittest is in the standard library and uses classes with assertion methods. pytest runs unittest tests too, needs less code for new ones and gives better failure reports, which is why most new projects choose it.

How many tests are enough?

Enough that you would trust a change to the code without re-checking everything by hand. Coverage tools such as the pytest-cov plugin show which lines no test runs, which is a useful guide, not a target.

Where do I put fixtures that several test files use?

In a file named conftest.py in the tests folder. pytest loads it automatically, and its fixtures are available to every test in that folder and below, with no import needed.

Should tests hit the real database or API?

Unit tests should not: they would be slow and flaky. Test the logic with fakes or fixtures, and add a smaller number of integration tests that use a real test database.

Tests are the safety net for everything else in Python best practices, and they pair naturally with the error handling guide for testing failure paths. Unit testing basics, debugging and writing clean Python code form the testing and best practices module of the Python Programming course.

Sources

GloryTecks Team

Training & Placement Faculty

The GloryTecks faculty team mentors learners across Data Science, AI and Data Engineering, with a focus on real-time projects and placement readiness in Hyderabad.

Want to master Python?

Join GloryTecks’ expert-led Python Programming training in Hyderabad with real-time projects and 100% placement support.

Explore course