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:
poduck
2025-12-07 19:12:01 -05:00
parent 1af79cc019
commit b9e90e6f46

View File

@@ -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