| name | unit-tests |
| description | Write pytest tests. Use when implementing features, fixing bugs, or when the user mentions testing, TDD, or pytest |
| allowed-tools | Read, Grep, Glob |
Testing
This Skill helps implement features and fix bugs using Test-Driven Development, following the Red-Green-Refactor cycle.
When to Use This Skill
Use this Skill when:
- Implementing new features - Write tests first, then implement
- Fixing bugs - Write failing test, then fix
- User mentions "TDD", "test", "pytest"
- Adding functionality - Use TDD to design it
- Refactoring code - Ensure tests pass throughout
Setup
- Framework: pytest (pytest-django)
- Settings:
pyproject.toml
- Default settings:
config.settings_dev
- Run specific test:
pytest path/to/test_file.py::test_name
Creating Test Data
Use factory_boy factories for all test data creation. Never use .objects.create() directly.
- Import factories from the app's
factories.py (e.g., from freedom_ls.accounts.factories import UserFactory)
- Override only the fields relevant to the test; let factories provide sensible defaults
- Always check existing factories before creating new ones
- See the factory-boy skill for the full factory reference
- Avoid creating fixtures that are thin wrappers around factories. Rather just use the factories
Key Rules
- Test files:
freedom_ls/<app_name>/tests/test_<module>.py
- Use
@pytest.mark.django_db for database tests
- Use
mock_site_context fixture for site-aware models — never manually set site
- Use factory_boy factories for all test data creation — never use
.objects.create() directly
- Use
reverse() for URLs, never hardcode
- No conditionals in tests — one assertion focus per test
- TDD cycle: RED (failing test) -> GREEN (minimal code) -> REFACTOR -> REPEAT
Test Patterns
Model Tests
@pytest.mark.django_db
def test_model_method(mock_site_context):
"""Test a specific model method."""
instance = MyModelFactory(field1="value")
result = instance.some_method()
assert result == expected_value
View Tests
@pytest.mark.django_db
def test_endpoint(client, mock_site_context):
"""Test endpoint returns expected response."""
user = UserFactory()
client.force_login(user)
response = client.get(reverse('app:endpoint'))
assert response.status_code == 200
assert response.context['key'] == expected_data
Utility Tests
def test_utility_function():
"""Test utility function."""
result = utility_function(input_value)
assert result == expected_output
Error Tests
@pytest.mark.django_db
def test_requires_field(mock_site_context):
"""Test that field is required."""
with pytest.raises(IntegrityError):
MyModelFactory(required_field=None)
Writing High-Value Tests
Every test must justify its existence. A test has value when it catches real bugs, documents important behaviour, or protects against meaningful regressions. Tests that merely exercise code without asserting anything interesting are noise.
What to test
- Business logic and domain rules — the core "why" of the feature. If a method calculates scores, enforces permissions, or applies rules, test those rules thoroughly.
- Edge cases that have bitten you — empty collections, None values, boundary conditions, off-by-one errors.
- Integration points — where two systems meet (view + model, serializer + database, HTMX partial + context). These are where bugs hide.
- Error paths that matter — invalid user input, missing objects, permission denied. Only test error handling that could realistically occur.
What NOT to test
- Django/framework internals — don't test that
CharField stores strings, that ForeignKey creates a column, or that reverse() resolves a URL you just defined. Trust the framework.
- Trivial CRUD with no logic — a model with only auto-generated fields and no custom methods rarely needs its own test. If
MyModelFactory() succeeds, you already know the model works.
- Implementation details — don't assert the exact SQL query, the number of times a method was called, or internal state that could change during refactoring. Test observable behaviour, not how it's achieved.
- Duplicate coverage — if one test already proves a code path works, don't write another that proves the same thing with slightly different data unless the variation exercises a genuinely different branch.
Qualities of a good test
- Focused — tests exactly one behaviour or rule. If the test name needs "and" in it, split it.
- Readable — a developer can understand what's being tested and why without reading the implementation. The test name and docstring are the spec.
- Resilient — doesn't break when you refactor internals. Tests that are tightly coupled to implementation details create drag, not safety.
- Fast — avoids unnecessary setup. Only create the data the test actually needs.
- Honest — fails when the behaviour is broken, passes when it works. No tests that pass by coincidence.
Red flags in tests
- A test with no meaningful assertions (or only
assert response.status_code == 200 when the view does complex work)
- A test that creates 10 objects but only uses 2
- Multiple tests that are copy-pasted with one field changed — use
@pytest.mark.parametrize
- A test that mocks so much that it's no longer testing real behaviour
- A test file with 50 tests for a model that has 2 methods
Writing Tests
- Use descriptive names explaining what's tested
- Include docstrings
- Use
@pytest.mark.django_db for database tests
- Use
mock_site_context for site-aware models
- Write one test at a time
- No conditionals in tests - test one path at a time
Assertions
- Be explicit:
assert result == [] NOT assert type(result) is list
- No if statements in tests
- Use
pytest.raises for exceptions
- Assert exact values, not types
Site-Aware Models
Always use mock_site_context fixture:
@pytest.mark.django_db
def test_creation(mock_site_context):
instance = MyModelFactory()
assert instance.site is not None
Don't manually set site - mock_site_context handles it automatically.
Keep Tests DRY
Avoid repetition:
- Use factories with overrides instead of duplicating setup code
- Create helper functions for complex multi-step setup
- Use parameterized tests
TDD Workflow
Red-Green-Refactor Cycle
- RED - Write failing test (verify it fails)
- GREEN - Write minimal code to pass
- REFACTOR - Improve design (tests still pass)
- REPEAT - Next test
IMPORTANT: Do not forget the refactor step. All tests should be clean and DRY!
New Features
- Understand requirements (models, views, behavior, edge cases)
- Follow RED -> GREEN -> REFACTOR -> REPEAT
Bug Fixes
- Understand bug - Read code, identify behavior
- Write failing test - Proves bug exists (ask before implementing)
- Verify test fails - Don't continue until it does
- Fix bug - Minimal code
- Verify test passes
- Run all tests - Ensure no regressions
Legacy Code
- Read code
- Check existing tests
- Create test file if needed:
freedom_ls/<app_name>/tests/test_<module>.py
- Write tests one at a time
- Run each test
Test Coverage
Cover these for each feature:
- Happy path
- Edge cases (empty, None, boundaries)
- Error cases (invalid inputs)
- Business logic (custom methods)
- Relationships (ForeignKey, M2M)
- Permissions (if applicable)
Some Guidelines
When testing validation logic: Test the happy and unhappy path. Don't just test things that will pass, assert that validation FAILS when it is supposed to
Never test that a hardcoded configuration value is what it is meant to be. Eg never say assert config.hardcoded_value == [whatever] or assert "something" in config.hardcoded_value
Never test trivial model instance creation. Eg never test that default values are as they should be, or that passed in values are saved unless the model is meant to do something unusual. Assume Django's model implementation works, don't waste time testing it.
Never test trivial Admin panel functionality. Assume Django's Admin interface just works, don't write tests that assert that the admin shows up exactly as it was configured because it will always do that. If you have done something unusual in the admin then test that.