Skip to content
Codeloom
Testing

Pytest Basics: Writing Your First Python Tests

A practical introduction to pytest — installation, test discovery, the assert statement, parametrize, fixtures, and the command-line flags you will use every day.

·8 min read · By Codeloom
Beginner 11 min read

What you'll learn

  • How to install pytest and run your first test
  • How pytest discovers test files and test functions
  • How plain assert statements become rich failure messages
  • How to run the same test against many inputs with parametrize
  • What fixtures are and when to use them
  • The command-line flags (-v, -k, -x) you will use daily

Prerequisites

Pytest is the de facto testing framework for Python. It is short to learn, friendly in its failure messages, and powerful enough for any project size. This post walks through everything you need to start writing real tests today.

If you have not read What Is Automated Testing? yet, that post explains the why. This one is the how.

Install

Inside a virtual environment, install pytest with pip:

python -m venv .venv
source .venv/bin/activate   # Windows: .venv\Scripts\activate
pip install pytest

Confirm the install:

pytest --version
# pytest 8.x.x

That’s it. No config file is required to start.

Your first test

Pytest discovers tests automatically. The rules:

  • Files named test_*.py or *_test.py
  • Functions named test_*
  • Classes named Test* (no __init__)

Create a file called test_math.py:

def add(a, b):
    return a + b

def test_add_returns_sum():
    assert add(2, 3) == 5

def test_add_handles_negatives():
    assert add(-1, 1) == 0

Run it:

pytest

You should see two dots and a green “2 passed” line. Each dot is a passing test.

In a real project, the code under test would live in its own module:

my_project/
├── src/
│   └── calc.py
└── tests/
    └── test_calc.py

The test file imports from the package and asserts on its behaviour.

The magic of assert

Other frameworks make you call methods like assertEqual(a, b). Pytest lets you use Python’s built-in assert statement and still get a rich failure message. Watch what happens when a test fails:

def test_add_handles_zero():
    assert add(2, 0) == 3   # wrong on purpose
>       assert add(2, 0) == 3
E       assert 2 == 3
E        +  where 2 = add(2, 0)

test_math.py:7: AssertionError

Pytest rewrites the assertion at import time to capture the values on each side. You see what was expected, what you got, and the call that produced the result — without any extra ceremony.

You can add a custom message after a comma:

assert add(2, 3) == 5, "addition should be commutative-friendly"

But most of the time the default message is enough.

Running specific tests

A few flags pay for themselves immediately.

Verbose output — see each test name as it runs:

pytest -v

Stop on first failure — useful when iterating:

pytest -x

Filter by substring — run only tests whose name matches:

pytest -k "negatives"

A single file or function:

pytest tests/test_math.py
pytest tests/test_math.py::test_add_returns_sum

Show the slowest tests so you know where to optimise:

pytest --durations=10

Combine freely: pytest -v -x -k "user" runs all “user” tests verbosely and stops on the first failure.

Parametrize: same test, many inputs

Often you want to check a function against many inputs without writing many near-identical tests. @pytest.mark.parametrize does this:

import pytest

@pytest.mark.parametrize("a, b, expected", [
    (1, 1, 2),
    (0, 0, 0),
    (-1, 1, 0),
    (100, 200, 300),
])
def test_add(a, b, expected):
    assert add(a, b) == expected

Pytest runs the test four times, once per row, and reports each independently:

test_math.py::test_add[1-1-2] PASSED
test_math.py::test_add[0-0-0] PASSED
test_math.py::test_add[-1-1-0] PASSED
test_math.py::test_add[100-200-300] PASSED

If one row fails, you see exactly which inputs broke. This is the single most useful pytest feature for testing pure functions.

You can stack parametrize decorators to test a Cartesian product:

@pytest.mark.parametrize("x", [0, 1, 2])
@pytest.mark.parametrize("y", [10, 20])
def test_pairs(x, y):
    assert x + y >= y

That runs 6 combinations.

Testing exceptions

To assert that a function raises, use pytest.raises:

import pytest

def divide(a, b):
    if b == 0:
        raise ValueError("cannot divide by zero")
    return a / b

def test_divide_by_zero_raises():
    with pytest.raises(ValueError, match="cannot divide"):
        divide(10, 0)

The optional match argument is a regex against the exception message. It catches the case where the right exception type is raised for the wrong reason.

For more on Python exceptions, see Python Error Handling.

Fixtures: shared setup

A fixture is a function that prepares something — a value, an object, a database connection — and makes it available to tests by name.

import pytest

@pytest.fixture
def sample_user():
    return {"name": "Ada", "email": "ada@example.com"}

def test_user_has_name(sample_user):
    assert sample_user["name"] == "Ada"

def test_user_has_email(sample_user):
    assert "@" in sample_user["email"]

Each test function parameter named sample_user triggers the fixture and receives its return value. Fixtures keep tests short and DRY without the rigidity of class-based setUp.

Fixtures can do teardown with yield:

@pytest.fixture
def temp_file(tmp_path):
    path = tmp_path / "data.txt"
    path.write_text("hello")
    yield path
    # cleanup runs after the test (tmp_path is auto-cleaned, this is illustrative)

def test_reads_file(temp_file):
    assert temp_file.read_text() == "hello"

Pytest ships useful built-in fixtures: tmp_path for a temporary directory, monkeypatch for safe attribute patching, capsys for capturing stdout, and more.

You can change a fixture’s scope so it is created once per module or session, not once per test:

@pytest.fixture(scope="session")
def expensive_resource():
    return spin_up_database()

Use scope wisely — session fixtures are fast but tests can pollute each other if you are not careful.

Try it yourself. Create a Cart class with add(item) and total() methods. Write a fixture that returns an empty Cart. Then write three tests that use the fixture: one for an empty total, one for a single item, and one parametrized over several item lists. Notice how the fixture eliminates the repeated cart = Cart() line.

What good test names look like

A great test name reads like a sentence describing behaviour, not an implementation detail.

Good:

def test_withdrawing_more_than_balance_raises_insufficient_funds():
    ...

def test_empty_cart_total_is_zero():
    ...

def test_signup_with_existing_email_returns_400():
    ...

Bad:

def test_withdraw():
    ...

def test_cart_1():
    ...

def test_signup_works():
    ...

When a test fails six months from now, the name should tell you what behaviour broke before you read the code. “test_withdraw” tells you nothing; “test_withdrawing_more_than_balance_raises_insufficient_funds” tells you everything.

A small project layout

A modest pytest project usually looks like this:

my_project/
├── pyproject.toml
├── src/
│   └── shop/
│       ├── __init__.py
│       ├── cart.py
│       └── pricing.py
└── tests/
    ├── conftest.py
    ├── test_cart.py
    └── test_pricing.py

conftest.py is a special file where fixtures live so they are automatically available to every test in the same folder (and subfolders) — no import required.

You can configure pytest in pyproject.toml:

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-v --tb=short"

--tb=short shortens tracebacks; testpaths tells pytest where to look. Most projects only need a couple of lines here.

Skipping and marking

Sometimes a test should not run — for example, a feature is half-built, or the test only applies on Linux. Mark it:

import sys
import pytest

@pytest.mark.skip(reason="WIP — re-enable after refactor")
def test_pending():
    ...

@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only")
def test_filesystem_permissions():
    ...

For tests you expect to fail, use xfail so a surprise success becomes visible:

@pytest.mark.xfail(reason="known bug, fix in PR #432")
def test_known_bug():
    ...

Try it yourself. Write a parse_price(text) function that converts strings like "$12.50" into a float. Then write a parametrized test that covers: a plain number, a string with a dollar sign, a string with whitespace, and an invalid input (using pytest.raises). Run with pytest -v and confirm each row reports separately.

Reading the failure output

When a test fails, pytest shows:

  1. The assertion line with both sides expanded
  2. A short traceback into your code
  3. The captured output (anything you printed)
  4. A summary line at the bottom

Read from the bottom up. The summary tells you how many failed; the assertion shows what was wrong; the traceback shows where. Most “this test is mysterious” cases vanish once you read the output carefully.

What to skip for now

Pytest has a deep feature set. You can grow into it. For your first month, ignore:

  • Plugins beyond pytest-cov (coverage)
  • Custom markers and complex conftest hierarchies
  • The pytester plugin (for testing pytest itself)
  • pytest-asyncio (until you actually have async code)

The core — assert, parametrize, fixtures, raises, and the CLI flags above — covers 90% of real test code.

Recap

You now know:

  • Pytest discovers test_*.py files and test_* functions automatically
  • Plain assert statements produce rich failure messages
  • -v, -x, -k, and direct file/function paths control what runs
  • @pytest.mark.parametrize runs one test against many input rows
  • pytest.raises asserts that code raises a specific exception
  • Fixtures centralise setup and live in conftest.py for sharing
  • Test names should describe behaviour in plain English

Next steps

The next step is writing tests for a real project of yours — pick a module that handles money, parsing, or business rules, and write five to ten tests for it. Then add a test every time you fix a bug.

For more on the surrounding ideas, read What Is Automated Testing?. For testing JavaScript code, see Vitest Basics — many of the same concepts apply with different syntax.

Related: Python Error Handling, What Is Python?.

Questions or feedback? Email codeloomdevv@gmail.com.