Tests give developers confidence that code behaves as expected and that future changes do not silently break existing features. pytest is a popular Python testing framework because it has a simple test-discovery model, readable assertions, fixtures and a large plugin ecosystem.

This guide walks through a practical pytest workflow: writing your first test, organizing test files, using fixtures, checking exceptions, isolating external dependencies and running tests in a repeatable environment.

1. Install pytest in the project environment

Create and activate a virtual environment, then install pytest:

python -m venv .venv
python -m pip install pytest

Using python -m pip helps ensure the package is installed for the interpreter you will use to run tests.

Check the installed version:

python -m pytest --version

For a maintained project, record the testing dependency in the project's dependency configuration so other developers and CI can install the same toolchain.

2. Write your first test

pytest discovers files commonly named test_*.py or *_test.py, and test functions whose names start with test_.

Create calculator.py:

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

Create test_calculator.py:

from calculator import add

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

Run the suite:

python -m pytest

pytest reports which tests passed and which failed. A failed assertion includes useful information about the expected and actual values.

3. Organize tests around behaviour

A small project can keep tests in a tests/ directory:

project/
  calculator.py
  tests/
    test_calculator.py

As the project grows, organize files around modules or features. Keep tests readable and focused. A good test name describes the behaviour being checked, such as test_rejects_negative_quantity, rather than the implementation detail.

Prefer testing public behaviour. If a test depends heavily on private helper functions or internal variable names, a harmless refactor can break the test without changing the program's behaviour.

4. Test normal cases, boundaries and invalid input

A function should not be tested only with one happy-path example.

For a discount function, consider:

  • A normal valid price and percentage.
  • A zero discount.
  • A full discount.
  • A negative price.
  • A percentage above the permitted range.
  • Boundary values at the allowed minimum and maximum.

Use parametrization to run the same behaviour test against multiple inputs:

import pytest
from calculator import add

@pytest.mark.parametrize(
    "a,b,expected",
    [
        (2, 3, 5),
        (0, 0, 0),
        (-2, 2, 0),
    ],
)
def test_add(a, b, expected):
    assert add(a, b) == expected

Parametrization reduces repeated test code while making the input cases visible.

5. Use fixtures for reusable setup

A fixture prepares data or resources for tests. It can be shared by several tests and can handle cleanup.

import pytest

@pytest.fixture
def sample_user():
    return {"id": 7, "name": "Asha"}

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

Fixtures can also use yield to perform cleanup after the test:

@pytest.fixture
def temporary_resource():
    resource = create_resource()
    yield resource
    resource.close()

Keep fixtures focused. A fixture that creates a database, logs in several users, configures a web server and builds a dozen records can make tests difficult to understand. Prefer smaller fixtures that can be combined.

6. Test expected exceptions

Use pytest.raises to verify that invalid input raises the expected exception:

import pytest

def test_invalid_quantity_is_rejected():
    with pytest.raises(ValueError, match="quantity"):
        create_order(quantity=0)

Checking the exception type is important. Checking a stable message fragment can be useful when the message is part of the public contract, but avoid making tests brittle by matching an entire sentence unnecessarily.

7. Isolate external dependencies

A unit test should usually be fast and predictable. It should not depend on a real payment provider, production database or public website unless the test is intentionally an integration test.

Use dependency injection where possible so a function can receive a fake or test implementation. pytest's monkeypatch fixture can replace a function or environment value during a test:

def get_service_url():
    import os
    return os.environ["SERVICE_URL"]

def test_service_url(monkeypatch):
    monkeypatch.setenv("SERVICE_URL", "http://localhost")
    assert get_service_url() == "http://localhost"

For HTTP clients, use a mock transport or a local test server supported by the client library. A test that calls a real external service can become flaky, slow and expensive.

8. Understand unit, integration and end-to-end tests

Different test layers answer different questions:

  • Unit tests check a small function or component in isolation.
  • Integration tests check whether components work together, such as an API and database.
  • End-to-end tests exercise a user workflow across the running application.

A healthy project usually needs a balance. Unit tests are fast and help pinpoint logic errors. Integration tests catch mismatches between components. End-to-end tests verify critical journeys but are usually slower and require more setup.

Do not try to test every detail through a browser test. Use the lowest layer that can prove the behaviour you care about.

9. Useful pytest commands

# Run all discovered tests
python -m pytest

# Run one file
python -m pytest tests/test_calculator.py

# Show more detailed output
python -m pytest -v

# Stop after the first failure
python -m pytest -x

# Run tests matching part of a name
python -m pytest -k "discount"

# Show print output
python -m pytest -s

Use markers to organize slower integration tests or environment-dependent checks. Document how developers should run each group.

10. Make tests reliable in CI

A test suite should behave consistently on a developer's machine and in continuous integration.

  • Use a clean environment and install declared dependencies.
  • Avoid relying on test execution order.
  • Use temporary directories and isolated test data.
  • Set timeouts for operations that can hang.
  • Clean up external resources.
  • Keep secrets out of test output and repository files.
  • Run the same core command locally and in CI.

If a test fails intermittently, investigate shared state, timing, network dependency, random data and concurrency. Do not simply rerun until it passes and ignore the underlying cause.

11. Common testing mistakes

MistakeWhy it causes troubleBetter approach
Testing only the happy pathBoundary failures remain hiddenAdd invalid and edge cases
One enormous testFailure location is unclearKeep tests focused
Sharing mutable fixtures unintentionallyTests influence one anotherUse appropriate fixture scope and fresh data
Calling live services in unit testsTests become flaky and slowMock or inject dependencies
Testing private implementation detailsRefactors break tests unnecessarilyAssert public behaviour
Ignoring failing testsRegressions accumulateFix or deliberately quarantine with ownership
Relying only on unit testsIntegration problems remainAdd representative integration and end-to-end tests

Frequently asked questions

Does pytest require classes?

No. Most pytest tests are simple functions. Classes can be used for grouping, but they are not required.

What is a fixture?

A fixture provides reusable setup and optionally cleanup for tests. It can create test data, configure a temporary resource or provide a fake dependency.

Should every line have a test?

Line coverage can reveal untested code, but high coverage does not prove that assertions are meaningful. Focus on important behaviour, edge cases and failure modes rather than chasing a percentage alone.

Final checklist

  • Keep tests in a predictable structure.
  • Name tests after the behaviour they verify.
  • Cover success, invalid input and boundaries.
  • Use fixtures for reusable setup and cleanup.
  • Isolate external services in unit tests.
  • Add integration tests for important component boundaries.
  • Run the suite in a clean CI environment.
  • Treat flaky tests as defects to investigate.

A useful test suite is a safety net for change. Start with the behaviour that matters most, make each test easy to understand, and run the same checks whenever the code or its dependencies change.