Introduction to PyTest
PyTest is a powerful, flexible testing framework for Python that makes it easy to write small, readable tests while scaling up to support complex functional testing. Unlike Python's built-in unittest module, PyTest offers a more streamlined approach with less boilerplate code and more powerful features.
Real-world analogy: If testing were cooking, PyTest would be like a modern kitchen with ergonomic tools that make both simple and complex dishes easier to prepare, while unittest would be more like a traditional kitchen that requires more manual steps and preparation.
Getting Started with PyTest
Installation
Installing PyTest is simple with pip:
pip install pytest
Or using a virtual environment (recommended):
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
pip install pytest
Creating Your First Test
PyTest makes writing tests incredibly simple. Let's start with a basic example:
# test_sample.py
def add(a, b):
return a + b
def test_add():
assert add(2, 3) == 5
assert add(-1, 1) == 0
assert add(0, 0) == 0
Running Tests
To run the tests, simply use the pytest command:
pytest
PyTest will automatically discover and run all tests in files that start with test_ or end with _test.py.
You can also run specific tests or test files:
pytest test_sample.py # Run tests in a specific file
pytest test_sample.py::test_add # Run a specific test
pytest -k "add" # Run tests with "add" in the name
PyTest Test Structure
PyTest uses a straightforward naming convention to identify tests:
- Test files should be named
test_*.pyor*_test.py - Test functions should be named
test_* - Test classes should be named
Test* - Test methods in classes should be named
test_*
# Basic test function
def test_function():
assert True
# Test class
class TestClass:
def test_method(self):
assert True
def test_another_method(self):
x = "hello"
assert x == "hello"
Real-world example: This structure is similar to how quality assurance teams organize their testing procedures—logical groups of related tests that verify a particular component or behavior.
Understanding Assertions
PyTest leverages Python's built-in assert statement, making assertions easy to write and read:
def test_basic_assertions():
# Simple equality
assert 1 + 1 == 2
# String comparison
assert "hello" == "hello"
# Boolean check
assert True
# Containment check
assert 5 in [1, 2, 3, 4, 5]
# Identity check
x = y = [1, 2, 3]
assert x is y
# Floating point (approximately equal)
assert 0.1 + 0.2 == pytest.approx(0.3)
One of PyTest's strengths is its excellent error reporting. When an assertion fails, PyTest provides detailed information:
# If this assertion fails:
def test_example():
a = 5
b = 6
assert a == b
# PyTest shows:
# E assert 5 == 6
# E +5
# E -6
Advanced Assertions with the Built-in Python Standard Library
import pytest
def test_exceptions():
# Testing exceptions
with pytest.raises(ZeroDivisionError):
1 / 0
# Check exception message
with pytest.raises(ValueError) as exc_info:
raise ValueError("Invalid value")
assert "Invalid value" in str(exc_info.value)
def test_approximate():
# Approximate floating point comparison
assert 0.1 + 0.2 == pytest.approx(0.3)
def test_warning():
# Testing warnings
with pytest.warns(DeprecationWarning):
import warnings
warnings.warn("This is deprecated", DeprecationWarning)
Organizing Tests with Classes
For better organization and when tests share common setup, you can use classes:
class TestCalculator:
def test_addition(self):
assert 1 + 1 == 2
def test_subtraction(self):
assert 3 - 1 == 2
def test_multiplication(self):
assert 2 * 3 == 6
def test_division(self):
assert 6 / 2 == 3
Unlike unittest, PyTest doesn't require classes to inherit from a specific base class. This makes tests cleaner and more maintainable.
Using Setup and Teardown
class TestDatabaseOperations:
def setup_method(self):
# This runs before each test method
self.connection = create_db_connection()
self.db = initialize_test_database(self.connection)
def teardown_method(self):
# This runs after each test method
self.db.cleanup()
self.connection.close()
def test_insert_record(self):
self.db.insert({"name": "John", "age": 30})
result = self.db.find({"name": "John"})
assert result["age"] == 30
def test_delete_record(self):
self.db.insert({"name": "Alice", "age": 25})
self.db.delete({"name": "Alice"})
result = self.db.find({"name": "Alice"})
assert result is None
Fixtures: A Powerful Approach to Test Setup
Fixtures are one of PyTest's most powerful features. They provide a way to set up preconditions for tests:
import pytest
@pytest.fixture
def sample_data():
"""Provides sample data for tests."""
return {"name": "Test User", "email": "test@example.com", "age": 30}
def test_user_name(sample_data):
assert sample_data["name"] == "Test User"
def test_user_email(sample_data):
assert "example.com" in sample_data["email"]
Real-world analogy: Fixtures are like prep chefs in a kitchen. They prepare and provide the ingredients (test data, objects, connections) so that the head chef (your test) can focus on the actual cooking (testing logic) without worrying about preparation.
Fixture Scopes
Fixtures can have different scopes, determining how often they are created:
@pytest.fixture(scope="function")
def function_fixture():
# Default: Created for each test function
return {}
@pytest.fixture(scope="class")
def class_fixture():
# Created once per test class
return {}
@pytest.fixture(scope="module")
def module_fixture():
# Created once per module
return {}
@pytest.fixture(scope="session")
def session_fixture():
# Created once per test session
return {}
Fixture Setup and Teardown
@pytest.fixture
def database_connection():
# Setup phase
connection = create_connection()
initialize_db(connection)
# Provide the fixture value
yield connection
# Teardown phase (runs after the test is complete)
connection.rollback() # Revert any changes
connection.close() # Close the connection
def test_database_operations(database_connection):
# The test uses the connection
database_connection.execute("INSERT INTO users VALUES ('test', 'user')")
result = database_connection.query("SELECT * FROM users WHERE username='test'")
assert result is not None
Fixture Dependencies
@pytest.fixture
def user():
return {"username": "testuser", "email": "test@example.com"}
@pytest.fixture
def authenticated_user(user):
# This fixture depends on the 'user' fixture
user['authenticated'] = True
return user
def test_authentication(authenticated_user):
assert authenticated_user["authenticated"] is True
assert authenticated_user["username"] == "testuser"
Parameterizing Tests
To test a function with multiple sets of inputs and expected outputs, you can use parameterization:
import pytest
def add(a, b):
return a + b
@pytest.mark.parametrize("a, b, expected", [
(1, 2, 3),
(0, 0, 0),
(-1, 1, 0),
(10, -5, 5),
])
def test_add(a, b, expected):
assert add(a, b) == expected
This concise approach creates four separate tests, one for each set of parameters. This is much cleaner than writing four separate test functions or using loops.
Parameterizing with Multiple Arguments
@pytest.mark.parametrize("username", ["user1", "user2", "admin"])
@pytest.mark.parametrize("role", ["viewer", "editor", "admin"])
def test_user_access(username, role):
# This will run 9 tests (3x3 combinations)
user = create_user(username, role)
assert user.has_role(role)
Parameterizing with Custom IDs
@pytest.mark.parametrize(
"input_value, expected",
[
(2, True),
(3, True),
(4, False),
(16, False)
],
ids=["small_prime", "medium_prime", "composite", "larger_composite"]
)
def test_is_prime(input_value, expected):
assert is_prime(input_value) == expected
Real-world example: At Spotify, they use parameterized tests extensively for their recommendation algorithms, testing with various user profiles and music preferences to ensure recommendations are appropriate across different scenarios.
Practical Example: Testing a User Management System
Let's apply PyTest to a more complete example - a user management system:
# user.py
class User:
def __init__(self, username, email, role="user"):
if not username or not email:
raise ValueError("Username and email are required")
self.username = username
self.email = email
self.role = role
self.is_active = True
def deactivate(self):
self.is_active = False
def activate(self):
self.is_active = True
def promote(self, new_role):
allowed_roles = ["user", "editor", "admin"]
if new_role not in allowed_roles:
raise ValueError(f"Role must be one of: {', '.join(allowed_roles)}")
self.role = new_role
def can_edit_content(self):
return self.is_active and self.role in ["editor", "admin"]
def can_manage_users(self):
return self.is_active and self.role == "admin"
# user_repository.py
class UserRepository:
def __init__(self):
self.users = {}
def add_user(self, user):
if user.username in self.users:
raise ValueError(f"User {user.username} already exists")
self.users[user.username] = user
return user
def get_user(self, username):
return self.users.get(username)
def delete_user(self, username):
if username in self.users:
del self.users[username]
return True
return False
def get_all_users(self):
return list(self.users.values())
Now let's write tests for these classes:
# test_user.py
import pytest
from user import User
class TestUser:
def test_user_creation(self):
user = User("johndoe", "john@example.com")
assert user.username == "johndoe"
assert user.email == "john@example.com"
assert user.role == "user" # Default role
assert user.is_active is True
def test_user_creation_validation(self):
with pytest.raises(ValueError):
User("", "john@example.com")
with pytest.raises(ValueError):
User("johndoe", "")
def test_user_deactivation(self):
user = User("johndoe", "john@example.com")
user.deactivate()
assert user.is_active is False
def test_user_activation(self):
user = User("johndoe", "john@example.com")
user.deactivate()
user.activate()
assert user.is_active is True
def test_user_promotion(self):
user = User("johndoe", "john@example.com")
user.promote("editor")
assert user.role == "editor"
user.promote("admin")
assert user.role == "admin"
def test_user_promotion_validation(self):
user = User("johndoe", "john@example.com")
with pytest.raises(ValueError):
user.promote("superuser") # Invalid role
@pytest.mark.parametrize("role, is_active, can_edit", [
("user", True, False),
("editor", True, True),
("admin", True, True),
("editor", False, False), # Inactive users can't edit
])
def test_can_edit_content(self, role, is_active, can_edit):
user = User("johndoe", "john@example.com", role)
if not is_active:
user.deactivate()
assert user.can_edit_content() == can_edit
@pytest.mark.parametrize("role, is_active, can_manage", [
("user", True, False),
("editor", True, False),
("admin", True, True),
("admin", False, False), # Inactive admin can't manage
])
def test_can_manage_users(self, role, is_active, can_manage):
user = User("johndoe", "john@example.com", role)
if not is_active:
user.deactivate()
assert user.can_manage_users() == can_manage
# test_user_repository.py
import pytest
from user import User
from user_repository import UserRepository
@pytest.fixture
def repository():
return UserRepository()
@pytest.fixture
def sample_user():
return User("johndoe", "john@example.com")
class TestUserRepository:
def test_add_user(self, repository, sample_user):
added_user = repository.add_user(sample_user)
assert added_user == sample_user
assert repository.get_user("johndoe") == sample_user
def test_add_duplicate_user(self, repository, sample_user):
repository.add_user(sample_user)
with pytest.raises(ValueError):
repository.add_user(sample_user) # Can't add duplicate
def test_get_user(self, repository, sample_user):
repository.add_user(sample_user)
user = repository.get_user("johndoe")
assert user == sample_user
# Non-existent user
user = repository.get_user("nonexistent")
assert user is None
def test_delete_user(self, repository, sample_user):
repository.add_user(sample_user)
result = repository.delete_user("johndoe")
assert result is True
assert repository.get_user("johndoe") is None
# Deleting non-existent user
result = repository.delete_user("nonexistent")
assert result is False
def test_get_all_users(self, repository):
user1 = User("user1", "user1@example.com")
user2 = User("user2", "user2@example.com")
repository.add_user(user1)
repository.add_user(user2)
all_users = repository.get_all_users()
assert len(all_users) == 2
assert user1 in all_users
assert user2 in all_users
Test Coverage with PyTest
PyTest can be integrated with the coverage.py package to measure code coverage:
pip install pytest-cov
You can then run your tests with coverage reporting:
pytest --cov=my_module
pytest --cov=my_module --cov-report=html # Generates HTML report
Real-world example: At Dropbox, they maintain high code coverage thresholds for their Python codebase, especially for critical components like authentication and file synchronization. Any new code without sufficient test coverage is flagged during code review.
Running and Selecting Tests
Selecting Tests by Name
# Run tests with "user" in the name
pytest -k user
# Run tests that have "user" but not "repository" in the name
pytest -k "user and not repository"
Selecting Tests by Markers
# Mark tests
@pytest.mark.slow
def test_slow_operation():
# This is a slow test
...
# Run only slow tests
pytest -m slow
# Run all tests except slow ones
pytest -m "not slow"
Common Command Line Options
pytest -v # Verbose output
pytest -q # Quiet output
pytest --collect-only # Only show which tests would be run
pytest -xvs # Exit on first failure, verbose, no capture
pytest --maxfail=2 # Stop after 2 failures
pytest --durations=3 # Show 3 slowest tests
pytest --durations=0 # Show all test durations
PyTest Plugins
PyTest has a rich ecosystem of plugins that extend its functionality:
- pytest-cov: Code coverage reporting
- pytest-mock: Thin-wrapper around the unittest.mock module
- pytest-django: Django integration for PyTest
- pytest-flask: Flask integration for PyTest
- pytest-xdist: Run tests in parallel
- pytest-benchmark: Benchmark your code
- pytest-timeout: Add timeouts to tests
- pytest-asyncio: Testing async code
Example: Using pytest-mock
def test_with_mock(mocker):
# Create a mock object
mock_requests = mocker.patch('requests.get')
# Set return value
mock_requests.return_value.status_code = 200
mock_requests.return_value.json.return_value = {'key': 'value'}
# Call function that uses requests.get
result = get_external_data()
# Assertions
assert mock_requests.called
assert result == {'key': 'value'}
Example: Using pytest-benchmark
def test_performance(benchmark):
# Benchmark a function call
result = benchmark(my_function, arg1, arg2)
# You can still make assertions on the result
assert result == expected_value
Best Practices for PyTest
Best Practices)) Test Structure Keep tests small and focused Use descriptive names Group related tests in classes Fixtures Reuse fixtures when appropriate Use proper scope Keep fixtures simple Assertions Use built-in assert Prefer multiple specific assertions Write clear failure messages Organization Follow naming conventions Separate unit and integration tests Maintain test independence
Key Best Practices
- Write descriptive test names - They serve as documentation
- Keep tests independent - Tests should not depend on each other
- Use fixtures efficiently - Right scope and reuse when appropriate
- Write focused tests - Test one thing per test function
- Follow naming conventions - Consistent naming makes tests discoverable
- Balance test coverage - Aim for good coverage without over-testing
- Use parameterization - For testing multiple similar cases
- Separate test types - Keep unit tests, integration tests, etc. separate
Real-world example: At Instagram, their Python codebase follows strict testing standards, including detailed naming conventions for tests. This makes it easy for any engineer to understand what a test is verifying and why it might be failing, which is crucial for a large team working on a complex codebase.
Practical Exercise
Exercise: Testing a Calculator Library
Let's practice by writing tests for a calculator library:
# calculator.py
class Calculator:
def add(self, a, b):
return a + b
def subtract(self, a, b):
return a - b
def multiply(self, a, b):
return a * b
def divide(self, a, b):
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
def power(self, a, b):
return a ** b
def square_root(self, a):
if a < 0:
raise ValueError("Cannot compute square root of negative number")
return a ** 0.5
Your task:
- Write a comprehensive test suite for the Calculator class
- Use fixtures to create a calculator instance
- Use parameterized tests for operations that need multiple test cases
- Test exception handling for divide and square_root methods
- Use markers to identify slow tests (like power with large exponents)
Summary
- PyTest is a powerful, flexible testing framework for Python
- It uses simple function-based tests with minimal boilerplate
- Fixtures provide a clean way to set up test preconditions
- Parameterization allows testing multiple inputs efficiently
- Assertions use Python's built-in assert statement with enhanced reporting
- Markers help categorize and select tests
- A rich ecosystem of plugins extends PyTest's functionality
- PyTest encourages readable, maintainable tests that serve as documentation
Remember: Good tests not only verify that your code works but also document how it should be used and behave under different conditions.
Assignment
Create a complete test suite for a simplified e-commerce inventory system:
- Create
product.pywith a Product class that has:- Properties: id, name, price, quantity
- Methods: is_in_stock(), apply_discount(percent), update_quantity(change)
- Create
inventory.pywith an Inventory class that:- Manages a collection of products
- Allows adding, removing, and finding products
- Has methods for filtering products (by price range, in stock, etc.)
- Calculates total inventory value
- Write a comprehensive test suite using PyTest that:
- Tests all functionality of both classes
- Uses fixtures effectively
- Implements parameterized tests where appropriate
- Tests edge cases and error conditions
- Achieves good code coverage
Bonus challenge: Add a shopping cart functionality that interacts with the inventory and test it thoroughly.