
Pytest Best Practices
- 20 installs
- 17 repo stars
- Updated March 28, 2026
- cfircoo/claude-code-toolkit
Automate pytest best practices in your development workflow
About
pytest-best-practices provides specialized automation for your workflow. Integrate it during build to automate key development tasks and improve team efficiency.
- Pytest Best Practices
- Automation
- Workflow
Pytest Best Practices by the numbers
- 20 all-time installs (skills.sh)
- Ranked #10,442 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/cfircoo/claude-code-toolkit --skill pytest-best-practicesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 20 |
|---|---|
| repo stars | ★ 17 |
| Last updated | March 28, 2026 |
| Repository | cfircoo/claude-code-toolkit ↗ |
What it does
Automate pytest best practices in your development workflow
Files
<objective> Provide pytest best practices and patterns for writing maintainable, efficient tests. </objective>
<essential_principles>
Test Independence
- Each test must run in isolation - no shared state between tests
- Use fixtures for setup/teardown, never class-level mutable state
- Tests should pass regardless of execution order
Naming Conventions
- Files:
test_*.pyor*_test.py - Functions:
test_<description>() - Classes:
Test<ClassName> - Fixtures: descriptive
lowercase_with_underscores
Directory Structure
tests/
├── conftest.py # Shared fixtures
├── unit/
│ └── test_module.py
├── integration/
│ └── test_api.py
└── fixtures/ # Test data filesCore Testing Rules
- Use plain
assertstatements (pytest provides detailed failure messages) - One logical assertion per test when practical
- Test edge cases: empty inputs, boundaries, invalid data, errors
- Keep tests focused and readable
</essential_principles>
<quick_reference>
| Pattern | Use Case |
|---|---|
@pytest.fixture | Setup/teardown, dependency injection |
@pytest.mark.parametrize | Run test with multiple inputs |
@pytest.mark.skip | Skip test temporarily |
@pytest.mark.xfail | Expected failure (known bug) |
pytest.raises(Exception) | Test exception raising |
pytest.approx(value) | Float comparison |
mocker.patch() | Mock dependencies |
conftest.py | Share fixtures across modules |
Common Commands
pytest -v # Verbose
pytest -x # Stop on first failure
pytest --lf # Run last failed
pytest -k "pattern" # Match test names
pytest -m "marker" # Run marked tests
pytest --cov=src # Coverage report</quick_reference>
<routing>
Based on what you're doing, read the relevant reference:
| Task | Reference |
|---|---|
| Setting up fixtures, scopes, factories | references/fixtures.md |
| Parametrizing tests, multiple inputs | references/parametrization.md |
| Mocking, patching, faking dependencies | references/mocking.md |
| Markers, exceptions, assertions, async | references/patterns.md |
</routing>
<dependencies>
pip install pytest pytest-asyncio pytest-mock pytest-cov pytest-xdist</dependencies>
Fixtures Reference
<basic_fixture>
import pytest
@pytest.fixture
def sample_user():
"""Create a sample user for testing."""
return {"id": 1, "name": "Test User", "email": "test@example.com"}
def test_user_has_email(sample_user):
assert "email" in sample_user
assert "@" in sample_user["email"]</basic_fixture>
<fixture_scopes>
function (default) - New instance per test
@pytest.fixture(scope="function")
def db_connection():
conn = create_connection()
yield conn
conn.close()module - Shared across all tests in module
@pytest.fixture(scope="module")
def expensive_resource():
resource = setup_expensive_thing()
yield resource
resource.cleanup()session - Shared across entire test session
@pytest.fixture(scope="session")
def app_config():
return load_config()class - Shared across all tests in a class
@pytest.fixture(scope="class")
def class_resource():
return create_resource()</fixture_scopes>
<teardown_pattern>
Use yield for setup/teardown:
@pytest.fixture
def temp_file():
"""Create and cleanup a temporary file."""
path = Path("/tmp/test_file.txt")
path.write_text("test content")
yield path # Test runs here
path.unlink(missing_ok=True) # Cleanup after test</teardown_pattern>
<fixture_factories>
Create multiple instances with custom attributes:
@pytest.fixture
def make_user():
"""Factory fixture for creating users."""
created_users = []
def _make_user(name="Test", email=None):
user = User(name=name, email=email or f"{name.lower()}@test.com")
created_users.append(user)
return user
yield _make_user
# Cleanup all created users
for user in created_users:
user.delete()
def test_multiple_users(make_user):
user1 = make_user("Alice")
user2 = make_user("Bob", email="bob@custom.com")
assert user1.email != user2.email</fixture_factories>
<conftest_pattern>
Share fixtures across modules in tests/conftest.py:
# tests/conftest.py
import pytest
@pytest.fixture
def api_client():
"""Shared API client available to all tests."""
from myapp import create_test_client
return create_test_client()
@pytest.fixture(autouse=True)
def reset_database(db):
"""Automatically reset DB before each test."""
db.reset()
yield
db.rollback()autouse=True - Fixture runs for every test without explicit request.
</conftest_pattern>
<fixture_dependencies>
Fixtures can depend on other fixtures:
@pytest.fixture
def db():
return create_database()
@pytest.fixture
def user(db): # Depends on db fixture
return db.create_user("test")
@pytest.fixture
def authenticated_client(user, api_client): # Multiple dependencies
api_client.login(user)
return api_client</fixture_dependencies>
Mocking Reference
<basic_mock>
Use pytest-mock's mocker fixture:
def test_api_call(mocker):
# Mock the requests.get function
mock_get = mocker.patch("mymodule.requests.get")
mock_get.return_value.json.return_value = {"status": "ok"}
result = fetch_status()
assert result == "ok"
mock_get.assert_called_once_with("https://api.example.com/status")Key: Patch where the function is used, not where it's defined.
</basic_mock>
<side_effects>
Return different values on successive calls:
def test_with_side_effect(mocker):
mock_db = mocker.patch("mymodule.database.query")
mock_db.side_effect = [
{"id": 1}, # First call
{"id": 2}, # Second call
DatabaseError("Connection lost"), # Third call raises
]
assert get_item(1)["id"] == 1
assert get_item(2)["id"] == 2
with pytest.raises(DatabaseError):
get_item(3)</side_effects>
<mock_context_manager>
Mock file operations and context managers:
def test_file_operations(mocker):
mock_open = mocker.patch(
"builtins.open",
mocker.mock_open(read_data="test content")
)
result = read_config("/fake/path")
assert result == "test content"
mock_open.assert_called_once_with("/fake/path", "r")</mock_context_manager>
<mock_async>
Mock async functions:
@pytest.mark.asyncio
async def test_async_api(mocker):
mock_fetch = mocker.patch("mymodule.async_fetch")
mock_fetch.return_value = {"data": "test"}
result = await process_data()
assert result["data"] == "test"For coroutines that need to be awaited:
@pytest.mark.asyncio
async def test_async_coroutine(mocker):
async def mock_coro():
return {"data": "test"}
mocker.patch("mymodule.async_fetch", side_effect=mock_coro)
result = await process_data()
assert result["data"] == "test"</mock_async>
<mock_property>
Mock class properties:
def test_property(mocker):
mocker.patch.object(MyClass, "config", new_callable=mocker.PropertyMock, return_value={"key": "value"})
obj = MyClass()
assert obj.config["key"] == "value"</mock_property>
<mock_environment>
Mock environment variables:
def test_env_var(mocker):
mocker.patch.dict("os.environ", {"API_KEY": "test-key"})
result = get_api_key()
assert result == "test-key"</mock_environment>
<spy_pattern>
Spy on real functions (call real implementation but track calls):
def test_spy(mocker):
spy = mocker.spy(mymodule, "real_function")
result = mymodule.real_function("arg")
# Real function was called
spy.assert_called_once_with("arg")
assert result == expected_real_result</spy_pattern>
<mock_assertions>
Common mock assertions:
mock.assert_called() # Called at least once
mock.assert_called_once() # Called exactly once
mock.assert_called_with(arg1, arg2) # Last call had these args
mock.assert_called_once_with(arg1) # Called once with these args
mock.assert_not_called() # Never called
mock.assert_has_calls([call(1), call(2)]) # Called with these in order
# Access call information
mock.call_count # Number of calls
mock.call_args # Last call's args
mock.call_args_list # All calls' args</mock_assertions>
Parametrization Reference
<basic_parametrize>
Run the same test with different inputs:
@pytest.mark.parametrize("input,expected", [
(1, 2),
(2, 4),
(3, 6),
(0, 0),
(-1, -2),
])
def test_double(input, expected):
assert double(input) == expected</basic_parametrize>
<parametrize_with_ids>
Add descriptive test IDs for better output:
@pytest.mark.parametrize(
"a,b,expected",
[
pytest.param(2, 3, 5, id="positive"),
pytest.param(-1, 1, 0, id="zero_result"),
pytest.param(-2, -3, -5, id="negative"),
pytest.param(0, 0, 0, id="zeros"),
]
)
def test_add(a, b, expected):
assert add(a, b) == expectedOutput shows: test_add[positive], test_add[zero_result], etc.
</parametrize_with_ids>
<stacking_parametrize>
Combine multiple parametrize decorators for cartesian product:
@pytest.mark.parametrize("x", [1, 2])
@pytest.mark.parametrize("y", [10, 20])
def test_combinations(x, y):
# Runs 4 tests: (1,10), (1,20), (2,10), (2,20)
assert x * y > 0</stacking_parametrize>
<parametrized_fixtures>
Parametrize at fixture level:
@pytest.fixture(params=["sqlite", "postgres", "mysql"])
def database(request):
"""Test against multiple database backends."""
db = create_database(request.param)
yield db
db.cleanup()
def test_query(database):
# This test runs 3 times, once per database
result = database.execute("SELECT 1")
assert result == 1</parametrized_fixtures>
<edge_case_parametrize>
Test edge cases systematically:
@pytest.mark.parametrize("invalid_input", [
None,
"",
[],
{},
-1,
float("inf"),
"not-a-number",
])
def test_handles_invalid_input(invalid_input):
with pytest.raises((ValueError, TypeError)):
process(invalid_input)</edge_case_parametrize>
<conditional_parametrize>
Skip certain parameter combinations:
@pytest.mark.parametrize("browser,platform", [
("chrome", "windows"),
("chrome", "mac"),
pytest.param("safari", "windows", marks=pytest.mark.skip(reason="Safari not on Windows")),
("safari", "mac"),
])
def test_browser_platform(browser, platform):
launch_browser(browser, platform)</conditional_parametrize>
<indirect_parametrize>
Pass params through fixtures:
@pytest.fixture
def user_type(request):
"""Create user based on parameter."""
if request.param == "admin":
return create_admin_user()
return create_regular_user()
@pytest.mark.parametrize("user_type", ["admin", "regular"], indirect=True)
def test_user_permissions(user_type):
assert user_type.can_access_dashboard()</indirect_parametrize>
Patterns Reference
<markers>
Built-in markers:
@pytest.mark.skip(reason="Not implemented yet")
def test_future_feature():
pass
@pytest.mark.skipif(sys.version_info < (3, 10), reason="Requires Python 3.10+")
def test_new_syntax():
pass
@pytest.mark.xfail(reason="Known bug, ticket #123")
def test_known_bug():
assert buggy_function() == expected
@pytest.mark.slow
def test_slow_operation():
time.sleep(10)Custom markers in pytest.ini:
[pytest]
markers =
slow: marks tests as slow (deselect with '-m "not slow"')
integration: marks tests as integration tests
smoke: marks tests for smoke testingRunning with markers:
pytest -m slow # Run only slow tests
pytest -m "not slow" # Skip slow tests
pytest -m "integration or smoke" # Run either</markers>
<exception_testing>
Basic exception testing:
def test_division_by_zero():
with pytest.raises(ZeroDivisionError):
divide(10, 0)Check exception message:
def test_exception_message():
with pytest.raises(ValueError) as exc_info:
validate_age(-1)
assert "must be positive" in str(exc_info.value)Match with regex:
def test_exception_with_match():
with pytest.raises(ValueError, match=r"invalid .* format"):
parse_date("not-a-date")Check exception attributes:
def test_exception_attributes():
with pytest.raises(CustomError) as exc_info:
risky_operation()
assert exc_info.value.error_code == 500</exception_testing>
<assertions>
Plain asserts (preferred):
def test_user():
user = get_user(1)
assert user is not None
assert user.name == "Alice"
assert user.age >= 18
assert "admin" in user.rolesApproximate comparisons (floats):
def test_floating_point():
result = calculate_pi()
assert result == pytest.approx(3.14159, rel=1e-5)
def test_list_approx():
result = [0.1 + 0.2, 0.3]
assert result == pytest.approx([0.3, 0.3])Collection assertions:
def test_collections():
result = get_items()
assert len(result) == 3
assert "apple" in result
assert set(result) == {"apple", "banana", "cherry"}
assert result == ["apple", "banana", "cherry"] # Order matters</assertions>
<async_testing>
Basic async test:
import pytest
@pytest.mark.asyncio
async def test_async_function():
result = await async_operation()
assert result == expectedAsync fixtures:
@pytest.fixture
async def async_client():
client = await create_async_client()
yield client
await client.close()
@pytest.mark.asyncio
async def test_with_async_fixture(async_client):
response = await async_client.get("/api/data")
assert response.status == 200Configure in pyproject.toml:
[tool.pytest.ini_options]
asyncio_mode = "auto" # Auto-detect async tests</async_testing>
<test_independence>
BAD - tests depend on each other:
class TestUserBad:
user = None
def test_create_user(self):
TestUserBad.user = create_user("test")
assert TestUserBad.user.id is not None
def test_get_user(self):
# Fails if test_create_user didn't run first!
user = get_user(TestUserBad.user.id)
assert user.name == "test"GOOD - each test is independent:
class TestUserGood:
@pytest.fixture
def user(self):
return create_user("test")
def test_create_user(self, user):
assert user.id is not None
def test_get_user(self, user):
fetched = get_user(user.id)
assert fetched.name == "test"</test_independence>
<global_state>
BAD - modifies global state:
def test_set_config():
global_config["debug"] = True
assert app.debug_mode() == True
# Other tests may fail!GOOD - fixture manages state:
@pytest.fixture(autouse=True)
def reset_config():
original = global_config.copy()
yield
global_config.clear()
global_config.update(original)</global_state>
<pytest_ini>
Standard pytest.ini:
[pytest]
testpaths = tests
python_files = test_*.py
python_functions = test_*
python_classes = Test*
addopts = -v --tb=short
filterwarnings =
ignore::DeprecationWarning
markers =
slow: marks tests as slow
integration: integration testsOr in pyproject.toml:
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-v --tb=short"
markers = [
"slow: marks tests as slow",
"integration: integration tests",
]</pytest_ini>
<edge_cases_checklist>
Always test:
- Empty inputs (
"",[],{},None) - Boundary values (0, -1, max_int, min_int)
- Invalid inputs (wrong types, malformed data)
- Error conditions (network failures, file not found)
- Concurrent access (if applicable)
- Unicode and special characters
- Large inputs (performance edge cases)
</edge_cases_checklist>