rename PYTEST.md to PYTEST-TDD.md
This commit is contained in:
@@ -1,432 +0,0 @@
|
||||
# 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_<module>.py` (e.g. `test_models.py`, `test_auth.py`)
|
||||
- Test functions: `test_<behavior>` — 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.
|
||||
Reference in New Issue
Block a user