On this page
Set up the project
Install pytest in the project's virtual environment and keep tests in a tests directory that mirrors the package structure, such as tests/test_pricing.py for app/pricing.py. Configure pytest in pyproject.toml so everyone runs it the same way, for example by setting the test paths and enabling strict markers. Run pytest from the project root; a single command should run the whole suite.
Write tests for behaviour
Each test should check one behaviour and be named after it, such as test_student_discount_applies_once. Structure the body as arrange, act and assert. Plain assert statements are enough, because pytest rewrites them to show the compared values on failure. Prefer testing public functions and observable results over internal helpers, so refactoring does not break tests unnecessarily.
@pytest.mark.parametrize(
"price, coupon, expected",
[(399, None, 399), (399, "STUDENT20", 319), (99, "EXPIRED", 99)],
ids=["no-coupon", "student", "expired"],
)
def test_final_price(price, coupon, expected):
assert final_price(price, coupon) == expectedIsolate external services
Tests should not call real payment providers, email services or paid APIs. Replace them at the boundary: inject a client object your code depends on and pass a fake in tests, or patch it with pytest's monkeypatch or unittest.mock. Mock the boundary your code owns, not deep internals of a third-party library, so tests remain stable when the library changes. For HTTP clients, response-mocking libraries can simulate status codes, timeouts and malformed bodies.
- Test success responses and each kind of failure you handle.
- Assert on what your code did with the response, not only that the mock was called.
- Keep a few integration tests against real services in a separate, opt-in suite where appropriate.
Test with a real database where it matters
Mocking a database often hides the bugs that matter, such as constraint violations and query mistakes. For data-access code, run tests against the same database engine as production, for example in a container, and wrap each test in a transaction that is rolled back, or recreate the schema per test session. These tests are slower, so keep them focused on queries and repositories.
Use coverage to find gaps
Measure coverage with pytest-cov and read the report for untested branches in important modules, such as pricing, permissions and payment handling. A high overall percentage can still miss the riskiest paths, and chasing 100% often produces tests that assert nothing useful. Set a sensible minimum in CI to prevent coverage from dropping, and review uncovered lines in critical code during review.
Keep the suite fast and reliable
- Run unit tests in seconds; mark slow tests and run them separately if needed.
- Avoid sleeping in tests; control time with injectable clocks or freezing libraries.
- Make tests independent so they pass in any order and in parallel.
- Fix or quarantine flaky tests immediately; an unreliable suite gets ignored.
Checklist
- Tests mirror the package structure and run with one command
- Each test checks one named behaviour
- Fixtures share setup with deliberate scope and cleanup
- External services are replaced at your own boundary
- Data-access code is tested against a real database engine
- Coverage guides attention to critical untested paths
Share or cite this guide
Innovate with Ajay Editorial (2026-10-07). Testing a Python Project with pytest: Fixtures, Mocks and Coverage That Matters. https://innovatewithajay.com/articles/testing-a-python-project-with-pytest
