docs: Add comprehensive testing guidelines to CLAUDE.md
Add testing documentation emphasizing mocked unit tests over slow database-hitting integration tests due to django-tenants overhead. Guidelines include: - Testing pyramid philosophy (prefer unit tests) - Unit test examples with mocks - Serializer and ViewSet testing patterns - When to use integration tests (sparingly) - Repository pattern for testable code - Dependency injection examples - Test file structure conventions - Commands for running tests with coverage 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -192,3 +192,222 @@ You're trying to run Python directly instead of through Docker. Use `docker comp
|
||||
### Tenant (identity/core/models.py)
|
||||
- `name`, `subdomain`, `schema_name`
|
||||
- Multi-tenancy via django-tenants
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
### Testing Philosophy
|
||||
|
||||
Follow the **Testing Pyramid**:
|
||||
```
|
||||
/\
|
||||
/E2E\ <- Few (real browser, full stack) - AVOID
|
||||
/------\
|
||||
/Integr- \ <- Some (real DB, verify ORM/queries)
|
||||
/---ation--\
|
||||
/ Unit \ <- Many (mocked, fast, isolated) - PREFER
|
||||
/--------------\
|
||||
```
|
||||
|
||||
**Why:** Django-tenants requires full PostgreSQL migrations per test, making DB-based tests extremely slow (~30-60 seconds each). Unit tests with mocks run in milliseconds.
|
||||
|
||||
### Unit Tests (Preferred)
|
||||
|
||||
Write fast, isolated unit tests using mocks. **Do NOT use `@pytest.mark.django_db`** for unit tests.
|
||||
|
||||
```python
|
||||
# GOOD: Fast unit test with mocks
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
def test_event_service_creates_event():
|
||||
# Arrange - mock dependencies
|
||||
mock_repo = Mock()
|
||||
mock_repo.save.return_value = Mock(id=1, title="Test Event")
|
||||
|
||||
mock_tenant = Mock()
|
||||
mock_tenant.can_use_feature.return_value = True
|
||||
|
||||
service = EventService(repository=mock_repo, tenant=mock_tenant)
|
||||
|
||||
# Act
|
||||
result = service.create_event(title="Test Event", start_time=datetime.now())
|
||||
|
||||
# Assert
|
||||
mock_repo.save.assert_called_once()
|
||||
assert result.title == "Test Event"
|
||||
|
||||
|
||||
def test_event_service_denies_without_permission():
|
||||
mock_tenant = Mock()
|
||||
mock_tenant.can_use_feature.return_value = False
|
||||
|
||||
service = EventService(repository=Mock(), tenant=mock_tenant)
|
||||
|
||||
with pytest.raises(PermissionDenied):
|
||||
service.create_event(title="Test")
|
||||
```
|
||||
|
||||
```python
|
||||
# BAD: Slow test hitting real database
|
||||
@pytest.mark.django_db
|
||||
def test_event_creation(tenant, user):
|
||||
# This takes 30+ seconds due to tenant schema setup!
|
||||
event = Event.objects.create(tenant=tenant, title="Test")
|
||||
assert event.id is not None
|
||||
```
|
||||
|
||||
### Testing Serializers
|
||||
|
||||
```python
|
||||
def test_event_serializer_validates_end_after_start():
|
||||
data = {
|
||||
'title': 'Test',
|
||||
'start_time': '2024-01-01T10:00:00Z',
|
||||
'end_time': '2024-01-01T09:00:00Z', # Before start!
|
||||
}
|
||||
serializer = EventSerializer(data=data)
|
||||
|
||||
assert not serializer.is_valid()
|
||||
assert 'end_time' in serializer.errors
|
||||
```
|
||||
|
||||
### Testing Views/ViewSets
|
||||
|
||||
Use `APIRequestFactory` with mocked authentication:
|
||||
|
||||
```python
|
||||
from rest_framework.test import APIRequestFactory
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
def test_resource_list_returns_filtered_resources():
|
||||
factory = APIRequestFactory()
|
||||
request = factory.get('/api/resources/')
|
||||
|
||||
# Mock the user and tenant
|
||||
request.user = Mock(is_authenticated=True, role='manager')
|
||||
request.tenant = Mock(id=1)
|
||||
|
||||
# Mock the queryset
|
||||
with patch.object(ResourceViewSet, 'get_queryset') as mock_qs:
|
||||
mock_qs.return_value = [Mock(id=1, name='Room A')]
|
||||
|
||||
view = ResourceViewSet.as_view({'get': 'list'})
|
||||
response = view(request)
|
||||
|
||||
assert response.status_code == 200
|
||||
```
|
||||
|
||||
### Integration Tests (Use Sparingly)
|
||||
|
||||
Only use `@pytest.mark.django_db` when you MUST verify:
|
||||
- Complex ORM queries with joins/aggregations
|
||||
- Database constraints and triggers
|
||||
- Migration correctness
|
||||
|
||||
```python
|
||||
# Integration test - use only when necessary
|
||||
@pytest.mark.django_db
|
||||
class TestEventQueryIntegration:
|
||||
"""Verify complex queries work correctly with real DB."""
|
||||
|
||||
def test_overlapping_events_query(self, tenant_with_events):
|
||||
# This tests the actual SQL query behavior
|
||||
overlapping = Event.objects.filter_overlapping(
|
||||
start=datetime(2024, 1, 1, 10),
|
||||
end=datetime(2024, 1, 1, 11)
|
||||
)
|
||||
assert overlapping.count() == 2
|
||||
```
|
||||
|
||||
### Writing Testable Code
|
||||
|
||||
Design code for testability using dependency injection:
|
||||
|
||||
```python
|
||||
# BAD: Hard to test - direct imports and global state
|
||||
class EventService:
|
||||
def create_event(self, data):
|
||||
tenant = get_current_tenant() # Global state!
|
||||
event = Event.objects.create(**data) # Direct ORM call
|
||||
send_notification(event) # Direct import
|
||||
return event
|
||||
|
||||
# GOOD: Easy to test - dependencies injected
|
||||
class EventService:
|
||||
def __init__(self, repository, tenant, notifier):
|
||||
self.repository = repository
|
||||
self.tenant = tenant
|
||||
self.notifier = notifier
|
||||
|
||||
def create_event(self, data):
|
||||
if not self.tenant.can_use_feature('events'):
|
||||
raise PermissionDenied()
|
||||
event = self.repository.save(Event(**data))
|
||||
self.notifier.send(event)
|
||||
return event
|
||||
```
|
||||
|
||||
### Repository Pattern
|
||||
|
||||
Abstract database access for easier mocking:
|
||||
|
||||
```python
|
||||
# In repositories.py
|
||||
class EventRepository:
|
||||
def save(self, event):
|
||||
event.save()
|
||||
return event
|
||||
|
||||
def get_by_id(self, event_id):
|
||||
return Event.objects.get(id=event_id)
|
||||
|
||||
def filter_by_date_range(self, start, end):
|
||||
return Event.objects.filter(start_time__gte=start, end_time__lte=end)
|
||||
|
||||
# In tests - mock the repository
|
||||
def test_something():
|
||||
mock_repo = Mock(spec=EventRepository)
|
||||
mock_repo.get_by_id.return_value = Mock(id=1, title="Test")
|
||||
# ... test with mock_repo
|
||||
```
|
||||
|
||||
### Test File Structure
|
||||
|
||||
Each app should have:
|
||||
```
|
||||
app/
|
||||
├── tests/
|
||||
│ ├── __init__.py
|
||||
│ ├── test_services.py # Unit tests for business logic
|
||||
│ ├── test_serializers.py # Serializer validation tests
|
||||
│ ├── test_views.py # View/ViewSet tests (mocked)
|
||||
│ └── test_integration.py # DB tests (sparingly)
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
docker compose -f docker-compose.local.yml exec django pytest
|
||||
|
||||
# Run specific test file
|
||||
docker compose -f docker-compose.local.yml exec django pytest smoothschedule/scheduling/schedule/tests/test_services.py
|
||||
|
||||
# Run with coverage
|
||||
docker compose -f docker-compose.local.yml exec django coverage run -m pytest
|
||||
docker compose -f docker-compose.local.yml exec django coverage report
|
||||
|
||||
# Run only unit tests (fast)
|
||||
docker compose -f docker-compose.local.yml exec django pytest -m "not django_db"
|
||||
|
||||
# Run only integration tests
|
||||
docker compose -f docker-compose.local.yml exec django pytest -m django_db
|
||||
```
|
||||
|
||||
### Key Testing Rules
|
||||
|
||||
1. **Default to mocks** - Only hit DB when testing query logic
|
||||
2. **One assertion focus** - Each test verifies one behavior
|
||||
3. **Descriptive names** - `test_create_event_raises_when_tenant_lacks_permission`
|
||||
4. **Arrange-Act-Assert** - Clear test structure
|
||||
5. **No test interdependence** - Tests must run in any order
|
||||
6. **Fast feedback** - If a test takes >1 second, it probably shouldn't hit the DB
|
||||
|
||||
Reference in New Issue
Block a user