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.

Frequently Asked Questions

Why does pytest say no tests ran when my file clearly has tests? Discovery is entirely name based. The file must match test_*.py or *_test.py and the functions inside must start with test, so calc_tests.py or a function named check_discount is invisible. Test classes must be named Test* and must not define an __init__ method, otherwise pytest skips them with a warning. Run pytest --collect-only to see exactly what it found before assuming your assertions are wrong.
What is the difference between a fixture and just calling a setup function? A fixture is requested by name, so pytest builds it only for tests that ask for it, caches it according to its scope, and runs the code after its yield as teardown even when the test fails. Fixtures can also depend on other fixtures, and putting them in conftest.py shares them across files without imports. A plain setup function gives you none of that and has to be called and cleaned up by hand in every test.
Should I aim for 100 percent test coverage? No. Coverage tells you which lines executed, not whether the result was checked, so a test that calls a function and asserts nothing still shows as covered. Chasing the last few percent usually means writing tests for trivial code or for error branches that cannot happen. Aim instead for good coverage of the logic that could realistically be wrong: calculations, boundaries, parsing and error handling.
How do I test code that calls an external API or a database? Do not call the real thing. Use the monkeypatch fixture to replace the function that performs the call with a stand-in that returns a fixed response, so the test is fast, deterministic and works offline. Keep the network or database call in a thin, separate function so it is easy to swap. If you genuinely need a real database, use an in-memory or temporary one created by a fixture and destroyed afterwards.
Is pytest better than unittest for a beginner? For learning, yes. pytest uses plain assert statements and plain functions, so there is no class hierarchy or assertEqual vocabulary to memorise, and its failure output shows the actual values involved. It also runs unittest style tests unchanged, so an existing suite keeps working. unittest is still worth recognising because it is in the standard library and older codebases and many textbooks use it.