11. Testing & Quality Assurance
11. Testing & Quality Assurance¶
Learning Objectives¶
- Master pytest framework basics and advanced features
- Understand effective test writing patterns
- Use mocking for isolated testing
- Measure and improve code coverage
- Automate testing and CI/CD integration
Table of Contents¶
1. pytest Basics¶
1.1 pytest Installation and Structure¶
# Installation
pip install pytest pytest-cov pytest-mock pytest-asyncio
# Project structure
myproject/
├── src/
│ └── mypackage/
│ ├── __init__.py
│ ├── calculator.py
│ └── utils.py
├── tests/
│ ├── __init__.py
│ ├── conftest.py # Shared fixtures
│ ├── test_calculator.py
│ └── test_utils.py
├── pyproject.toml
└── pytest.ini # Or in pyproject.toml
1.2 Writing Basic Tests¶
# src/mypackage/calculator.py
class Calculator:
def add(self, a: float, b: float) -> float:
return a + b
def divide(self, a: float, b: float) -> float:
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
def is_positive(self, n: float) -> bool:
return n > 0
# tests/test_calculator.py
import pytest
from mypackage.calculator import Calculator
class TestCalculator:
"""Calculator class tests"""
def setup_method(self):
"""Run before each test method"""
self.calc = Calculator()
def test_add_positive_numbers(self):
"""Test adding positive numbers"""
result = self.calc.add(2, 3)
assert result == 5
def test_add_negative_numbers(self):
"""Test adding negative numbers"""
result = self.calc.add(-2, -3)
assert result == -5
def test_divide_normal(self):
"""Normal division"""
result = self.calc.divide(10, 2)
assert result == 5.0
def test_divide_by_zero_raises_error(self):
"""Test division by zero exception"""
with pytest.raises(ValueError, match="Cannot divide by zero"):
self.calc.divide(10, 0)
def test_is_positive_true(self):
assert self.calc.is_positive(5) is True
def test_is_positive_false(self):
assert self.calc.is_positive(-5) is False
1.3 pytest Execution Options¶
# Basic execution
pytest
# Verbose output
pytest -v
# Specific file/directory
pytest tests/test_calculator.py
pytest tests/
# Specific test function
pytest tests/test_calculator.py::TestCalculator::test_add_positive_numbers
# Filter by keyword
pytest -k "add" # Only tests with 'add'
pytest -k "not slow" # Tests without 'slow'
# Stop at first failure
pytest -x
# Rerun last failed tests only
pytest --lf
# Start with failed tests
pytest --ff
# Parallel execution (needs pytest-xdist)
pip install pytest-xdist
pytest -n auto
1.4 Configuration File¶
# pytest.ini
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = -v --tb=short
markers =
slow: marks tests as slow
integration: marks tests as integration tests
# pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
addopts = "-v --tb=short"
markers = [
"slow: marks tests as slow",
"integration: marks tests as integration tests",
]
2. Fixtures¶
2.1 Basic Fixtures¶
# tests/conftest.py
import pytest
from mypackage.calculator import Calculator
from mypackage.database import Database
@pytest.fixture
def calculator():
"""Provide Calculator instance"""
return Calculator()
@pytest.fixture
def sample_data():
"""Sample test data"""
return {
"users": [
{"id": 1, "name": "Alice"},
{"id": 2, "name": "Bob"},
],
"products": [
{"id": 1, "price": 100},
{"id": 2, "price": 200},
]
}
# tests/test_with_fixtures.py
def test_add_with_fixture(calculator):
"""Use fixture as argument"""
assert calculator.add(2, 3) == 5
def test_sample_data(sample_data):
"""Sample data fixture"""
assert len(sample_data["users"]) == 2
2.2 Fixture Scope¶
@pytest.fixture(scope="function") # Default: per test function
def func_fixture():
print("Setup function fixture")
yield "function"
print("Teardown function fixture")
@pytest.fixture(scope="class") # Once per test class
def class_fixture():
print("Setup class fixture")
yield "class"
print("Teardown class fixture")
@pytest.fixture(scope="module") # Once per module
def module_fixture():
print("Setup module fixture")
yield "module"
print("Teardown module fixture")
@pytest.fixture(scope="session") # Once per test session
def session_fixture():
print("Setup session fixture")
yield "session"
print("Teardown session fixture")
2.3 Setup/Teardown Pattern¶
@pytest.fixture
def database():
"""Database connection fixture with cleanup"""
# Setup
db = Database()
db.connect()
db.create_tables()
yield db # Provide to test
# Teardown (runs automatically after test)
db.drop_tables()
db.disconnect()
@pytest.fixture
def temp_file(tmp_path):
"""Create temporary file (uses pytest built-in tmp_path)"""
file_path = tmp_path / "test.txt"
file_path.write_text("test content")
yield file_path
# tmp_path is automatically cleaned up
2.4 Factory Fixtures¶
@pytest.fixture
def user_factory():
"""User object creation factory"""
created_users = []
def _create_user(name: str, age: int = 20):
user = {"name": name, "age": age, "id": len(created_users) + 1}
created_users.append(user)
return user
yield _create_user
# Teardown: clean up all created users
created_users.clear()
def test_with_factory(user_factory):
user1 = user_factory("Alice")
user2 = user_factory("Bob", age=30)
assert user1["name"] == "Alice"
assert user2["age"] == 30
2.5 Fixture Dependencies¶
@pytest.fixture
def config():
return {"db_url": "sqlite:///test.db"}
@pytest.fixture
def database(config): # Depends on config fixture
db = Database(config["db_url"])
db.connect()
yield db
db.disconnect()
@pytest.fixture
def user_service(database): # Depends on database fixture
return UserService(database)
3. Parametrize¶
3.1 Basic Parametrization¶
@pytest.mark.parametrize("a, b, expected", [
(2, 3, 5),
(0, 0, 0),
(-1, 1, 0),
(100, 200, 300),
])
def test_add_parametrized(calculator, a, b, expected):
assert calculator.add(a, b) == expected
@pytest.mark.parametrize("dividend, divisor, expected", [
(10, 2, 5.0),
(9, 3, 3.0),
(7, 2, 3.5),
])
def test_divide_parametrized(calculator, dividend, divisor, expected):
assert calculator.divide(dividend, divisor) == expected
3.2 Specifying IDs¶
@pytest.mark.parametrize("input_val, expected", [
pytest.param(1, True, id="positive"),
pytest.param(0, False, id="zero"),
pytest.param(-1, False, id="negative"),
])
def test_is_positive_with_ids(calculator, input_val, expected):
assert calculator.is_positive(input_val) == expected
3.3 Multiple Parametrization¶
@pytest.mark.parametrize("x", [1, 2])
@pytest.mark.parametrize("y", [10, 20])
def test_multiply(x, y):
"""Creates 4 test cases: (1,10), (1,20), (2,10), (2,20)"""
result = x * y
assert result == x * y
3.4 Exception Test Parametrization¶
@pytest.mark.parametrize("dividend, divisor, error_match", [
(10, 0, "Cannot divide by zero"),
(5, 0, "Cannot divide by zero"),
])
def test_divide_errors(calculator, dividend, divisor, error_match):
with pytest.raises(ValueError, match=error_match):
calculator.divide(dividend, divisor)
4. Mocking¶
4.1 pytest-mock Basics¶
# src/mypackage/weather.py
import requests
class WeatherService:
def __init__(self, api_key: str):
self.api_key = api_key
self.base_url = "https://api.weather.com"
def get_temperature(self, city: str) -> float:
response = requests.get(
f"{self.base_url}/current",
params={"city": city, "key": self.api_key}
)
response.raise_for_status()
return response.json()["temperature"]
# tests/test_weather.py
import pytest
from unittest.mock import Mock, patch
from mypackage.weather import WeatherService
def test_get_temperature_with_mock(mocker):
"""Using mocker fixture (pytest-mock)"""
# Mock setup
mock_response = Mock()
mock_response.json.return_value = {"temperature": 25.5}
mock_response.raise_for_status = Mock()
mocker.patch("mypackage.weather.requests.get", return_value=mock_response)
# Test
service = WeatherService("fake-api-key")
temp = service.get_temperature("Seoul")
assert temp == 25.5
def test_get_temperature_with_patch():
"""Using @patch decorator"""
with patch("mypackage.weather.requests.get") as mock_get:
mock_get.return_value.json.return_value = {"temperature": 30.0}
mock_get.return_value.raise_for_status = Mock()
service = WeatherService("fake-key")
temp = service.get_temperature("Tokyo")
assert temp == 30.0
mock_get.assert_called_once()
4.2 Mock Object Details¶
from unittest.mock import Mock, MagicMock, PropertyMock
def test_mock_methods():
mock = Mock()
# Set return value
mock.method.return_value = 42
assert mock.method() == 42
# side_effect for raising exception
mock.error_method.side_effect = ValueError("Error!")
with pytest.raises(ValueError):
mock.error_method()
# side_effect for sequential returns
mock.sequence.side_effect = [1, 2, 3]
assert mock.sequence() == 1
assert mock.sequence() == 2
assert mock.sequence() == 3
def test_mock_assertions():
mock = Mock()
mock.method("arg1", kwarg="value")
# Call verification
mock.method.assert_called()
mock.method.assert_called_once()
mock.method.assert_called_with("arg1", kwarg="value")
mock.method.assert_called_once_with("arg1", kwarg="value")
# Call count
assert mock.method.call_count == 1
def test_magic_mock():
"""MagicMock: supports magic methods"""
mock = MagicMock()
mock.__len__.return_value = 5
assert len(mock) == 5
mock.__getitem__.return_value = "item"
assert mock[0] == "item"
4.3 Mocking Async Code¶
import pytest
from unittest.mock import AsyncMock
@pytest.mark.asyncio
async def test_async_mock():
# Using AsyncMock
mock_async = AsyncMock(return_value={"data": "value"})
result = await mock_async()
assert result == {"data": "value"}
@pytest.mark.asyncio
async def test_async_service(mocker):
from mypackage.async_service import AsyncDataService
mock_fetch = mocker.patch.object(
AsyncDataService,
"fetch_data",
new_callable=AsyncMock,
return_value={"status": "ok"}
)
service = AsyncDataService()
result = await service.fetch_data()
assert result["status"] == "ok"
mock_fetch.assert_awaited_once()
4.4 Mocking Patterns¶
# Mock environment variables
def test_with_env_var(mocker):
mocker.patch.dict("os.environ", {"API_KEY": "test-key"})
import os
assert os.environ["API_KEY"] == "test-key"
# Mock time
def test_with_frozen_time(mocker):
from datetime import datetime
mock_now = datetime(2024, 1, 15, 12, 0, 0)
mocker.patch("mypackage.service.datetime")
mocker.patch("mypackage.service.datetime.now", return_value=mock_now)
# Mock filesystem
def test_file_read(mocker):
mock_open = mocker.mock_open(read_data="file content")
mocker.patch("builtins.open", mock_open)
with open("dummy.txt") as f:
content = f.read()
assert content == "file content"
5. Coverage¶
5.1 pytest-cov Setup¶
# Installation
pip install pytest-cov
# Run with coverage
pytest --cov=mypackage tests/
# Generate HTML report
pytest --cov=mypackage --cov-report=html tests/
# Terminal output with missing lines
pytest --cov=mypackage --cov-report=term-missing tests/
5.2 Configuration File¶
# .coveragerc
[run]
source = src/mypackage
omit =
*/tests/*
*/__pycache__/*
*/migrations/*
[report]
exclude_lines =
pragma: no cover
def __repr__
raise NotImplementedError
if TYPE_CHECKING:
fail_under = 80
[html]
directory = htmlcov
# pyproject.toml
[tool.coverage.run]
source = ["src/mypackage"]
omit = ["*/tests/*", "*/__pycache__/*"]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"raise NotImplementedError",
]
fail_under = 80
5.3 Excluding from Coverage¶
def debug_function(): # pragma: no cover
"""Debug-only function - exclude from coverage"""
print("Debug info")
def main():
if __name__ == "__main__": # pragma: no cover
run_app()
6. Test Patterns¶
6.1 Arrange-Act-Assert (AAA)¶
def test_user_registration():
# Arrange: setup
user_data = {"name": "Alice", "email": "alice@example.com"}
service = UserService()
# Act: execute
user = service.register(user_data)
# Assert: verify
assert user.name == "Alice"
assert user.email == "alice@example.com"
assert user.id is not None
6.2 Given-When-Then (BDD Style)¶
def test_withdraw_sufficient_balance():
"""
Given: Account has 1000 won
When: Withdraw 500 won
Then: Balance becomes 500 won
"""
# Given
account = Account(balance=1000)
# When
account.withdraw(500)
# Then
assert account.balance == 500
6.3 Test Markers¶
import pytest
@pytest.mark.slow
def test_large_data_processing():
"""Slow test"""
pass
@pytest.mark.integration
def test_database_connection():
"""Integration test"""
pass
@pytest.mark.skip(reason="Feature not implemented")
def test_future_feature():
pass
@pytest.mark.skipif(
condition=True,
reason="Skip under specific conditions"
)
def test_conditional():
pass
@pytest.mark.xfail(reason="Known bug")
def test_known_bug():
assert False # Test passes even if it fails
# Run: pytest -m "not slow" # Exclude slow
# Run: pytest -m "integration" # Only integration
6.4 Test Grouping¶
class TestUserCreation:
"""User creation test group"""
def test_create_with_valid_data(self):
pass
def test_create_with_invalid_email(self):
pass
def test_create_duplicate_user(self):
pass
class TestUserAuthentication:
"""Authentication test group"""
def test_login_success(self):
pass
def test_login_wrong_password(self):
pass
7. Practice Problems¶
Exercise 1: Write Basic Tests¶
Write tests for the following function.
# Test target
def validate_email(email: str) -> bool:
"""Email validation"""
import re
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
return bool(re.match(pattern, email))
# Write tests
@pytest.mark.parametrize("email, expected", [
("user@example.com", True),
("invalid-email", False),
("user@domain", False),
("user.name+tag@example.co.kr", True),
("", False),
])
def test_validate_email(email, expected):
assert validate_email(email) == expected
Exercise 2: Use Fixtures¶
Write and use a database connection fixture.
# Sample solution
@pytest.fixture
def db_connection():
"""In-memory test database"""
import sqlite3
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
yield conn
conn.close()
def test_insert_user(db_connection):
db_connection.execute("INSERT INTO users (name) VALUES (?)", ("Alice",))
result = db_connection.execute("SELECT name FROM users").fetchone()
assert result[0] == "Alice"
Exercise 3: Mocking Practice¶
Test a function that calls an external API using mocking.
# Test target
class PaymentService:
def process_payment(self, amount: float) -> dict:
# Actually calls external payment API
import requests
response = requests.post(
"https://payment.api/charge",
json={"amount": amount}
)
return response.json()
# Test (sample solution)
def test_process_payment(mocker):
mock_post = mocker.patch("requests.post")
mock_post.return_value.json.return_value = {
"status": "success",
"transaction_id": "txn_123"
}
service = PaymentService()
result = service.process_payment(100.0)
assert result["status"] == "success"
mock_post.assert_called_once()