Unit testing is the backbone of reliable software, and pytest has become the go‑to framework for Python developers who want a clean, powerful, and extensible way to verify their code. In this guide we’ll walk through everything you need to start writing solid unit tests with pytest, from setting up your environment to integrating tests into a continuous‑integration pipeline. By the end you’ll have a reusable test suite, a deeper understanding of fixtures and parametrization, and a clear picture of common pitfalls to avoid.
What You’ll Need
- Python 3.7 or newer installed
- pip (Python package manager)
- A code editor or IDE (VS Code, PyCharm, etc.)
- Basic knowledge of Python functions and modules
- Optional: a Git repository and CI service (GitHub Actions, GitLab CI)
Step 1: Install pytest and Set Up a Virtual Environment
First, create an isolated virtual environment so your test dependencies don’t clash with other projects. Open a terminal and run:
python -m venv .venv
Activate the environment:
# Windows
.venvScriptsactivate
# macOS/Linux
source .venv/bin/activate
With the environment active, install pytest:
pip install pytest
Verify the installation by checking the version:
pytest --version
You should see something like pytest 7.x.x. Keeping pytest updated ensures you have the latest bug fixes and features.
Step 2: Create Your First Test File
pytest discovers test files that match the pattern test_*.py or *_test.py. In your project root, create a folder called tests and add a file named test_example.py:
# tests/test_example.py
def add(a, b):
return a + b
This simple function will serve as the subject of our first test. By placing the function in the same file, you can focus on the testing workflow without worrying about import paths.
Step 3: Write Simple Test Functions
Now add a test function that asserts the expected behavior. pytest recognises any function whose name starts with test_ as a test case.
# tests/test_example.py
def add(a, b):
return a + b
def test_add_positive_numbers():
assert add(2, 3) == 5
def test_add_negative_numbers():
assert add(-1, -4) == -5
Run the suite with a single command:
pytest -q
The -q flag makes the output concise. You should see something like:
..
2 passed in 0.03s
Two dots mean two tests passed. If a test fails, pytest will show a detailed traceback and the exact assertion that failed.
Step 4: Use Fixtures for Setup and Teardown
Real‑world code often needs resources such as database connections, temporary files, or mock objects. pytest fixtures let you define reusable setup code that is automatically injected into test functions.
# tests/conftest.py
import pytest
@pytest.fixture
def sample_list():
# Setup
lst = [1, 2, 3]
yield lst
# Teardown (optional)
lst.clear()
Now any test that accepts sample_list as an argument receives the prepared list:
# tests/test_example.py
def test_append(sample_list):
sample_list.append(4)
assert sample_list == [1, 2, 3, 4]
Fixtures can be scoped to function, class, module, or session level, giving you fine‑grained control over how often the setup runs.
Step 5: Parameterize Tests to Reduce Duplication
When you need to run the same logic with multiple inputs, @pytest.mark.parametrize eliminates repetitive code.
# tests/test_example.py
import pytest
@pytest.mark.parametrize(
"a,b,expected",
[
(1, 2, 3),
(0, 0, 0),
(-1, 5, 4),
(100, 200, 300),
]
)
def test_add_multiple(a, b, expected):
assert add(a, b) == expected
Running pytest -q now yields four passing tests, each represented by a separate line in the output when you omit -q. Parameterization keeps your test suite DRY and makes it easier to spot edge‑case failures.
Step 6: Run Tests, Generate Reports, and Debug Failures
Beyond the quiet mode, pytest offers rich output options. Use -v for verbose test names, --maxfail=2 to stop after two failures, and --tb=short for concise tracebacks:
pytest -v --maxfail=2 --tb=short
For continuous integration or local analysis you might want an HTML report. Install the plugin and generate the report with a single flag:
pip install pytest-html
pytest --html=report.html
The resulting report.html file contains a searchable summary, making it simple to share results with non‑technical stakeholders.
Step 7: Integrate pytest into a CI Pipeline
Automating test execution ensures every push is validated. Below is a minimal GitHub Actions workflow (.github/workflows/python-test.yml) that installs dependencies, runs pytest, and uploads the HTML report as an artifact.
name: Python Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
pip install pytest pytest-html
- name: Run tests
run: |
source .venv/bin/activate
pytest --html=report.html
- name: Upload report
uses: actions/upload-artifact@v3
with:
name: pytest-report
path: report.html
Every time code is pushed, GitHub will execute the workflow, and you’ll receive immediate feedback if a test breaks.
Common Mistakes to Avoid
1 Naming errors: pytest only discovers files and functions that follow its naming conventions. A typo like Test_example.py (capital “T”) will be ignored.
2 Testing implementation details: Focus on observable behavior, not on private variables or internal algorithmic steps. Over‑specifying makes refactoring painful.
3 Hard‑coding paths: Use fixtures or tmp_path to create temporary files instead of absolute paths that break on other machines.
4 Missing assertions: A test that runs code but never asserts anything always passes, giving a false sense of security.
5 Ignoring test isolation: Shared mutable state between tests leads to flaky results. Always reset state in fixtures or use the function scope.
Tips and Tricks
• Mark slow tests: Use @pytest.mark.slow and run them only when needed with pytest -m "not slow".
• Leverage built‑in fixtures: tmp_path for temporary directories, capsys for capturing stdout/stderr, and monkeypatch for safely modifying environment variables.
• Run a single test: Use the :: syntax, e.g., pytest tests/test_example.py::test_add_positive_numbers.
• Combine with coverage: Install pytest-cov and generate a coverage report: pytest --cov=your_package --cov-report=html.
• Use parametric fixtures: When a fixture needs multiple configurations, combine @pytest.fixture(params=[...]) with the fixture value.
Frequently Asked Questions
Do I need to write tests for every function?
Not necessarily. Prioritize critical logic, public APIs, and areas prone to bugs. Aim for high‑risk coverage rather than 100% line coverage; a well‑chosen subset often catches most defects.
Can pytest test asynchronous code?
Yes. Install pytest-asyncio and mark async test functions with @pytest.mark.asyncio. The framework will handle the event loop for you.
How do I mock external services?
Use the unittest.mock library or the pytest-mock plugin. Replace network calls with Mock objects, configure return values, and assert that calls were made as expected.
Conclusion
Writing unit tests with pytest becomes second nature once you master fixtures, parametrization, and the rich reporting options the framework provides. By following the steps above, setting up a CI pipeline, and avoiding common pitfalls, you’ll deliver more reliable Python code and gain confidence when refactoring or adding new features. Keep experimenting with plugins, keep your tests readable, and let pytest do the heavy lifting so you can focus on building great software.





