Python Testing Best Practices with pytest: Structure, Fixtures, Parametrize, Mocking, and CI
Quick Answer
Use pytest with a clear tests/ folder, small focused tests, reusable fixtures, and one command that runs the full suite.
Recommended layout:
project/
src/
my_app/
calculator.py
parser.py
tests/
test_calculator.py
test_parser.py
pyproject.tomlRun tests:
pytestLast verified: June 26, 2026.
Official Links
- pytest docs: https://docs.pytest.org/
- pytest good practices: https://docs.pytest.org/en/stable/explanation/goodpractices.html
- unittest.mock: https://docs.python.org/3/library/unittest.mock.html
- tempfile: https://docs.python.org/3/library/tempfile.html
What You Will Learn
By the end, you should be able to:
- create a clean
tests/folder - name test files and test functions correctly
- write simple pytest assertions
- test success and failure cases
- use fixtures
- use
parametrize - test temporary files
- mock environment variables
- mock slow or external services
- add tests to CI
1. Why Tests Matter
Tests protect behavior.
They help you answer:
- Does the code still work after a change?
- Did I break an edge case?
- Can another developer refactor safely?
- Can CI catch problems before deployment?
Tests are not only for large companies. They are useful for portfolio projects, scripts, CLIs, APIs, and data pipelines.
2. Install pytest
python -m pip install pytestIf you use pyproject.toml, add pytest as a development dependency:
[project.optional-dependencies]
dev = [
"pytest>=8.0",
]Install:
python -m pip install -e ".[dev]"3. Use A Dedicated Tests Folder
Good:
tests/
test_prices.py
test_parsing.py
test_config.pyAvoid mixing tests into random folders.
Pytest discovers:
- files named
test_*.py - functions named
test_*
Example:
def test_total_price_includes_tax():
assert 118 == 100 + 184. Keep Tests Small
Small tests are easier to understand.
Weak test:
def test_everything():
# creates data
# parses data
# saves file
# calls API
# checks output
...Better:
def test_parse_price_removes_currency_symbol():
assert parse_price("Rs.1,999") == 1999One test should usually verify one behavior.
5. Test Pure Functions First
Pure functions are easiest to test.
Application code:
def discount_pct(actual_price: float, discounted_price: float) -> float:
if actual_price <= 0:
raise ValueError("actual_price must be positive")
return round((actual_price - discounted_price) / actual_price * 100, 1)Test:
def test_discount_pct():
assert discount_pct(1000, 750) == 25.0Start testing with calculation, parsing, validation, and transformation logic.
6. Test Error Cases
Use pytest.raises.
import pytest
def test_discount_pct_rejects_zero_actual_price():
with pytest.raises(ValueError, match="actual_price"):
discount_pct(0, 100)Error tests prevent silent bad data.
7. Use Parametrize For Similar Cases
Instead of repeating similar tests:
import pytest
@pytest.mark.parametrize(
"raw, expected",
[
("Rs.1,999", 1999),
("1999", 1999),
("1,999", 1999),
],
)
def test_parse_price(raw, expected):
assert parse_price(raw) == expectedParametrized tests are concise and readable.
8. Use Fixtures For Reusable Setup
Fixtures prepare reusable test data.
import pytest
@pytest.fixture
def sample_order():
return {
"price": 1000,
"discounted_price": 750,
"customer": "Aarav",
}
def test_order_discount(sample_order):
assert discount_pct(sample_order["price"], sample_order["discounted_price"]) == 25.0Use fixtures for:
- sample dictionaries
- temporary databases
- test clients
- fake API responses
- shared setup
Do not hide important test logic inside overly complex fixtures.
9. Test Files With tmp_path
Use pytest's tmp_path for temporary files.
def save_report(path, text):
path.write_text(text, encoding="utf-8")
def test_save_report(tmp_path):
report_path = tmp_path / "report.txt"
save_report(report_path, "hello")
assert report_path.read_text(encoding="utf-8") == "hello"Never write tests that depend on your desktop path or real downloads folder.
10. Test Environment Variables With monkeypatch
Use monkeypatch.
import os
def load_api_url():
return os.environ.get("API_URL", "http://localhost:8000")
def test_load_api_url_from_env(monkeypatch):
monkeypatch.setenv("API_URL", "https://example.com")
assert load_api_url() == "https://example.com"This keeps tests isolated.
11. Mock External Calls
Do not call real APIs in unit tests.
Application code:
def fetch_user(http_client, user_id: int) -> dict:
response = http_client.get(f"/users/{user_id}")
return response.json()Test:
from unittest.mock import Mock
def test_fetch_user():
response = Mock()
response.json.return_value = {"id": 1, "name": "Aarav"}
http_client = Mock()
http_client.get.return_value = response
assert fetch_user(http_client, 1)["name"] == "Aarav"
http_client.get.assert_called_once_with("/users/1")Mock network, payment, email, and slow services.
12. Test Classes By Behavior
Do not test private implementation details unless necessary.
class Cart:
def __init__(self):
self.items = []
def add(self, price: float):
self.items.append(price)
def total(self) -> float:
return sum(self.items)Test:
def test_cart_total():
cart = Cart()
cart.add(100)
cart.add(50)
assert cart.total() == 150Focus on public behavior.
13. Separate Unit And Integration Tests
Unit tests:
- fast
- isolated
- no real network
- no real external services
Integration tests:
- test multiple parts together
- may use a test database
- may be slower
- should be clearly marked
Example marker:
import pytest
@pytest.mark.integration
def test_database_insert_and_read():
...Configure markers:
[tool.pytest.ini_options]
markers = [
"integration: tests that touch external systems or multiple layers",
]14. Avoid Flaky Tests
Flaky tests pass sometimes and fail sometimes.
Common causes:
- real network calls
- current time
- random values
- shared global state
- test order dependency
- sleeping instead of waiting for a condition
Make tests deterministic.
If code uses randomness, inject a seed or pass values directly.
15. Use Coverage Carefully
Install:
python -m pip install pytest-covRun:
pytest --cov=src --cov-report=term-missingCoverage is useful, but high coverage does not guarantee good tests.
Focus on important behavior and edge cases.
16. Add pytest Configuration
In pyproject.toml:
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q"This makes test discovery predictable.
17. Add Tests To CI
GitHub Actions example:
name: Python 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: python -m pip install -e ".[dev]"
- run: pytestCI makes tests run the same way for everyone.
18. Testing Checklist
Before sharing a project, confirm:
- tests live in
tests/ - test files start with
test_ - tests run with
pytest - important success cases are covered
- important failure cases are covered
- external APIs are mocked in unit tests
- file tests use
tmp_path - environment tests use
monkeypatch - CI runs tests
19. FAQ
Is pytest better than unittest?
Both work. Pytest is popular because it has simple assertions, fixtures, parametrization, and strong plugin support.
How many tests should a beginner project have?
Start with tests for the most important functions. Even five good tests are useful.
Should I test private functions?
Usually test public behavior. If private logic is complex, consider extracting it into a public helper with a clear purpose.
Should tests call real APIs?
Not in unit tests. Use mocks. Put live API checks behind explicit integration tests.
What should I test first?
Test parsing, validation, calculations, transformations, and error handling first.
Next in this series: Enhancing Python Code Quality with Ruff and Pre-commit Hooks →
