pytest Subtests Guide
Table of Contents
Introduction
Subtests are a pytest 9.0 feature that allows you to run multiple test cases within a single test method, with each case reported independently. They provide better test organization and failure reporting compared to traditional approaches.
What are Subtests?
Subtests allow you to:
Run multiple related test cases in a single test method
Get independent failure reporting for each case
Continue testing even if one case fails
Share expensive setup/teardown across cases
Provide contextual information for debugging
Why Use Subtests?
Traditional approach (separate test methods):
def test_validates_path_case1(self) -> None:
assert validate("path1")
def test_validates_path_case2(self) -> None:
assert validate("path2")
def test_validates_path_case3(self) -> None:
assert validate("path3")
Problems:
Lots of boilerplate
Hard to add new test cases
Unclear relationship between tests
Expensive setup runs multiple times
Subtest approach:
def test_validates_paths(self, subtests: pytest.Subtests) -> None:
"""Test path validation using subtests."""
paths = ["path1", "path2", "path3"]
for path in paths:
with subtests.test(msg=f"Validating: {path}", path=path):
assert validate(path)
Benefits:
Less boilerplate
Easy to add test cases
Clear test organization
Setup runs once
All cases run even if one fails
When to Use Subtests
Decision Tree
Is this a test with multiple similar test cases?
├─ NO → Use a single test method
└─ YES → Continue...
│
Are there ≥4 test cases?
├─ NO → Continue to next question
│ │
│ Are test cases dynamic (from file/API/etc)?
│ ├─ YES → ✅ USE SUBTESTS
│ └─ NO → ❌ USE PARAMETRIZE
│
└─ YES → Continue...
│
Do cases share expensive setup/teardown?
├─ YES → ✅ USE SUBTESTS
└─ NO → Continue...
│
Do you want all cases to run even if one fails?
├─ YES → ✅ USE SUBTESTS
└─ NO → ⚠️ USE PARAMETRIZE (or subtests if preferred)
Use Cases
1. Testing Multiple Input Variations
When: You have many similar inputs to test
def test_rejects_invalid_paths(self, subtests: pytest.Subtests) -> None:
"""Test that various invalid paths are rejected."""
invalid_paths = [
("Path traversal", "../../../etc/passwd"),
("Absolute path", "/etc/passwd"),
("Windows path", "C:\\Windows\\System32"),
("Null byte", "file\x00.txt"),
("Too long", "a" * 10000),
]
for description, path in invalid_paths:
with subtests.test(msg=f"{description}: {path}", path=path):
assert not InputValidator.validate_file_path(path)
2. Testing Multiple Implementations
When: Testing the same behavior across multiple implementations
def test_handlers_reject_malicious_input(self, subtests: pytest.Subtests) -> None:
"""Test that all handlers reject malicious input."""
handlers = [
("JSON", JsonHandler()),
("YAML", YamlHandler()),
("TOML", TomlHandler()),
]
malicious_input = "../../../etc/passwd"
for name, handler in handlers:
with subtests.test(msg=f"Handler: {name}", handler=name):
assert not handler.apply_change(malicious_input, "content", 1, 1)
3. Testing Configuration Variants
When: Testing different configuration values
def test_from_env_boolean_true_variants(self, subtests: pytest.Subtests) -> None:
"""Test various boolean true values."""
true_values = ["true", "True", "TRUE", "1", "yes", "on"]
for value in true_values:
with (
subtests.test(msg=f"Boolean value: {value}", value=value),
patch.dict(os.environ, {"CONFIG_FLAG": value}),
):
config = Config.from_env()
assert config.flag is True
4. Testing Platform-Specific Behavior
When: Testing behavior across different platforms/environments
def test_path_validation_cross_platform(self, subtests: pytest.Subtests) -> None:
"""Test path validation for different platforms."""
test_cases = [
("Unix absolute", "/etc/passwd", False),
("Windows absolute", "C:\\Windows", False),
("UNC path", "\\\\server\\share", False),
("Relative safe", "config.json", True),
]
for description, path, should_validate in test_cases:
with subtests.test(msg=description, path=path):
result = validate_path(path)
assert result == should_validate
5. Testing Edge Cases
When: You have many edge cases to verify
def test_handles_unicode_edge_cases(self, subtests: pytest.Subtests) -> None:
"""Test handling of Unicode edge cases."""
edge_cases = [
("Empty string", ""),
("Null byte", "\x00"),
("Zero-width space", "\u200b"),
("RTL override", "\u202e"),
("Fullwidth dots", "\uff0e\uff0e"),
("Combining characters", "e\u0301"), # é
]
for description, text in edge_cases:
with subtests.test(msg=f"Edge case: {description}", input=text):
result = sanitize_input(text)
assert is_safe(result)
When NOT to Use Subtests
Use @pytest.mark.parametrize Instead
1. Small, Static Test Sets (< 4 cases)
❌ Don’t use subtests:
def test_boolean_parsing(self, subtests: pytest.Subtests) -> None:
for value, expected in [("true", True), ("false", False)]:
with subtests.test(value=value):
assert parse_bool(value) == expected
✅ Use parametrize:
@pytest.mark.parametrize("value,expected", [
("true", True),
("false", False),
])
def test_boolean_parsing(value: str, expected: bool) -> None:
assert parse_bool(value) == expected
Why: Parametrize is clearer for small, static sets and shows each case as a separate test in reports.
2. When You Want Separate Test Reports
❌ Don’t use subtests if:
You want each case to appear as a separate test in CI reports
You’re tracking test counts as a metric
You need fine-grained test selection (e.g.,
pytest -k case1)
✅ Use parametrize for:
Separate test entries in reports
Better test discovery
Individual test selection
Use Separate Test Methods Instead
2. Tests with Different Fixtures
❌ Don’t use subtests:
def test_with_different_setups(
self,
subtests: pytest.Subtests,
tmp_path: Path,
mock_api: Mock,
) -> None:
with subtests.test("uses tmp_path"):
# Only uses tmp_path
file = tmp_path / "test.txt"
assert file.exists()
with subtests.test("uses mock_api"):
# Only uses mock_api
mock_api.return_value = "data"
assert fetch_data() == "data"
✅ Use separate tests:
def test_file_operations(tmp_path: Path) -> None:
file = tmp_path / "test.txt"
assert file.exists()
def test_api_operations(mock_api: Mock) -> None:
mock_api.return_value = "data"
assert fetch_data() == "data"
Basic Subtest Pattern
Minimal Example
def test_with_subtests(self, subtests: pytest.Subtests) -> None:
"""Test description."""
test_cases = [
# (input, expected)
("input1", "expected1"),
("input2", "expected2"),
("input3", "expected3"),
]
for input_val, expected in test_cases:
with subtests.test(msg=f"Testing: {input_val}", input=input_val):
result = function_under_test(input_val)
assert result == expected
Key Components
Fixture Parameter:
subtests: pytest.SubtestsInjects the subtests fixture into your test
Context Manager:
with subtests.test(...)Creates an isolated subtest context
Message:
msg="Descriptive message"Shown in failure reports (required for clarity)
Context Variables:
**kwargs(e.g.,input=input_val)Included in failure reports for debugging
Complete Pattern
def test_example(self, subtests: pytest.Subtests) -> None:
"""Test example showing complete pattern."""
# 1. Define test cases
test_cases = [
("case1", "expected1"),
("case2", "expected2"),
]
# 2. Loop through test cases
for case_input, expected_output in test_cases:
# 3. Create subtest with descriptive message and context
with subtests.test(
msg=f"Testing case: {case_input}", # Descriptive message
input=case_input, # Context for debugging
expected=expected_output # More context
):
# 4. Execute test logic
result = function_to_test(case_input)
# 5. Make assertion
assert result == expected_output, \
f"Expected {expected_output}, got {result}"
Advanced Patterns
Pattern 1: Multiple Context Managers
Combine subtests with other context managers using Python 3.10+ syntax:
def test_with_multiple_contexts(self, subtests: pytest.Subtests) -> None:
"""Test with multiple context managers."""
configs = [("dev", 8080), ("prod", 443)]
for env, port in configs:
with (
subtests.test(msg=f"Environment: {env}", env=env, port=port),
patch.dict(os.environ, {"ENV": env, "PORT": str(port)}),
tempfile.TemporaryDirectory() as tmpdir,
):
config = load_config()
assert config.port == port
assert Path(tmpdir).exists()
Pattern 2: Nested Subtests
Test combinations of parameters:
def test_handler_format_combinations(self, subtests: pytest.Subtests) -> None:
"""Test all handler-format combinations."""
handlers = [JsonHandler(), YamlHandler(), TomlHandler()]
formats = ["compact", "pretty", "minimal"]
for handler in handlers:
for fmt in formats:
with subtests.test(
msg=f"{handler.__class__.__name__} with {fmt} format",
handler=handler.__class__.__name__,
format=fmt
):
result = handler.format_output(data, format=fmt)
assert is_valid_format(result, fmt)
Pattern 3: Subtests with Setup/Teardown
Share expensive setup across subtests:
def test_database_operations(self, subtests: pytest.Subtests) -> None:
"""Test database operations with shared connection."""
# Expensive setup (runs once)
conn = create_database_connection()
setup_test_data(conn)
operations = [
("insert", lambda: insert_record(conn, {"id": 1})),
("update", lambda: update_record(conn, 1, {"name": "updated"})),
("delete", lambda: delete_record(conn, 1)),
]
try:
for op_name, op_func in operations:
with subtests.test(msg=f"Operation: {op_name}", operation=op_name):
result = op_func()
assert result.success
finally:
# Cleanup (runs once)
conn.close()
Pattern 4: Conditional Subtests
Skip subtests based on conditions:
def test_platform_specific_features(self, subtests: pytest.Subtests) -> None:
"""Test platform-specific features."""
features = [
("symlinks", sys.platform != "win32", test_symlinks),
("permissions", sys.platform != "win32", test_permissions),
("unicode_paths", True, test_unicode_paths),
]
for feature_name, supported, test_func in features:
with subtests.test(msg=f"Feature: {feature_name}", feature=feature_name):
if not supported:
pytest.skip(f"{feature_name} not supported on this platform")
result = test_func()
assert result.passed
Pattern 5: Subtests with Fixtures
Use fixtures within subtests:
def test_handlers_with_files(
self,
subtests: pytest.Subtests,
tmp_path: Path
) -> None:
"""Test handlers with temporary files."""
handlers = [
("json", JsonHandler(), "test.json", '{"key": "value"}'),
("yaml", YamlHandler(), "test.yaml", "key: value"),
("toml", TomlHandler(), "test.toml", 'key = "value"'),
]
for name, handler, filename, content in handlers:
with subtests.test(msg=f"Handler: {name}", handler=name):
# Each subtest gets same tmp_path but different file
test_file = tmp_path / filename
test_file.write_text(content)
assert handler.can_handle(str(test_file))
result = handler.parse(str(test_file))
assert result["key"] == "value"
Best Practices
1. Write Descriptive Messages
❌ Poor:
with subtests.test(msg=f"Test {i}"):
assert validate(data[i])
✅ Good:
with subtests.test(
msg=f"Validate user input: {data[i]['username']}",
username=data[i]["username"],
index=i
):
assert validate(data[i])
2. Include Context Variables
Context variables appear in failure reports:
❌ Minimal context:
with subtests.test(msg="Testing path"):
assert validate(path)
✅ Rich context:
with subtests.test(
msg=f"Validating path: {path}",
path=path,
path_type=get_path_type(path),
length=len(path)
):
assert validate(path)
3. Keep Subtests Focused
Each subtest should test one thing:
❌ Testing too much:
with subtests.test(msg="User operations"):
user = create_user("alice")
assert user.name == "alice"
assert user.email == "alice@example.com"
assert user.is_active
update_user(user.id, name="bob")
assert get_user(user.id).name == "bob"
✅ Focused tests:
with subtests.test(msg="Create user"):
user = create_user("alice")
assert user.name == "alice"
with subtests.test(msg="User has email"):
assert user.email == "alice@example.com"
with subtests.test(msg="User is active"):
assert user.is_active
4. Use Type Hints
def test_with_subtests(self, subtests: pytest.Subtests) -> None:
"""Always type-hint the subtests fixture."""
# ...
5. Document Test Cases
def test_input_validation(self, subtests: pytest.Subtests) -> None:
"""Test input validation for various edge cases.
Tests cover:
* Empty strings
* Whitespace-only strings
* Special characters
* Unicode characters
* Maximum length inputs
"""
test_cases = [...]
7. Use Meaningful Variable Names
❌ Unclear:
for x, y in cases:
with subtests.test(msg=f"{x}"):
assert f(x) == y
✅ Clear:
for input_value, expected_output in test_cases:
with subtests.test(msg=f"Input: {input_value}", input=input_value):
assert function(input_value) == expected_output
Examples from Codebase
Example 1: Path Traversal Testing
From tests/security/test_input_validation.py:
def test_path_traversal_unix(self, subtests: pytest.Subtests) -> None:
"""Test detection of Unix-style path traversal using subtests."""
unix_paths = [
"../../etc/passwd",
"../../../root/.ssh/id_rsa",
"./../../etc/shadow",
]
for path in unix_paths:
with subtests.test(msg=f"Unix path traversal: {path}", path=path):
assert not InputValidator.validate_file_path(path)
Why this works:
Clear test purpose (Unix path traversal)
Descriptive messages
Context variable (path) for debugging
All cases run independently
Example 2: Handler Validation
From tests/security/test_path_traversal.py:
def test_handlers_reject_absolute_paths(
self,
setup_test_files: tuple[Path, Path, Path, str],
subtests: pytest.Subtests
) -> None:
"""Test that handlers reject absolute paths using subtests."""
base_path, _, outside_file, _file_type = setup_test_files
handlers = [
JsonHandler(workspace_root=str(base_path)),
YamlHandler(workspace_root=str(base_path)),
TomlHandler(workspace_root=str(base_path)),
]
for handler in handlers:
with subtests.test(
msg=f"Handler: {handler.__class__.__name__}",
handler=handler.__class__.__name__
):
assert not handler.apply_change(
str(outside_file), "test content", 1, 1
), f"{handler.__class__.__name__} should reject absolute paths"
Why this works:
Tests all handlers with same logic
Expensive fixture setup shared
Clear failure reporting per handler
All handlers tested even if one fails
Example 3: Configuration Variants
From tests/unit/test_runtime_config.py:
def test_from_env_enable_rollback_true_variants(
self, subtests: pytest.Subtests
) -> None:
"""Test various true values using subtests."""
for value in ["true", "True", "1", "yes", "on"]:
with (
subtests.test(msg=f"Boolean true variant: {value}", value=value),
patch.dict(os.environ, {"CR_ENABLE_ROLLBACK": value}),
):
config = RuntimeConfig.from_env()
assert config.enable_rollback is True
Why this works:
Multiple context managers combined
Each variant tested independently
Easy to add new boolean values
Clear which variant failed
Common Pitfalls
Pitfall 1: Forgetting the Fixture
❌ Error:
def test_without_fixture(self) -> None:
with subtests.test(msg="test"): # NameError: subtests not defined
assert True
✅ Fix:
def test_with_fixture(self, subtests: pytest.Subtests) -> None:
with subtests.test(msg="test"):
assert True
Pitfall 2: Not Providing Messages
❌ Poor debugging:
with subtests.test(): # No context when it fails
assert validate(path)
✅ Clear debugging:
with subtests.test(msg=f"Validating: {path}", path=path):
assert validate(path)
Pitfall 5: Nested with Statements (Linter Violation)
❌ Ruff SIM117 violation:
with subtests.test(msg="test"):
with patch.dict(os.environ, {"KEY": "value"}):
assert True
✅ Combined context managers:
with (
subtests.test(msg="test"),
patch.dict(os.environ, {"KEY": "value"}),
):
assert True
Migration Guide
Step 1: Identify Candidates
Look for:
Multiple test methods with similar names/logic
Tests with loops that don’t use subtests
Tests with many similar assertions
Tests that could benefit from better failure reporting
Step 2: Choose the Pattern
Use the decision tree to determine if subtests are appropriate.
Step 3: Refactor
Before:
def test_case_1(self) -> None:
assert validate("input1")
def test_case_2(self) -> None:
assert validate("input2")
def test_case_3(self) -> None:
assert validate("input3")
After:
def test_validation_cases(self, subtests: pytest.Subtests) -> None:
"""Test validation with various inputs."""
inputs = ["input1", "input2", "input3"]
for input_val in inputs:
with subtests.test(msg=f"Validating: {input_val}", input=input_val):
assert validate(input_val)
Step 4: Add Context
Enhance with descriptive messages and context variables:
def test_validation_cases(self, subtests: pytest.Subtests) -> None:
"""Test validation with various inputs."""
test_cases = [
("Simple input", "input1"),
("Complex input", "input2"),
("Edge case", "input3"),
]
for description, input_val in test_cases:
with subtests.test(
msg=f"{description}: {input_val}",
description=description,
input=input_val
):
result = validate(input_val)
assert result is True
Step 5: Test
Run the migrated test to ensure:
All subtests pass
Failure messages are clear
No regressions introduced
Step 6: Document
Update docstrings to mention subtests:
def test_validation_cases(self, subtests: pytest.Subtests) -> None:
"""Test validation with various inputs using subtests.
Each input is tested independently to ensure comprehensive
coverage and clear failure reporting.
"""