Testing Your Code with pytest
Writing tests is a crucial part of building reliable software. pytest is the most popular testing framework in Python. It makes writing tests simple and readable. This guide covers everything you need to write effective tests for your Python code.
Why Use pytest?
pytest discovers tests automatically. You just write functions that start with test_. The framework runs them and reports which ones pass or fail.
pytest is different from unittest. You do not need to extend any classes. You write plain Python functions. This makes tests easier to read and maintain.
# A simple pytest test
def test_addition():
assert 1 + 1 == 2
Run this with pytest in your terminal.
Installing pytest
Install pytest with pip.
pip install pytest
Verify the installation.
pytest --version
Writing Your First Test
Create a file named test_example.py. pytest finds files that start with test_.
def test_addition():
result = 2 + 2
assert result == 4
def test_string():
text = "hello"
assert text.upper() == "HELLO"
Run the tests.
pytest test_example.py
pytest finds and runs all functions starting with test_. Each function that runs without raising an AssertionError passes.
Assertions
Assertions are checks that verify your code works. pytest uses Python’s built-in assert statement. When an assertion fails, pytest shows you exactly what went wrong.
def test_divide():
result = 10 / 2
assert result == 5
def test_list_operations():
numbers = [1, 2, 3, 4, 5]
assert len(numbers) == 5
assert sum(numbers) == 15
assert max(numbers) == 5
You can also add custom error messages to assertions.
def test_custom_message():
x = 1
y = 2
assert x == y, f"Expected {x} to equal {y}"
Fixtures
Fixtures provide setup and teardown for tests. They share resources across multiple tests. Use the @pytest.fixture decorator to create fixtures.
import pytest
@pytest.fixture
def database():
# Setup: create a test database
db = create_test_database()
yield db
# Teardown: clean up after tests
db.close()
def test_user_creation(database):
user = database.create_user("alice")
assert user.name == "alice"
def test_user_count(database):
count = database.count_users()
assert count > 0
The yield keyword separates setup from teardown. Code before yield runs before each test. Code after yield runs after each test.
Fixtures can also accept other fixtures as parameters. This creates a dependency chain for complex test scenarios.
@pytest.fixture
def app():
application = create_app()
return application
@pytest.fixture
def client(app):
return app.test_client()
def test_homepage(client):
response = client.get("/")
assert response.status_code == 200
Parametrized Tests
Parametrization lets you run the same test with different inputs. Use @pytest.mark.parametrize to avoid duplicating test code.
import pytest
@pytest.mark.parametrize("input,expected", [
(1, 2),
(2, 4),
(3, 6),
(10, 20),
])
def test_double(input, expected):
assert input * 2 == expected
@pytest.mark.parametrize("word,uppercase", [
("hello", "HELLO"),
("world", "WORLD"),
("pytest", "PYTEST"),
])
def test_uppercase(word, uppercase):
assert word.upper() == uppercase
You can also combine multiple parametrizations.
@pytest.mark.parametrize("a,b,result", [
(1, 1, 2),
(2, 3, 5),
(10, -5, 5),
])
def test_addition(a, b, result):
assert a + b == result
Mocking
Mocking replaces real objects with fake ones during testing. This is useful for testing code that depends on external services, databases, or APIs. Use unittest.mock or the pytest-mock plugin.
from unittest.mock import Mock, patch
import pytest
def test_api_call():
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"data": "test"}
with patch("requests.get", return_value=mock_response):
result = fetch_data()
assert result == {"data": "test"}
The patch decorator temporarily replaces the specified function with a mock. This lets you control what the function returns without making actual HTTP requests.
@pytest.fixture
def mock_database():
mock = Mock()
mock.get_user.return_value = {"id": 1, "name": "Alice"}
return mock
def test_get_user(mock_database):
user = get_user(1, database=mock_database)
assert user["name"] == "Alice"
mock_database.get_user.assert_called_once_with(1)
You can also mock entire modules.
@patch("module.random.randint", return_value=42)
def test_random(mock_rand):
result = roll_dice()
assert result == 42
Running Tests
Here are some common pytest commands:
# Run all tests
pytest
# Run a specific file
pytest test_example.py
# Run tests matching a pattern
pytest -k "test_addition"
# Show detailed output
pytest -v
# Stop after first failure
pytest -x
# Show print statements
pytest -s
# Run tests in a specific class
pytest::TestClass
# Generate coverage report
pytest --cov=myapp
Best Practices
Write descriptive test names. The test name should explain what is being tested.
# Good
def test_user_cannot_login_with_wrong_password():
...
# Bad
def test_login():
...
Keep tests independent. Each test should work on its own without depending on other tests.
Test edge cases. Do not just test the happy path.
def test_divide_by_zero():
with pytest.raises(ZeroDivisionError):
x = 1 / 0
Use pytest.raises to test that exceptions are raised correctly.
Conclusion
pytest makes testing Python code straightforward. Start with simple tests, then add fixtures, parametrization, and mocking as your test suite grows. Consistent testing leads to reliable, maintainable code.