Getting Started with pytest
Writing tests is one of the most important skills for any Python developer. Tests verify that your code works correctly, catch bugs before they reach production, and give you confidence when refactoring. pytest is the most popular testing framework in Python—it’s simple, powerful, and has a great ecosystem.
This guide walks you through writing your first tests with pytest.
Installing pytest
Install pytest with pip:
pip install pytest
Verify the installation:
pytest --version
# output: pytest 8.x.x
Your First Test
pytest discovers tests automatically by looking for files named test_*.py or *_test.py. Each test is a function starting with test_.
Create a file called test_example.py:
def add(a, b):
return a + b
def test_add_two_numbers():
result = add(2, 3)
assert result == 5
def test_add_negative_numbers():
result = add(-1, -1)
assert result == -2
Run the tests:
pytest test_example.py
# output: = test session starts =
# output: collected 2 items
# output: test_example.py::test_add_two_numbers PASSED
# output: test_example.py::test_add_negative_numbers PASSED
The assert statement checks that a condition is true. If the assertion fails, pytest shows you exactly what went wrong.
How pytest Discovers Tests
pytest follows specific naming conventions:
- Files:
test_*.pyor*_test.py - Functions:
test_* - Classes:
Test*
This convention lets pytest find your tests without any configuration. You can customize this with pytest.ini or pyproject.toml if needed.
Assertions
Assertions are the heart of testing. pytest extends Python’s built-in assert to provide detailed failure messages:
def test_list_operations():
fruits = ["apple", "banana", "cherry"]
# Basic assertion
assert len(fruits) == 3
# Membership test
assert "banana" in fruits
# Equality
assert fruits[0] == "apple"
# Boolean
assert not fruits == []
Assertion Helpers
pytest provides helpful assertion introspection. When you write:
assert actual == expected
And it fails, pytest shows both values:
E AssertionError: assert 5 == 6
E + where 5 = add(2, 3)
E + where 6 = 3 + 3
This makes debugging much easier than a generic assertion error.
Test Driven Development
Test-driven development (TDD) flips the traditional workflow: write the test first, then write the code that makes it pass.
TDD follows a simple cycle:
- Write a failing test
- Write the minimum code to make it pass
- Refactor if needed
Here’s an example:
# test_calculator.py
def test_divide_by_zero_returns_infinity():
result = divide(10, 0)
assert result == float('inf')
Running this test fails because divide doesn’t exist yet:
E NameError: name 'divide' is not defined
Now implement the function:
# calculator.py
def divide(a, b):
if b == 0:
return float('inf')
return a / b
Run the test again—it passes. This workflow ensures your code is always tested.
Fixtures
Fixtures provide reusable test data and setup code. They run before each test that uses them:
import pytest
@pytest.fixture
def user():
return {"name": "Alice", "email": "alice@example.com"}
def test_user_name(user):
assert user["name"] == "Alice"
def test_user_email(user):
assert user["email"] == "alice@example.com"
The user fixture runs once and the same instance is shared between tests. This keeps tests fast and organized.
Fixtures with Setup
Use fixtures to handle expensive setup:
@pytest.fixture
def database():
db = Database.connect("test.db")
db.seed() # Insert test data
yield db # Provide to tests
db.cleanup() # Cleanup after tests
The yield keyword splits the fixture into setup and teardown. Code before yield runs before the test; code after yield runs after.
Fixture Scope
Control how often fixtures run with scope:
@pytest.fixture(scope="session")
def database():
"""Created once per test session."""
return Database.connect("test.db")
@pytest.fixture(scope="function")
def user():
"""Created for each test function."""
return User()
Common scopes:
function(default): runs for each testclass: runs once per test classsession: runs once per test session
Parametrized Tests
Run the same test with different inputs using @pytest.mark.parametrize:
@pytest.mark.parametrize("a,b,expected", [
(2, 3, 5),
(-1, 1, 0),
(0, 0, 0),
(100, 200, 300),
])
def test_add(a, b, expected):
assert add(a, b) == expected
This creates four separate tests from one function. Parametrization is powerful for testing edge cases without duplicating code.
Test Organization
Group related tests in classes:
class TestMathOperations:
def test_add(self):
assert add(1, 2) == 3
def test_subtract(self):
assert subtract(5, 3) == 2
def test_multiply(self):
assert multiply(4, 5) == 20
class TestStringOperations:
def test_uppercase(self):
assert "hello".upper() == "HELLO"
Classes help organize tests and can share fixtures with class scope.
Running Tests
Common pytest commands:
# Run all tests
pytest
# Run specific file
pytest test_math.py
# Run specific test function
pytest test_math.py::TestMathOperations::test_add
# Run tests matching a pattern
pytest -k "test_add"
# Show extra test summary info
pytest -v
# Stop on first failure
pytest -x
# Run tests in a specific directory
pytest tests/unit/
Common pytest Options
| Option | Description |
|---|---|
-v | Verbose output |
-x | Stop after first failure |
-k | Run tests matching expression |
--tb | Traceback format (short/long/line) |
--lf | Run only tests that failed last time |
--cov | Run with coverage (requires pytest-cov) |
Marking Tests
Use markers to categorize tests:
@pytest.mark.slow
def test_large_dataset():
# Takes several seconds
pass
@pytest.mark.integration
def test_database_connection():
# Requires database
pass
Run specific markers:
pytest -m "not slow"
pytest -m "integration"
Built-in markers include @pytest.mark.skip, @pytest.mark.xfail, and @pytest.mark.parametrize.
Best Practices
One Assertion Per Test (Mostly)
It’s not a hard rule, but testing one thing per test makes debugging easier:
# Good - focused test
def test_user_name_is_required():
with pytest.raises(ValueError):
User(name=None)
Use Descriptive Names
Test names should describe what they verify:
# Good
def test_division_by_zero_returns_infinity():
# Bad
def test_div():
Keep Tests Independent
Each test should work on its own:
# Good - creates fresh data
@pytest.fixture
def user():
return User(name="Alice")
def test_user_name(user):
assert user.name == "Alice"
Test Edge Cases
Don’t just test the happy path:
def test_add_handles_empty_list():
assert add([]) == 0
def test_add_handles_single_item():
assert add([5]) == 5
Conclusion
pytest makes testing accessible and even enjoyable. Start with simple assertions, use fixtures for reusable setup, and leverage parametrization for thorough test coverage. The pytest ecosystem includes plugins for coverage reporting, mocking, and async testing—extending its power as your needs grow.
Remember: tests are code too. Keep them clean, organized, and focused.
See Also
- functools-module — Higher-order functions and utilities like lru_cache
- contextlib-module — Context manager utilities for testing
- error-handling — Handling exceptions and errors in Python