Enforce Google's hermetic testing practices in Python.
Block network, filesystem, and subprocess access in unit tests. Validate your test pyramid. Eliminate flaky tests.
Documentation • PyPI • Quickstart • Why?
# Install
pip install pytest-test-categories
# Mark your tests
# @pytest.mark.small - Fast, hermetic (no I/O)
# @pytest.mark.medium - Can use localhost, filesystem
# @pytest.mark.large - Full network access
# Enable enforcement in pyproject.toml
# test_categories_enforcement = "strict"
# Run pytest as usual
pytest- Why pytest-test-categories?
- Test Size Categories
- Installation
- Quick Start
- Configuration
- Enforcement Modes
- Philosophy: No Escape Hatches
- Test Distribution Targets
- Reporting
- Documentation
- Contributing
- License
Flaky tests are a symptom. Hidden external dependencies are the disease.
Most test suites suffer from:
- Flaky tests - Tests that pass locally but fail in CI due to network timeouts, race conditions, or shared state
- Slow CI pipelines - No time budgets means tests grow unbounded
- Inverted test pyramid - Too many slow integration tests, too few fast unit tests
- No enforced boundaries - "Unit tests" that secretly hit the database, network, or filesystem
The root cause? Tests with hidden external dependencies that make them non-deterministic.
pytest-test-categories brings Google's battle-tested testing philosophy (from "Software Engineering at Google") to Python:
| What | How |
|---|---|
| Categorize tests by size | @pytest.mark.small, medium, large, xlarge |
| Enforce hermeticity | Block network, filesystem, database, subprocess in small tests |
| Enforce time limits | 1s for small, 5min for medium, 15min for large |
| Validate distribution | Maintain healthy 80/15/5 test pyramid |
When a small test tries to access the network, it fails immediately with actionable guidance:
======================================================================
[TC001] Network Violation
======================================================================
Category: SMALL
What happened:
SMALL test attempted network connection to api.example.com:443
To fix this (choose one):
• Mock the network call using responses, httpretty, or respx
• Use dependency injection to provide a fake HTTP client
• Change test category to @pytest.mark.medium
======================================================================
| Resource | Small | Medium | Large | XLarge |
|---|---|---|---|---|
| Time Limit | 1s | 5min | 15min | 15min |
| Network | ❌ Blocked | Localhost | ✓ Allowed | ✓ Allowed |
| Filesystem | ❌ Blocked | ✓ Allowed | ✓ Allowed | ✓ Allowed |
| Database | ❌ Blocked | ✓ Allowed | ✓ Allowed | ✓ Allowed |
| Subprocess | ❌ Blocked | ✓ Allowed | ✓ Allowed | ✓ Allowed |
| Sleep | ❌ Blocked | ✓ Allowed | ✓ Allowed | ✓ Allowed |
Small tests must be hermetic - completely isolated from external resources. This eliminates flakiness at the source.
pip install pytest-test-categoriesuv add pytest-test-categoriespoetry add pytest-test-categoriesMark your tests with size markers:
import pytest
@pytest.mark.small
def test_unit():
"""Fast, hermetic unit test - no network, no filesystem, no database."""
assert 1 + 1 == 2
@pytest.mark.small
def test_with_mocking(mocker):
"""Mocked tests are still hermetic - mocks intercept before the network layer."""
mocker.patch("requests.get").return_value.json.return_value = {"status": "ok"}
# Your code that uses requests.get() works without hitting the network
@pytest.mark.medium
def test_integration(tmp_path):
"""Integration test - can access localhost and filesystem."""
db_path = tmp_path / "test.db"
# ... test with local databaseOr inherit from base test classes:
from pytest_test_categories import SmallTest, MediumTest
class DescribeUserService(SmallTest):
"""All tests in this class are automatically marked as small."""
def test_validates_email_format(self):
assert is_valid_email("user@example.com")
def test_rejects_invalid_email(self):
assert not is_valid_email("not-an-email")
class DescribeUserRepository(MediumTest):
"""Tests requiring database access."""
def test_saves_user(self, db_connection):
# ... test with real databaseBy default, enforcement is off. Enable it in pyproject.toml:
[tool.pytest.ini_options]
# Resource isolation: "off" (default), "warn", or "strict"
test_categories_enforcement = "strict"
# Distribution validation: "off" (default), "warn", or "strict"
test_categories_distribution_enforcement = "warn"Run pytest as usual:
pytest[tool.pytest.ini_options]
# Enforcement modes: "strict" (fail), "warn" (warning), "off" (disabled)
test_categories_enforcement = "strict"
test_categories_distribution_enforcement = "warn"CLI options override pyproject.toml settings:
# Enforcement modes
pytest --test-categories-enforcement=strict
pytest --test-categories-distribution-enforcement=warn
# Reporting
pytest --test-size-report=basic # Summary report
pytest --test-size-report=detailed # Per-test details
pytest --test-size-report=json # JSON output
pytest --test-size-report=json --test-size-report-file=report.jsonNote: Time limits are fixed per Google's testing standards and cannot be customized. This ensures consistent test categorization across all projects.
| Mode | Behavior | Use Case |
|---|---|---|
off |
No enforcement (default) | Initial exploration |
warn |
Emit warnings, tests continue | Migration period |
strict |
Fail tests on violations | Production enforcement |
# Week 1: Discovery - see what would fail
test_categories_enforcement = "off"
# Week 2-4: Migration - fix violations incrementally
test_categories_enforcement = "warn"
# Week 5+: Enforced - violations fail the build
test_categories_enforcement = "strict"pytest-test-categories intentionally does not provide per-test override markers like @pytest.mark.allow_network.
If a test needs network access, filesystem access, or other resources, it should be marked with the appropriate size:
# Wrong: Trying to bypass restrictions
@pytest.mark.small
@pytest.mark.allow_network # This marker does not exist!
def test_api_call():
...
# Correct: Use the appropriate test size
@pytest.mark.medium # Medium tests can access localhost
def test_api_call():
...
# Or: Mock the dependency for small tests
@pytest.mark.small
def test_api_call(httpx_mock):
httpx_mock.add_response(url="https://api.example.com/", json={"status": "ok"})
...Why no escape hatches?
- Flaky tests are expensive - escape hatches become the norm, defeating the purpose
- Categories have meaning - if a "small" test can access the network, it's not really a small test
- Encourages better design - mocking and dependency injection lead to more testable code
See the Design Philosophy documentation for the full rationale.
| Size | Target Percentage | Tolerance |
|---|---|---|
| Small | 80% | ± 5% |
| Medium | 15% | ± 5% |
| Large/XLarge | 5% | ± 3% |
When distribution enforcement is enabled, pytest will warn or fail if your test distribution falls outside these ranges.
# Basic summary report
pytest --test-size-report=basic
# Detailed report with per-test information
pytest --test-size-report=detailedFor CI/CD integration and custom tooling:
# Output JSON report to terminal
pytest --test-size-report=json
# Write JSON report to a file
pytest --test-size-report=json --test-size-report-file=report.jsonJSON Report Structure
{
"timestamp": "2025-11-29T12:00:00.000000Z",
"summary": {
"total_tests": 150,
"distribution": {
"small": {"count": 120, "percentage": 80.0, "target": 80.0},
"medium": {"count": 22, "percentage": 14.67, "target": 15.0},
"large": {"count": 6, "percentage": 4.0, "target": 4.0},
"xlarge": {"count": 2, "percentage": 1.33, "target": 1.0}
},
"violations": {
"timing": 0,
"hermeticity": {
"network": 0,
"filesystem": 0,
"process": 0,
"database": 0,
"sleep": 0,
"total": 0
}
}
},
"tests": [
{
"name": "tests/test_example.py::test_fast_function",
"size": "small",
"duration": 0.002,
"status": "passed",
"violations": []
}
]
}For comprehensive documentation, visit pytest-test-categories.readthedocs.io:
- Getting Started - Installation and first steps
- User Guide - Test sizes, isolation, timing, and distribution
- Configuration - All configuration options
- Examples - Common patterns and CI integration
- API Reference - Markers, fixtures, and error messages
- Architecture - Design philosophy and ADRs
- CONTRIBUTING.md - Contribution guidelines
- CODE_OF_CONDUCT.md - Community standards
- SECURITY.md - Security policy
- ROADMAP.md - Project vision and milestones
- GitHub Discussions - Questions and ideas
- Issue Tracker - Bug reports and feature requests
We welcome contributions! Please read our CONTRIBUTING.md for detailed guidelines.
- Fork and clone the repository
- Create an issue describing what you plan to work on
- Create a feature branch from main
- Make your changes following our coding standards
- Run pre-commit hooks to ensure quality:
uv run pre-commit run --all-files - Open a pull request linking to your issue
This project is licensed under the MIT License.
Happy testing!
Built with the belief that flaky tests are a solved problem.