diff --git a/skills/PYTEST.md b/skills/PYTEST.md new file mode 100644 index 0000000..30f3742 --- /dev/null +++ b/skills/PYTEST.md @@ -0,0 +1,432 @@ +# Pytest TDD Workflow + +Guide development using test-driven development (TDD) with pytest. Always write the test first, watch it fail, then write the minimal code to make it pass. Follow the red-green-refactor cycle for every piece of functionality. + +## Instructions + +- **Never write implementation code before its test exists and fails.** +- Follow the numbered workflow below for each unit of functionality. +- Run tests after every change — both after writing the failing test and after writing the implementation. +- If the project lacks pytest configuration or test infrastructure, set it up first (Section 2). +- Use Flask/Quart testing patterns when working on web applications (Section 7). + +--- + +## 1. The Red-Green-Refactor Cycle + +Repeat this cycle for every piece of functionality: + +### Red — Write a failing test +1. Write a test that describes the desired behavior. +2. Run the test. **Confirm it fails.** If it passes, the test is not testing new behavior — revise it. +3. Verify it fails for the *right reason* (e.g. `AssertionError` or `AttributeError`, not `ImportError` or `SyntaxError`). + +### Green — Make it pass +4. Write the **minimum** code needed to make the test pass. No more. +5. Run the test. **Confirm it passes.** + +### Refactor — Clean up +6. Improve the implementation (remove duplication, clarify names, extract functions) while keeping all tests green. +7. Run tests after each refactoring change. + +**Show each step explicitly.** When presenting work, label which phase you are in (Red/Green/Refactor) so the workflow is visible. + +--- + +## 2. Project Setup + +Before writing tests, ensure the project has proper test infrastructure. + +### Directory layout + +``` +project/ +├── src/ # or the package name directly +│ └── myapp/ +│ └── __init__.py +├── tests/ +│ ├── conftest.py # shared fixtures +│ ├── test_models.py +│ └── test_routes.py +└── pyproject.toml +``` + +### pyproject.toml — pytest configuration + +If pytest config does not exist, add it: + +```toml +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-v --tb=short" +``` + +### conftest.py + +Create `tests/conftest.py` for shared fixtures. Keep it organized — if it grows large, split fixtures into multiple `conftest.py` files in subdirectories. + +### Dependencies + +Ensure pytest is installed. Use `uv` for dependency management: + +```bash +uv add --dev pytest +``` + +--- + +## 3. Writing the Failing Test (Red) + +### File naming + +- Test files: `test_.py` (e.g. `test_models.py`, `test_auth.py`) +- Test functions: `test_` — describe what is being tested, not how + +```python +# Good — describes behavior +def test_new_user_has_default_role(): + ... + +def test_login_rejects_invalid_password(): + ... + +# Bad — describes implementation +def test_user_init(): + ... + +def test_check_password_returns_false(): + ... +``` + +### Write one test at a time + +Write a single test, run it, see it fail, then implement. Do not write a batch of tests upfront. + +### Assert clearly + +Each test should have a single, clear assertion (or a small group of closely related assertions). Use plain `assert` statements — pytest's introspection provides detailed failure messages. + +```python +def test_new_user_has_default_role(): + user = User(name="alice") + assert user.role == "viewer" +``` + +### Run and confirm failure + +```bash +pytest tests/test_models.py::test_new_user_has_default_role -v +``` + +Read the failure output. Confirm the error is what you expect (e.g. `NameError: name 'User' is not defined` or `AssertionError`). If the failure is something else (syntax error, import misconfiguration), fix the test before proceeding. + +--- + +## 4. Making the Test Pass (Green) + +Write the **minimal** implementation to make the failing test pass. + +- Do not add features the test does not require. +- Do not handle edge cases that are not yet tested. +- Hard-coding a return value is acceptable if only one test exists — the next test will force a real implementation. + +Run the full test suite, not just the new test: + +```bash +pytest -v +``` + +All tests must pass before moving on. + +--- + +## 5. Refactor + +With all tests green, improve the code: + +- Remove duplication between implementation and tests. +- Rename variables and functions for clarity. +- Extract helper functions or methods. +- Simplify logic. + +**Run the full test suite after every refactoring change.** If a test breaks, undo the last change and try a smaller step. + +Do not add new behavior during refactoring. If you notice missing behavior, go back to Red and write a test for it first. + +--- + +## 6. Fixtures + +Use fixtures for setup code that multiple tests share. + +### Basic fixture + +```python +# tests/conftest.py +import pytest + +@pytest.fixture +def sample_user(): + return User(name="alice", email="alice@example.com") +``` + +```python +# tests/test_models.py +def test_user_display_name(sample_user): + assert sample_user.display_name == "alice" +``` + +### Yield fixtures for setup/teardown + +```python +@pytest.fixture +def db_session(): + session = create_session() + yield session + session.rollback() + session.close() +``` + +### Fixture scope + +| Scope | Lifetime | Use when | +|-------|----------|----------| +| `"function"` (default) | Each test | Most cases — tests stay isolated | +| `"class"` | Each test class | Grouping related tests with shared setup | +| `"module"` | Each test file | Expensive setup shared across a file | +| `"session"` | Entire test run | Very expensive one-time setup (e.g. database creation) | + +Prefer `function` scope. Only widen scope when setup is genuinely expensive and tests do not mutate shared state. + +### conftest.py organization + +- `tests/conftest.py` — fixtures used across the entire test suite +- `tests/api/conftest.py` — fixtures specific to API tests +- Fixtures in a `conftest.py` are available to all tests in that directory and below + +--- + +## 7. Flask/Quart Test Patterns + +### App factory fixture + +```python +# tests/conftest.py +import pytest +from myapp import create_app + +@pytest.fixture +def app(): + app = create_app({ + "TESTING": True, + "SECRET_KEY": "test-secret-key", + # override database URI for tests, etc. + }) + yield app + +@pytest.fixture +def client(app): + return app.test_client() + +@pytest.fixture +def runner(app): + return app.test_cli_runner() +``` + +### Testing routes + +```python +def test_homepage_returns_200(client): + response = client.get("/") + assert response.status_code == 200 + +def test_homepage_contains_title(client): + response = client.get("/") + assert b"Welcome" in response.data +``` + +### Testing POST requests and forms + +```python +def test_login_with_valid_credentials(client): + response = client.post("/login", data={ + "username": "alice", + "password": "correct-password", + }, follow_redirects=True) + assert response.status_code == 200 +``` + +### Testing JSON APIs + +```python +def test_api_returns_user_list(client): + response = client.get("/api/users") + assert response.status_code == 200 + data = response.get_json() + assert isinstance(data, list) +``` + +### Testing with application context + +```python +def test_db_query_within_app_context(app): + with app.app_context(): + users = User.query.all() + assert len(users) >= 0 +``` + +### Testing with request context + +```python +def test_url_generation(app): + with app.test_request_context(): + assert url_for("index") == "/" +``` + +### Quart async tests + +For Quart applications, use `pytest-asyncio`: + +```python +import pytest + +@pytest.fixture +def app(): + app = create_app({"TESTING": True}) + return app + +@pytest.fixture +def client(app): + return app.test_client() + +@pytest.mark.asyncio +async def test_homepage(client): + response = await client.get("/") + assert response.status_code == 200 +``` + +--- + +## 8. Parametrize + +Use `@pytest.mark.parametrize` to test multiple inputs without duplicating test functions. + +### Basic parametrize + +```python +@pytest.mark.parametrize("input_val, expected", [ + ("alice", True), + ("", False), + ("a" * 256, False), + ("bob@", False), +]) +def test_is_valid_username(input_val, expected): + assert is_valid_username(input_val) == expected +``` + +### Testing edge cases systematically + +Think about these categories for each function: + +- **Valid inputs** — normal cases that should succeed +- **Boundary values** — empty strings, zero, max length, off-by-one +- **Invalid inputs** — wrong types, malformed data, None +- **Error cases** — inputs that should raise specific exceptions + +```python +@pytest.mark.parametrize("amount, expected", [ + (100, "$1.00"), # normal + (0, "$0.00"), # zero + (1, "$0.01"), # minimum + (99999, "$999.99"), # large value +]) +def test_format_currency(amount, expected): + assert format_currency(amount) == expected + +@pytest.mark.parametrize("amount", [-1, -100]) +def test_format_currency_rejects_negative(amount): + with pytest.raises(ValueError): + format_currency(amount) +``` + +--- + +## 9. Mocking + +### When to mock + +- **Mock**: external services (APIs, email, payment), system clock, file system operations, slow or nondeterministic operations. +- **Don't mock**: your own code under test, data structures, pure functions. Prefer real objects when practical. + +### monkeypatch (preferred for simple cases) + +```python +def test_get_api_data(monkeypatch): + def mock_get(url): + return MockResponse(json_data={"result": "ok"}) + + monkeypatch.setattr("myapp.services.requests.get", mock_get) + data = get_api_data() + assert data["result"] == "ok" +``` + +### monkeypatch for environment variables + +```python +def test_config_reads_env(monkeypatch): + monkeypatch.setenv("SECRET_KEY", "test-key") + config = load_config() + assert config.secret_key == "test-key" +``` + +### unittest.mock for complex cases + +```python +from unittest.mock import patch, MagicMock + +@patch("myapp.services.send_email") +def test_registration_sends_welcome_email(mock_send): + register_user("alice@example.com") + mock_send.assert_called_once_with( + to="alice@example.com", + subject="Welcome", + ) +``` + +### Testing exceptions + +```python +def test_divide_by_zero_raises(): + with pytest.raises(ZeroDivisionError): + divide(1, 0) + +def test_invalid_input_message(): + with pytest.raises(ValueError, match="must be positive"): + process(-1) +``` + +--- + +## 10. Test Quality Checklist + +After completing a set of tests, review against this checklist: + +- [ ] **Every public function/route has at least one test.** +- [ ] **Tests are isolated** — no test depends on another test's state or execution order. +- [ ] **No hardcoded paths or ports** — use fixtures and configuration. +- [ ] **Assertions are specific** — test exact values, not just truthiness (`assert x == 5`, not `assert x`). +- [ ] **Error paths are tested** — not just the happy path. +- [ ] **Test names describe behavior** — someone reading only test names understands what the code does. +- [ ] **Fixtures are minimal** — each fixture sets up only what is needed, no "god fixtures" with everything. +- [ ] **No logic in tests** — tests should not contain `if`, `for`, or `try/except`. Each test is a straight line. +- [ ] **Mocks are focused** — only mock what is necessary. Over-mocking makes tests brittle. +- [ ] **Tests run fast** — the full suite completes in seconds. Slow tests indicate missing mocks or unnecessary I/O. + +### Running with coverage + +```bash +uv add --dev pytest-cov +pytest --cov=src --cov-report=term-missing -v +``` + +Report untested lines and suggest tests for them.