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)
|
### Tenant (identity/core/models.py)
|
||||||
- `name`, `subdomain`, `schema_name`
|
- `name`, `subdomain`, `schema_name`
|
||||||
- Multi-tenancy via django-tenants
|
- 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