Testing and Continuous Integration Guide
This guide covers everything you need to know about testing Python code and setting up continuous integration (CI) pipelines. Whether you're new to testing or looking to improve your CI practices, this guide will help you establish robust testing workflows.
Understanding Testing in Python
Types of Tests
-
Unit Tests
- Test individual components in isolation
- Fast and focused
- Should cover edge cases
-
Integration Tests
- Test multiple components together
- Verify component interactions
- More complex setup
-
Functional Tests
- Test complete features
- End-to-end scenarios
- User-centric testing
Testing with pytest
Basic Test Structure
# test_calculator.py
def test_addition():
assert 2 + 2 == 4
def test_string_length():
assert len("hello") == 5
Fixtures
# conftest.py
import pytest
from database import Database
@pytest.fixture
def db():
"""Provide a database connection for tests."""
db = Database()
db.connect()
yield db
db.disconnect()
# test_database.py
def test_save_user(db):
user = {"name": "John"}
assert db.save_user(user) is True
Parameterized Tests
import pytest
@pytest.mark.parametrize("input,expected", [
("hello", 5),
("python", 6),
("", 0),
])
def test_string_length(input, expected):
assert len(input) == expected
Testing Exceptions
def divide(a, b):
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
def test_divide_by_zero():
with pytest.raises(ValueError) as exc_info:
divide(1, 0)
assert str(exc_info.value) == "Cannot divide by zero"
Mock Objects and Patching
Using unittest.mock
from unittest.mock import Mock, patch
import requests
def get_user_data(user_id):
response = requests.get(f"https://api.example.com/users/{user_id}")
return response.json()
def test_get_user_data():
# Mock the requests.get call
with patch('requests.get') as mock_get:
mock_get.return_value.json.return_value = {"id": 1, "name": "John"}
result = get_user_data(1)
assert result["name"] == "John"
mock_get.assert_called_once_with("https://api.example.com/users/1")
Mock Context Managers
from unittest.mock import patch, mock_open
def read_config():
with open('config.json') as f:
return f.read()
def test_read_config():
mock_data = '{"setting": "value"}'
with patch('builtins.open', mock_open(read_data=mock_data)):
result = read_config()
assert result == mock_data
Code Coverage
Using pytest-cov
# Install pytest-cov
pip install pytest-cov
# Run tests with coverage
pytest --cov=mypackage tests/
# Generate HTML report
pytest --cov=mypackage --cov-report=html tests/
Coverage Configuration (.coveragerc)
[run]
source = mypackage
omit = tests/*
[report]
exclude_lines =
pragma: no cover
def __repr__
raise NotImplementedError
Continuous Integration
GitHub Actions
# .github/workflows/python-tests.yml
name: Python Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.8, 3.9, "3.10"]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pytest pytest-cov
- name: Run tests
run: |
pytest --cov=./ --cov-report=xml
- name: Upload coverage
uses: codecov/codecov-action@v2
GitLab CI
# .gitlab-ci.yml
image: python:3.9
stages:
- test
- deploy
before_script:
- pip install -r requirements.txt
test:
stage: test
script:
- pip install pytest pytest-cov
- pytest --cov=./ --cov-report=term-missing
coverage: '/TOTAL.+ ([0-9]{1,3}%)/'
Best Practices
1. Test Organization
my_project/
├── src/
│ └── mypackage/
│ ├── __init__.py
│ └── module.py
└── tests/
├── __init__.py
├── conftest.py
├── test_unit/
│ └── test_module.py
└── test_integration/
└── test_features.py
2. Testing Standards
-
Naming Conventions
- Test files:
test_*.py
- Test functions:
test_*
- Test classes:
Test*
- Test files:
-
FIRST Principles
- Fast: Tests should run quickly
- Isolated: Tests shouldn't depend on each other
- Repeatable: Same results every time
- Self-validating: Pass/fail without manual checking
- Timely: Written before or with code
3. Test Documentation
def test_user_registration():
"""
Test user registration process.
Steps:
1. Create user data
2. Submit registration
3. Verify user exists
4. Check welcome email
"""
# Test implementation
Common Testing Patterns
1. Factory Pattern
# tests/factories.py
from faker import Faker
fake = Faker()
def create_user(**kwargs):
"""Create a test user with random data."""
return {
"username": kwargs.get("username", fake.user_name()),
"email": kwargs.get("email", fake.email()),
"password": kwargs.get("password", fake.password())
}
2. Test Doubles
# Test stub
class StubPaymentGateway:
def process_payment(self, amount):
return True
# Test spy
class SpyEmailService:
def __init__(self):
self.sent_emails = []
def send_email(self, to, subject, body):
self.sent_emails.append({"to": to, "subject": subject})
return True
Debugging Tests
Using pdb
def test_complex_operation():
import pdb; pdb.set_trace() # Or use breakpoint() in Python 3.7+
result = complex_operation()
assert result == expected_value
pytest Options
# Show print statements
pytest -s
# Show local variables in errors
pytest --showlocals
# Stop on first failure
pytest -x
# Run specific test
pytest tests/test_module.py::test_function
Performance Testing
import pytest
import time
@pytest.mark.benchmark
def test_performance(benchmark):
def slow_function():
time.sleep(0.1)
return sum(range(1000))
result = benchmark(slow_function)
assert result == 499500
Remember: Testing is an investment in your code's reliability and maintainability. Start with simple tests and gradually build up your test suite as your project grows.