# SkillPatch skill: pytest-skill

This skill generates production-grade pytest tests in Python, covering fixtures, parametrize, markers, mocking, and conftest patterns. It provides concrete code examples for common testing scenarios including exception handling, database connections, API mocking, and test parametrization. It also includes a quick reference for pytest CLI commands and highlights common anti-patterns to avoid.

You (the agent) were given this URL and asked to use this skill. This is a **public** skill — no SkillPatch account, API key, or prior setup is required. Two ways to use it:

**1. Use it right now** — the skill's complete file tree (SKILL.md + all reference files) is inlined below; read `SKILL.md` first, then follow it, consulting the other files as it directs.

**2. Install the exact package onto disk** (recommended if you can run a shell — this reproduces the skill byte-for-byte, including any binary assets that can't be inlined):

```bash
mkdir -p .claude/skills/pytest-skill
curl -sSL https://skillpatch.dev/install_skill/pytest-skill | tar -xz -C .claude/skills/
```

(`.claude/skills/` is Claude Code's convention; use whatever directory your agent loads skills from.)


---

## Skill files (3)

- `SKILL.md`
- `reference/advanced-patterns.md`
- `reference/playbook.md`


### `SKILL.md`

````markdown
---
name: pytest-skill
description: >
  Generates production-grade pytest tests in Python with fixtures, parametrize,
  markers, mocking, and conftest patterns. Use when user mentions "pytest",
  "conftest", "@pytest.fixture", "@pytest.mark", "Python test". Triggers on:
  "pytest", "conftest", "Python test", "parametrize", "Python unit test".
languages:
  - Python
category: unit-testing
license: MIT
metadata:
  author: TestMu AI
  version: "1.0"
---

# Pytest Testing Skill

## Core Patterns

### Basic Test

```python
import pytest

def test_addition():
    assert 2 + 3 == 5

def test_exception():
    with pytest.raises(ValueError, match="invalid"):
        int("not_a_number")

class TestCalculator:
    def test_add(self):
        calc = Calculator()
        assert calc.add(2, 3) == 5

    def test_divide_by_zero(self):
        with pytest.raises(ZeroDivisionError):
            Calculator().divide(10, 0)
```

### Fixtures

```python
@pytest.fixture
def calculator():
    return Calculator()

@pytest.fixture
def db_connection():
    conn = Database.connect("test_db")
    yield conn  # teardown after yield
    conn.rollback()
    conn.close()

@pytest.fixture(scope="module")
def api_client():
    client = APIClient(base_url="http://localhost:8000")
    yield client
    client.logout()

# conftest.py - shared fixtures
@pytest.fixture(autouse=True)
def reset_state():
    State.reset()
    yield
    State.cleanup()

# Usage
def test_add(calculator):
    assert calculator.add(2, 3) == 5
```

### Parametrize

```python
@pytest.mark.parametrize("input,expected", [
    ("hello", 5), ("", 0), ("pytest", 6),
])
def test_string_length(input, expected):
    assert len(input) == expected

@pytest.mark.parametrize("a,b,expected", [
    (2, 3, 5), (-1, 1, 0), (0, 0, 0),
])
def test_add(calculator, a, b, expected):
    assert calculator.add(a, b) == expected
```

### Markers

```python
@pytest.mark.slow
def test_large_dataset(): ...

@pytest.mark.skip(reason="Not implemented")
def test_future_feature(): ...

@pytest.mark.skipif(sys.platform == "win32", reason="Unix only")
def test_unix_permissions(): ...

@pytest.mark.xfail(reason="Known bug #123")
def test_known_bug(): ...
```

### Mocking

```python
from unittest.mock import patch, MagicMock

def test_send_email(mocker):
    mock_smtp = mocker.patch("myapp.email.smtplib.SMTP")
    send_welcome_email("user@test.com")
    mock_smtp.return_value.sendmail.assert_called_once()

def test_api_call(mocker):
    mock_response = mocker.Mock()
    mock_response.status_code = 200
    mock_response.json.return_value = {"users": [{"name": "Alice"}]}
    mocker.patch("myapp.service.requests.get", return_value=mock_response)
    users = get_users()
    assert len(users) == 1

@patch("myapp.service.database")
def test_save_user(mock_db):
    mock_db.save.return_value = True
    assert save_user({"name": "Alice"}) is True
    mock_db.save.assert_called_once()
```

### Assertions

```python
assert x == y
assert x != y
assert x in collection
assert isinstance(obj, MyClass)
assert 0.1 + 0.2 == pytest.approx(0.3)

with pytest.raises(ValueError) as exc_info:
    raise ValueError("bad")
assert "bad" in str(exc_info.value)
```

### Anti-Patterns

| Bad | Good | Why |
|-----|------|-----|
| `self.assertEqual()` | `assert x == y` | pytest rewrites give better output |
| Setup in `__init__` | `@pytest.fixture` | Lifecycle management |
| Global state | Fixture with `yield` | Proper cleanup |
| Huge test functions | Small focused tests | Easier debugging |

## Quick Reference

| Task | Command |
|------|---------|
| Run all | `pytest` |
| Run file | `pytest tests/test_login.py` |
| Run specific | `pytest tests/test_login.py::test_login_success` |
| By marker | `pytest -m slow` |
| By keyword | `pytest -k "login and not invalid"` |
| Verbose | `pytest -v` |
| Stop first fail | `pytest -x` |
| Last failed | `pytest --lf` |
| Coverage | `pytest --cov=myapp --cov-report=html` |
| Parallel | `pytest -n auto` (pytest-xdist) |

## pyproject.toml

```toml
[tool.pytest.ini_options]
testpaths = ["tests"]
markers = ["slow: slow tests", "integration: integration tests"]
addopts = "-v --tb=short"
```

## Deep Patterns

For production-grade patterns, see `reference/playbook.md`:

| Section | What's Inside |
|---------|--------------|
| §1 Config | pytest.ini + pyproject.toml with markers, coverage |
| §2 Fixtures | Scoping, factories, teardown, autouse, tmp_path |
| §3 Parametrize | Basic, with IDs, cartesian, indirect |
| §4 Mocking | pytest-mock, monkeypatch, spies, env vars |
| §5 Async | pytest-asyncio, async fixtures, async client |
| §6 Exceptions | pytest.raises(match=), warnings |
| §7 Markers & Plugins | Custom markers, collection hooks |
| §8 Class-Based | Nested classes, autouse setup |
| §9 CI/CD | GitHub Actions matrix, coverage gates |
| §10 Debugging Table | 10 common problems with fixes |
| §11 Best Practices | 15-item production checklist |

````


### `reference/advanced-patterns.md`

````markdown
# Pytest — Advanced Patterns & Playbook

## Fixture Patterns

```python
import pytest
from unittest.mock import AsyncMock, patch, MagicMock

# Scoped fixtures with teardown
@pytest.fixture(scope="session")
def db_engine():
    engine = create_engine("postgresql://localhost/test")
    Base.metadata.create_all(engine)
    yield engine
    Base.metadata.drop_all(engine)
    engine.dispose()

@pytest.fixture
def db_session(db_engine):
    connection = db_engine.connect()
    transaction = connection.begin()
    session = Session(bind=connection)
    yield session
    session.close()
    transaction.rollback()
    connection.close()

# Factory fixture
@pytest.fixture
def user_factory(db_session):
    created = []
    def _create(name="Alice", email=None):
        user = User(name=name, email=email or f"{name.lower()}@test.com")
        db_session.add(user)
        db_session.flush()
        created.append(user)
        return user
    yield _create
    for u in created:
        db_session.delete(u)

# Parameterized fixture
@pytest.fixture(params=["sqlite", "postgres"])
def database(request):
    db = connect(request.param)
    yield db
    db.close()

# Fixture with indirect parametrize
@pytest.fixture
def http_client(request):
    timeout = getattr(request, 'param', 30)
    return HttpClient(timeout=timeout)

@pytest.mark.parametrize("http_client", [5, 10, 30], indirect=True)
def test_with_different_timeouts(http_client):
    assert http_client.timeout in (5, 10, 30)
```

## Advanced Parametrize

```python
# Matrix parametrize
@pytest.mark.parametrize("x", [1, 2])
@pytest.mark.parametrize("y", [10, 20])
def test_multiply(x, y):
    assert x * y > 0  # Generates 4 tests: (1,10), (1,20), (2,10), (2,20)

# Conditional skip
@pytest.mark.parametrize("browser", [
    "chrome",
    "firefox",
    pytest.param("safari", marks=pytest.mark.skipif(sys.platform != "darwin", reason="macOS only"))
])
def test_browser(browser): pass

# ID customization
@pytest.mark.parametrize("input,expected", [
    pytest.param("hello", 5, id="simple-word"),
    pytest.param("", 0, id="empty-string"),
    pytest.param("a b c", 5, id="with-spaces"),
])
def test_length(input, expected):
    assert len(input) == expected
```

## Mocking Patterns

```python
# Patch decorator
@patch("myapp.services.requests.get")
def test_fetch_user(mock_get):
    mock_get.return_value.json.return_value = {"id": 1, "name": "Alice"}
    mock_get.return_value.status_code = 200
    user = fetch_user(1)
    assert user.name == "Alice"
    mock_get.assert_called_once_with("https://api.example.com/users/1")

# Context manager patch
def test_with_context():
    with patch("myapp.db.Session") as MockSession:
        session = MockSession.return_value.__enter__.return_value
        session.query.return_value.first.return_value = User(id=1)
        result = get_user(1)
        assert result.id == 1

# Async mocking
@pytest.mark.asyncio
async def test_async_service():
    with patch("myapp.client.fetch", new_callable=AsyncMock) as mock:
        mock.return_value = {"data": "test"}
        result = await process_data()
        assert result == "test"

# Mock property
def test_property():
    with patch.object(type(obj), 'prop', new_callable=PropertyMock, return_value=42):
        assert obj.prop == 42
```

## Plugin Ecosystem

```python
# conftest.py — production-grade
import pytest

def pytest_addoption(parser):
    parser.addoption("--env", default="test", choices=["test", "staging", "prod"])

@pytest.fixture
def env(request):
    return request.config.getoption("--env")

def pytest_collection_modifyitems(config, items):
    """Auto-mark slow tests, skip them unless --runslow."""
    if not config.getoption("--runslow", default=False):
        skip_slow = pytest.mark.skip(reason="use --runslow to run")
        for item in items:
            if "slow" in item.keywords:
                item.add_marker(skip_slow)

# Custom marker
def pytest_configure(config):
    config.addinivalue_line("markers", "slow: marks tests as slow")
    config.addinivalue_line("markers", "integration: marks integration tests")
```

## Configuration

```ini
# pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = [
    "-ra",                    # Show summary of all non-passing
    "--strict-markers",       # Error on unknown markers
    "--strict-config",        # Error on config issues
    "-x",                     # Stop on first failure
    "--tb=short",             # Short traceback
    "--cov=src",              # Coverage for src/
    "--cov-report=term-missing",
    "--cov-fail-under=80",
]
markers = [
    "slow: marks tests as slow",
    "integration: marks integration tests",
    "e2e: end-to-end tests",
]
filterwarnings = ["error", "ignore::DeprecationWarning"]
```

## Async Testing

```python
import pytest
import asyncio

@pytest.mark.asyncio
async def test_concurrent_requests():
    results = await asyncio.gather(
        fetch("/api/users"), fetch("/api/products"), fetch("/api/orders")
    )
    assert all(r.status == 200 for r in results)

@pytest.fixture
async def async_client():
    async with AsyncClient(app=app, base_url="http://test") as client:
        yield client

@pytest.mark.asyncio
async def test_api_endpoint(async_client):
    response = await async_client.post("/users", json={"name": "Alice"})
    assert response.status_code == 201
```

## Anti-Patterns

- ❌ `assert True` or `assert result` without checking specific values
- ❌ `@pytest.fixture(autouse=True)` at module scope — hidden side effects
- ❌ Fixtures that do too much — split into focused, composable fixtures
- ❌ Hardcoded test data paths — use `tmp_path` fixture
- ❌ `time.sleep()` in tests — use `pytest-timeout` and mock time
- ❌ Catching exceptions in test code — let pytest handle assertion errors

````


### `reference/playbook.md`

````markdown
# pytest — Advanced Implementation Playbook

## §1 — Production Configuration

```ini
# pytest.ini
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = -v --strict-markers --tb=short -q
markers =
    slow: marks tests as slow (deselect with '-m "not slow"')
    integration: marks integration tests
    smoke: marks smoke tests
    api: marks API tests
filterwarnings =
    error
    ignore::DeprecationWarning
```

```toml
# pyproject.toml (alternative)
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-v --strict-markers --tb=short"
markers = [
    "slow: marks tests as slow",
    "integration: integration tests",
    "smoke: smoke tests",
]

[tool.coverage.run]
source = ["src"]
omit = ["tests/*", "*/migrations/*"]

[tool.coverage.report]
fail_under = 80
show_missing = true
```

## §2 — Fixtures (Scoping, Factories, Teardown)

```python
# conftest.py — shared fixtures
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session

# Session-scoped: created once per test session
@pytest.fixture(scope="session")
def engine():
    engine = create_engine("sqlite:///test.db")
    Base.metadata.create_all(engine)
    yield engine
    Base.metadata.drop_all(engine)

# Function-scoped: created per test (default), auto-cleanup
@pytest.fixture
def db_session(engine):
    connection = engine.connect()
    transaction = connection.begin()
    session = Session(bind=connection)
    yield session
    session.close()
    transaction.rollback()
    connection.close()

# Factory fixture — create multiple instances
@pytest.fixture
def user_factory(db_session):
    created = []
    def _create_user(name="Test User", email=None, role="viewer"):
        email = email or f"{name.lower().replace(' ', '.')}@test.com"
        user = User(name=name, email=email, role=role)
        db_session.add(user)
        db_session.commit()
        created.append(user)
        return user
    yield _create_user
    for user in created:
        db_session.delete(user)
    db_session.commit()

# Autouse fixture — runs for every test in module
@pytest.fixture(autouse=True)
def reset_cache():
    cache.clear()
    yield
    cache.clear()

# tmp_path for file operations (built-in)
def test_writes_output(tmp_path):
    output_file = tmp_path / "result.json"
    generate_report(output_file)
    assert output_file.exists()
    data = json.loads(output_file.read_text())
    assert data["status"] == "complete"
```

## §3 — Parameterized Tests

```python
import pytest

# Basic parametrize
@pytest.mark.parametrize("input,expected", [
    ("hello", "HELLO"),
    ("world", "WORLD"),
    ("", ""),
    ("123", "123"),
])
def test_uppercase(input, expected):
    assert input.upper() == expected

# Multiple parameters with IDs
@pytest.mark.parametrize("email,valid", [
    ("user@test.com", True),
    ("invalid", False),
    ("", False),
    ("user@.com", False),
], ids=["valid_email", "no_at_sign", "empty", "missing_domain"])
def test_validate_email(email, valid):
    assert validate_email(email) == valid

# Combine parametrize (cartesian product)
@pytest.mark.parametrize("browser", ["chrome", "firefox", "edge"])
@pytest.mark.parametrize("resolution", ["1920x1080", "1366x768", "375x667"])
def test_responsive_layout(browser, resolution):
    assert render_page(browser, resolution).is_valid()

# Indirect parametrize (pass to fixture)
@pytest.fixture
def user(request):
    return create_user(role=request.param)

@pytest.mark.parametrize("user", ["admin", "editor", "viewer"], indirect=True)
def test_permissions(user):
    assert user.can_view()
```

## §4 — Mocking with pytest-mock

```python
# pip install pytest-mock

def test_send_email(mocker):
    mock_smtp = mocker.patch("myapp.email.smtplib.SMTP")
    send_email("test@example.com", "Hello", "Body")
    mock_smtp.return_value.sendmail.assert_called_once()

def test_api_call(mocker):
    mock_get = mocker.patch("myapp.api.requests.get")
    mock_get.return_value.json.return_value = {"id": 1, "name": "Alice"}
    mock_get.return_value.status_code = 200
    user = get_user(1)
    assert user["name"] == "Alice"
    mock_get.assert_called_once_with("https://api.example.com/users/1")

def test_database_error(mocker):
    mocker.patch("myapp.db.session.commit", side_effect=IntegrityError("duplicate"))
    with pytest.raises(DuplicateError):
        create_user("Alice", "alice@test.com")

# Spy — track calls without replacing
def test_logging(mocker):
    spy = mocker.spy(logger, "info")
    process_order(order)
    spy.assert_called_with("Order processed: %s", order.id)

# Mock environment variables
def test_config(monkeypatch):
    monkeypatch.setenv("API_KEY", "test-key-123")
    monkeypatch.setenv("DEBUG", "true")
    config = load_config()
    assert config.api_key == "test-key-123"
    assert config.debug is True
```

## §5 — Async Testing

```python
# pip install pytest-asyncio

import pytest

@pytest.mark.asyncio
async def test_async_fetch():
    result = await fetch_data("https://api.example.com/data")
    assert result["status"] == "ok"

@pytest.mark.asyncio
async def test_async_exception():
    with pytest.raises(ConnectionError):
        await fetch_data("https://invalid.example.com")

# Async fixtures
@pytest.fixture
async def async_client():
    async with AsyncClient(app=app, base_url="http://test") as client:
        yield client

@pytest.mark.asyncio
async def test_api_endpoint(async_client):
    response = await async_client.get("/api/users")
    assert response.status_code == 200
    assert len(response.json()) > 0
```

## §6 — Testing Exceptions & Warnings

```python
# Exception testing
def test_division_by_zero():
    with pytest.raises(ZeroDivisionError):
        1 / 0

def test_error_message():
    with pytest.raises(ValueError, match=r".*invalid email.*"):
        validate_email("not-an-email")

def test_raises_with_info():
    with pytest.raises(PermissionError) as exc_info:
        delete_file("/protected/file.txt")
    assert "permission denied" in str(exc_info.value).lower()
    assert exc_info.value.errno == 13

# Warning testing
def test_deprecation_warning():
    with pytest.warns(DeprecationWarning, match="use new_func"):
        old_func()
```

## §7 — Markers & Custom Plugins

```python
# Custom marker usage
@pytest.mark.slow
def test_full_data_processing():
    result = process_large_dataset()
    assert result.row_count > 1_000_000

@pytest.mark.integration
def test_database_connection():
    assert db.is_connected()

# Run by marker: pytest -m "not slow"
# Run by marker: pytest -m "smoke and not integration"

# Custom plugin — conftest.py
def pytest_collection_modifyitems(config, items):
    """Auto-mark tests in integration/ directory"""
    for item in items:
        if "integration" in str(item.fspath):
            item.add_marker(pytest.mark.integration)

# Custom report header
def pytest_report_header(config):
    return f"Environment: {os.getenv('ENV', 'local')}"
```

## §8 — Class-Based Test Organization

```python
class TestUserService:
    @pytest.fixture(autouse=True)
    def setup(self, db_session, user_factory):
        self.db = db_session
        self.create_user = user_factory
        self.service = UserService(db_session)

    def test_create_user(self):
        user = self.service.create("Alice", "alice@test.com")
        assert user.id is not None
        assert user.name == "Alice"

    def test_find_by_email(self):
        self.create_user(name="Bob", email="bob@test.com")
        user = self.service.find_by_email("bob@test.com")
        assert user.name == "Bob"

    def test_delete_nonexistent(self):
        with pytest.raises(NotFoundError):
            self.service.delete(999)

    class TestPermissions:
        """Nested class for permission-related tests"""
        def test_admin_can_delete(self, user_factory):
            admin = user_factory(role="admin")
            assert admin.can_delete()

        def test_viewer_cannot_delete(self, user_factory):
            viewer = user_factory(role="viewer")
            assert not viewer.can_delete()
```

## §9 — CI/CD Integration

```yaml
# GitHub Actions
name: Python Tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ['3.10', '3.11', '3.12']
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: '${{ matrix.python-version }}' }
      - name: Install deps
        run: pip install -r requirements-test.txt
      - name: Run tests
        run: pytest --cov=src --cov-report=xml --junitxml=results.xml -v
      - name: Upload coverage
        uses: codecov/codecov-action@v4
        with: { files: coverage.xml }
      - name: Upload results
        uses: actions/upload-artifact@v4
        if: always()
        with: { name: test-results-${{ matrix.python-version }}, path: results.xml }
```

## §10 — Debugging Quick-Reference

| Problem | Cause | Fix |
|---------|-------|-----|
| Fixture not found | Wrong scope or missing conftest.py | Check conftest.py location, fixture name |
| `ScopeMismatch` | Function fixture depends on session fixture | Match scope: session → module → function |
| Tests interfere | Shared mutable state | Use function-scoped fixtures, `autouse` cleanup |
| Parametrize fails | Wrong number of params | Ensure tuple count matches parameter names |
| Slow collection | Too many test paths | Set `testpaths` in pytest.ini |
| Async test hangs | Missing `@pytest.mark.asyncio` | Add marker or set `asyncio_mode = "auto"` |
| Coverage wrong | Source path mismatch | Set `source` in `[tool.coverage.run]` |
| Import errors | Missing `__init__.py` or bad path | Add `__init__.py` or use `src` layout with `--import-mode=importlib` |
| Monkeypatch not reverting | Using at module scope | Only use in function-scoped fixtures |
| Marker warnings | Marker not registered | Add to `markers` in pytest.ini |

## §11 — Best Practices Checklist

- ✅ Use fixtures over setup/teardown methods
- ✅ Use `conftest.py` for shared fixtures (auto-discovered)
- ✅ Use `tmp_path` for file operations (built-in, auto-cleanup)
- ✅ Use `monkeypatch` for env vars and attribute patching
- ✅ Use `pytest-mock` (mocker fixture) over `unittest.mock`
- ✅ Use `@pytest.mark.parametrize` for data-driven tests
- ✅ Register all custom markers in `pytest.ini`
- ✅ Use `--strict-markers` to catch typos in marker names
- ✅ Use `pytest-cov` for coverage with `--cov-fail-under=80`
- ✅ Use `pytest-xdist` for parallel execution: `pytest -n auto`
- ✅ Use `--tb=short` for concise tracebacks in CI
- ✅ Structure: `tests/unit/`, `tests/integration/`, `conftest.py`
- ✅ Name files `test_*.py` and functions `test_*`
- ✅ Use factory fixtures for creating test objects
- ✅ Use `pytest.raises(match=...)` for precise error checking

````
