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
| Mistake | Why it causes trouble | Better approach |
|---|---|---|
| Testing only the happy path | Boundary failures remain hidden | Add invalid and edge cases |
| One enormous test | Failure location is unclear | Keep tests focused |
| Sharing mutable fixtures unintentionally | Tests influence one another | Use appropriate fixture scope and fresh data |
| Calling live services in unit tests | Tests become flaky and slow | Mock or inject dependencies |
| Testing private implementation details | Refactors break tests unnecessarily | Assert public behaviour |
| Ignoring failing tests | Regressions accumulate | Fix or deliberately quarantine with ownership |
| Relying only on unit tests | Integration problems remain | Add 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.