chore(skills): Improve Django and DRF skills (#9831)

Co-authored-by: Adrián Jesús Peña Rodríguez <adrianjpr@gmail.com>
This commit is contained in:
Pepe Fagoaga
2026-01-22 13:54:06 +01:00
committed by GitHub
parent 03d4c19ed5
commit dce05295ef
22 changed files with 3887 additions and 340 deletions

View File

@@ -6,7 +6,7 @@ description: >
license: Apache-2.0
metadata:
author: prowler-cloud
version: "1.0"
version: "1.1.0"
scope: [root, api]
auto_invoke:
- "Writing Prowler API tests"
@@ -17,115 +17,136 @@ allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task
## Critical Rules
- ALWAYS use `response.json()["data"]` not `response.data`
- ALWAYS use `content_type = "application/vnd.api+json"` in requests
- ALWAYS test cross-tenant isolation with `other_tenant_provider` fixture
- ALWAYS use `content_type = "application/vnd.api+json"` for PATCH/PUT requests
- ALWAYS use `format="vnd.api+json"` for POST requests
- ALWAYS test cross-tenant isolation - RLS returns 404, NOT 403
- NEVER skip RLS isolation tests when adding new endpoints
- NEVER use realistic-looking API keys in tests (TruffleHog will flag them)
- ALWAYS mock BOTH `.delay()` AND `Task.objects.get` for async task tests
---
## 1. JSON:API Format (Critical)
## 1. Fixture Dependency Chain
```python
content_type = "application/vnd.api+json"
payload = {
"data": {
"type": "providers", # Plural, kebab-case
"id": str(resource.id), # Required for PATCH
"attributes": {"alias": "updated"},
}
}
response.json()["data"]["attributes"]["alias"]
```
create_test_user (session) ─► tenants_fixture (function) ─► authenticated_client
└─► providers_fixture ─► scans_fixture ─► findings_fixture
```
---
## 2. RLS Isolation Tests
```python
def test_cross_tenant_access_denied(self, authenticated_client, other_tenant_provider):
"""User cannot see resources from other tenants."""
response = authenticated_client.get(
reverse("provider-detail", args=[other_tenant_provider.id])
)
assert response.status_code == status.HTTP_404_NOT_FOUND
```
---
## 3. RBAC Tests
```python
def test_unlimited_visibility_sees_all(self, authenticated_client_admin, providers_fixture):
response = authenticated_client_admin.get(reverse("provider-list"))
assert len(response.json()["data"]) == len(providers_fixture)
def test_limited_visibility_sees_only_assigned(self, authenticated_client_limited):
# User with unlimited_visibility=False sees only providers in their provider_groups
pass
def test_permission_required(self, authenticated_client_readonly):
response = authenticated_client_readonly.post(reverse("provider-list"), ...)
assert response.status_code == status.HTTP_403_FORBIDDEN
```
---
## 4. Managers (objects vs all_objects)
```python
def test_objects_excludes_deleted(self):
deleted_provider = Provider.objects.create(..., is_deleted=True)
assert deleted_provider not in Provider.objects.all()
assert deleted_provider in Provider.all_objects.all()
```
---
## 5. Celery Task Tests
```python
@patch("tasks.tasks.perform_prowler_scan")
def test_task_success(self, mock_scan):
mock_scan.return_value = {"findings_count": 100}
result = perform_scan_task(tenant_id="...", scan_id="...", provider_id="...")
assert result["findings_count"] == 100
```
---
## 6. Key Fixtures
### Key Fixtures
| Fixture | Description |
|---------|-------------|
| `create_test_user` | Session user (dev@prowler.com) |
| `tenants_fixture` | 3 tenants (2 with membership, 1 isolated) |
| `providers_fixture` | Providers in tenant 1 |
| `other_tenant_provider` | Provider in isolated tenant (RLS tests) |
| `authenticated_client` | Client with JWT for tenant 1 |
| `create_test_user` | Session user (`dev@prowler.com`) |
| `tenants_fixture` | 3 tenants: [0],[1] have membership, [2] isolated |
| `authenticated_client` | JWT client for tenant[0] |
| `providers_fixture` | 9 providers in tenant[0] |
| `tasks_fixture` | 2 Celery tasks with TaskResult |
### RBAC Fixtures
| Fixture | Permissions |
|---------|-------------|
| `authenticated_client_rbac` | All permissions (admin) |
| `authenticated_client_rbac_noroles` | Membership but NO roles |
| `authenticated_client_no_permissions_rbac` | All permissions = False |
---
## 7. Fake Secrets in Tests (TruffleHog)
CI runs TruffleHog to detect leaked secrets. Use obviously fake values:
## 2. JSON:API Requests
### POST (Create)
```python
# BAD - TruffleHog will flag these patterns:
api_key = "sk-test1234567890T3BlbkFJtest1234567890" # OpenAI pattern
api_key = "AKIA..." # AWS pattern
# GOOD - clearly fake values:
api_key = "sk-fake-test-key-for-unit-testing-only"
api_key = "fake-aws-key-for-testing"
response = client.post(
reverse("provider-list"),
data={"data": {"type": "providers", "attributes": {...}}},
format="vnd.api+json", # NOT content_type!
)
```
**Patterns to avoid:**
- `sk-*T3BlbkFJ*` (OpenAI)
- `AKIA[A-Z0-9]{16}` (AWS Access Key)
- `ghp_*` or `gho_*` (GitHub tokens)
### PATCH (Update)
```python
response = client.patch(
reverse("provider-detail", kwargs={"pk": provider.id}),
data={"data": {"type": "providers", "id": str(provider.id), "attributes": {...}}},
content_type="application/vnd.api+json", # NOT format!
)
```
### Reading Responses
```python
data = response.json()["data"]
attrs = data["attributes"]
errors = response.json()["errors"] # For 400 responses
```
---
## 3. RLS Isolation (Cross-Tenant)
**RLS returns 404, NOT 403** - the resource is invisible, not forbidden.
```python
def test_cross_tenant_access_denied(self, authenticated_client, tenants_fixture):
other_tenant = tenants_fixture[2] # Isolated tenant
foreign_provider = Provider.objects.create(tenant_id=other_tenant.id, ...)
response = authenticated_client.get(reverse("provider-detail", args=[foreign_provider.id]))
assert response.status_code == status.HTTP_404_NOT_FOUND # NOT 403!
```
---
## 4. Celery Task Testing
### Testing Strategies
| Strategy | Use For |
|----------|---------|
| Mock `.delay()` + `Task.objects.get` | Testing views that trigger tasks |
| `task.apply()` | Synchronous task logic testing |
| Mock `chain`/`group` | Testing Canvas orchestration |
| Mock `connection` | Testing `@set_tenant` decorator |
| Mock `apply_async` | Testing Beat scheduled tasks |
### Why NOT `task_always_eager`
| Problem | Impact |
|---------|--------|
| No task serialization | Misses argument type errors |
| No broker interaction | Hides connection issues |
| Different execution context | `self.request` behaves differently |
**Instead, use:** `task.apply()` for sync execution, mocking for isolation.
> **Full examples:** See [assets/api_test.py](assets/api_test.py) for `TestCeleryTaskLogic`, `TestCeleryCanvas`, `TestSetTenantDecorator`, `TestBeatScheduling`.
---
## 5. Fake Secrets (TruffleHog)
```python
# BAD - TruffleHog flags these:
api_key = "sk-test1234567890T3BlbkFJtest1234567890"
# GOOD - obviously fake:
api_key = "sk-fake-test-key-for-unit-testing-only"
```
---
## 6. Response Status Codes
| Scenario | Code |
|----------|------|
| Successful GET | 200 |
| Successful POST | 201 |
| Async operation (DELETE/scan trigger) | 202 |
| Sync DELETE | 204 |
| Validation error | 400 |
| Missing permission (RBAC) | 403 |
| RLS isolation / not found | 404 |
---
@@ -134,11 +155,13 @@ api_key = "fake-aws-key-for-testing"
```bash
cd api && poetry run pytest -x --tb=short
cd api && poetry run pytest -k "test_provider"
cd api && poetry run pytest -k "TestRBAC"
cd api && poetry run pytest api/src/backend/api/tests/test_rbac.py
```
---
## Resources
- **Documentation**: See [references/test-api-docs.md](references/test-api-docs.md) for local file paths and documentation
- **Full Examples**: See [assets/api_test.py](assets/api_test.py) for complete test patterns
- **Fixture Reference**: See [references/test-api-docs.md](references/test-api-docs.md)
- **Fixture Source**: `api/src/backend/conftest.py`

View File

@@ -0,0 +1,371 @@
# Example: Prowler API Test Patterns
# Source: api/src/backend/api/tests/test_views.py
from unittest.mock import Mock, patch
import pytest
from conftest import (
API_JSON_CONTENT_TYPE,
TEST_PASSWORD,
TEST_USER,
get_api_tokens,
get_authorization_header,
)
from django.urls import reverse
from rest_framework import status
from api.models import Provider, Scan, StateChoices
from api.rls import Tenant
@pytest.mark.django_db
class TestProviderViewSet:
"""Example API tests for Provider endpoints."""
def test_list_providers(self, authenticated_client, providers_fixture):
"""GET list returns all providers for authenticated tenant."""
response = authenticated_client.get(reverse("provider-list"))
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) == len(providers_fixture)
def test_create_provider(self, authenticated_client):
"""POST with JSON:API format creates provider."""
response = authenticated_client.post(
reverse("provider-list"),
data={
"data": {
"type": "providers",
"attributes": {
"provider": "aws",
"uid": "123456789012",
"alias": "my-aws-account",
},
}
},
format="vnd.api+json", # Use format= for POST
)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["data"]["attributes"]["uid"] == "123456789012"
def test_update_provider(self, authenticated_client, providers_fixture):
"""PATCH with JSON:API format updates provider."""
provider = providers_fixture[0]
payload = {
"data": {
"type": "providers",
"id": str(provider.id), # ID required for PATCH
"attributes": {"alias": "updated-alias"},
}
}
response = authenticated_client.patch(
reverse("provider-detail", kwargs={"pk": provider.id}),
data=payload,
content_type="application/vnd.api+json", # Use content_type= for PATCH
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["data"]["attributes"]["alias"] == "updated-alias"
@pytest.mark.django_db
class TestRLSIsolation:
"""Example RLS cross-tenant isolation tests."""
def test_cross_tenant_access_returns_404(
self, authenticated_client, tenants_fixture
):
"""User cannot see resources from other tenants - returns 404 NOT 403."""
# Create resource in tenant user has NO access to (tenant[2] is isolated)
other_tenant = tenants_fixture[2]
foreign_provider = Provider.objects.create(
provider="aws",
uid="999888777666",
alias="foreign_provider",
tenant_id=other_tenant.id,
)
# Try to access - should get 404 (not 403!)
response = authenticated_client.get(
reverse("provider-detail", args=[foreign_provider.id])
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_list_excludes_other_tenants(
self, authenticated_client, providers_fixture, tenants_fixture
):
"""List endpoints only return resources from user's tenants."""
# Create provider in isolated tenant
other_tenant = tenants_fixture[2]
Provider.objects.create(
provider="aws",
uid="foreign123",
tenant_id=other_tenant.id,
)
response = authenticated_client.get(reverse("provider-list"))
assert response.status_code == status.HTTP_200_OK
# Should only see providers_fixture (9 providers in tenant[0])
assert len(response.json()["data"]) == len(providers_fixture)
@pytest.mark.django_db
class TestRBACPermissions:
"""Example RBAC permission tests."""
def test_requires_permission(self, authenticated_client_no_permissions_rbac):
"""Users without manage_providers cannot create providers."""
response = authenticated_client_no_permissions_rbac.post(
reverse("provider-list"),
data={
"data": {
"type": "providers",
"attributes": {"provider": "aws", "uid": "123456789012"},
}
},
format="vnd.api+json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_user_with_no_roles_denied(self, authenticated_client_rbac_noroles):
"""User with membership but no roles gets 403."""
response = authenticated_client_rbac_noroles.get(reverse("user-list"))
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_admin_sees_all(self, authenticated_client_rbac, providers_fixture):
"""Admin with unlimited_visibility=True sees all providers."""
response = authenticated_client_rbac.get(reverse("provider-list"))
assert response.status_code == status.HTTP_200_OK
@pytest.mark.django_db
class TestAsyncOperations:
"""Example async task tests - mock BOTH .delay() AND Task.objects.get."""
@patch("api.v1.views.Task.objects.get")
@patch("api.v1.views.delete_provider_task.delay")
def test_delete_provider_returns_202(
self,
mock_delete_task,
mock_task_get,
authenticated_client,
providers_fixture,
tasks_fixture,
):
"""DELETE returns 202 Accepted with Content-Location header."""
provider = providers_fixture[0]
prowler_task = tasks_fixture[0]
# Mock the Celery task
task_mock = Mock()
task_mock.id = prowler_task.id
mock_delete_task.return_value = task_mock
mock_task_get.return_value = prowler_task
response = authenticated_client.delete(
reverse("provider-detail", kwargs={"pk": provider.id})
)
assert response.status_code == status.HTTP_202_ACCEPTED
assert "Content-Location" in response.headers
assert f"/api/v1/tasks/{prowler_task.id}" in response.headers["Content-Location"]
# Verify task was called
mock_delete_task.assert_called_once()
@patch("api.v1.views.Task.objects.get")
@patch("api.v1.views.perform_scan_task.delay")
def test_trigger_scan_returns_202(
self,
mock_scan_task,
mock_task_get,
authenticated_client,
providers_fixture,
tasks_fixture,
):
"""POST to scan trigger returns 202 with task location."""
provider = providers_fixture[0]
prowler_task = tasks_fixture[0]
task_mock = Mock()
task_mock.id = prowler_task.id
mock_scan_task.return_value = task_mock
mock_task_get.return_value = prowler_task
response = authenticated_client.post(
reverse("provider-scan", kwargs={"pk": provider.id}),
format="vnd.api+json",
)
assert response.status_code == status.HTTP_202_ACCEPTED
@pytest.mark.django_db
class TestJSONAPIResponses:
"""Example JSON:API response handling."""
def test_read_single_resource(self, authenticated_client, providers_fixture):
"""Read data from single resource response."""
provider = providers_fixture[0]
response = authenticated_client.get(
reverse("provider-detail", kwargs={"pk": provider.id})
)
data = response.json()["data"]
attrs = data["attributes"]
resource_id = data["id"]
assert resource_id == str(provider.id)
assert attrs["provider"] == provider.provider
def test_read_list_response(self, authenticated_client, providers_fixture):
"""Read data from list response."""
response = authenticated_client.get(reverse("provider-list"))
items = response.json()["data"]
assert len(items) == len(providers_fixture)
def test_read_relationships(self, authenticated_client, scans_fixture):
"""Read relationship data."""
scan = scans_fixture[0]
response = authenticated_client.get(
reverse("scan-detail", kwargs={"pk": scan.id})
)
data = response.json()["data"]
relationships = data["relationships"]
provider_rel = relationships["provider"]["data"]
assert provider_rel["type"] == "providers"
assert provider_rel["id"] == str(scan.provider_id)
def test_error_response(self, authenticated_client):
"""Read error response structure."""
response = authenticated_client.post(
reverse("user-list"),
data={"email": "invalid"}, # Missing required fields
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
errors = response.json()["errors"]
# Error has source.pointer and detail
assert "source" in errors[0]
assert "detail" in errors[0]
@pytest.mark.django_db
class TestSoftDelete:
"""Example soft-delete manager tests."""
def test_objects_excludes_soft_deleted(self, providers_fixture):
"""Default manager excludes soft-deleted records."""
provider = providers_fixture[0]
provider.is_deleted = True
provider.save()
# objects manager excludes deleted
assert provider not in Provider.objects.all()
# all_objects includes deleted
assert provider in Provider.all_objects.all()
# =============================================================================
# CELERY TASK TESTING
# =============================================================================
@pytest.mark.django_db
class TestCeleryTaskLogic:
"""Example: Testing Celery task logic directly with apply()."""
def test_task_logic_directly(self, tenants_fixture, providers_fixture):
"""Use apply() for synchronous execution without Celery worker."""
from tasks.tasks import check_provider_connection_task
tenant = tenants_fixture[0]
provider = providers_fixture[0]
# Execute task synchronously (no broker needed)
result = check_provider_connection_task.apply(
kwargs={"tenant_id": str(tenant.id), "provider_id": str(provider.id)}
)
assert result.successful()
assert result.result["connected"] is True
@pytest.mark.django_db
class TestCeleryCanvas:
"""Example: Testing Canvas (chain/group) task orchestration."""
@patch("tasks.tasks.chain")
@patch("tasks.tasks.group")
def test_post_scan_workflow(self, mock_group, mock_chain, tenants_fixture):
"""Mock chain/group to verify task orchestration."""
from tasks.tasks import _perform_scan_complete_tasks
tenant = tenants_fixture[0]
# Mock chain.apply_async
mock_chain_instance = Mock()
mock_chain.return_value = mock_chain_instance
_perform_scan_complete_tasks(str(tenant.id), "scan-123", "provider-456")
# Verify chain was called
assert mock_chain.called
mock_chain_instance.apply_async.assert_called()
@pytest.mark.django_db
class TestSetTenantDecorator:
"""Example: Testing @set_tenant decorator behavior."""
@patch("api.decorators.connection")
def test_sets_rls_context(self, mock_conn, tenants_fixture, providers_fixture):
"""Verify @set_tenant sets RLS context via SET_CONFIG_QUERY."""
from tasks.tasks import check_provider_connection_task
tenant = tenants_fixture[0]
provider = providers_fixture[0]
# Call task with tenant_id - decorator sets RLS and pops it
check_provider_connection_task.apply(
kwargs={"tenant_id": str(tenant.id), "provider_id": str(provider.id)}
)
# Verify SET_CONFIG_QUERY was executed
mock_conn.cursor.return_value.__enter__.return_value.execute.assert_called()
@pytest.mark.django_db
class TestBeatScheduling:
"""Example: Testing Beat scheduled task creation."""
@patch("tasks.beat.perform_scheduled_scan_task.apply_async")
def test_schedule_provider_scan(self, mock_apply, providers_fixture):
"""Verify periodic task is created with correct settings."""
from django_celery_beat.models import PeriodicTask
from tasks.beat import schedule_provider_scan
provider = providers_fixture[0]
mock_apply.return_value = Mock(id="task-123")
schedule_provider_scan(provider)
# Verify periodic task created
assert PeriodicTask.objects.filter(
name=f"scan-perform-scheduled-{provider.id}"
).exists()
# Verify immediate execution with countdown
mock_apply.assert_called_once()
call_kwargs = mock_apply.call_args
assert call_kwargs.kwargs.get("countdown") == 5

View File

@@ -1,18 +1,214 @@
# API Test Documentation
# API Test Documentation Reference
## Local Documentation
## File Locations
For API testing patterns, see:
| Type | Path |
|------|------|
| Central fixtures | `api/src/backend/conftest.py` |
| API unit tests | `api/src/backend/api/tests/` |
| Integration tests | `api/src/backend/api/tests/integration/` |
| Task tests | `api/src/backend/tasks/tests/` |
| Dev fixtures (JSON) | `api/src/backend/api/fixtures/dev/` |
- `api/src/backend/conftest.py` - All fixtures
- `api/src/backend/api/tests/` - API tests
- `api/src/backend/tasks/tests/` - Task tests
---
## Contents
## Fixture Dependency Graph
The documentation covers:
- JSON:API format for requests/responses
- RLS isolation test patterns
- RBAC permission tests
- Celery task mocking
- Test fixtures and their usage
```
create_test_user (session)
└─► tenants_fixture (function)
├─► set_user_admin_roles_fixture
│ │
│ └─► authenticated_client
│ └─► (most API tests use this)
├─► providers_fixture
│ └─► scans_fixture
│ └─► findings_fixture
└─► RBAC fixtures (create their own tenants/users):
├─► create_test_user_rbac
│ └─► authenticated_client_rbac
├─► create_test_user_rbac_no_roles
│ └─► authenticated_client_rbac_noroles
├─► create_test_user_rbac_limited
│ └─► authenticated_client_no_permissions_rbac
├─► create_test_user_rbac_manage_account
│ └─► authenticated_client_rbac_manage_account
└─► create_test_user_rbac_manage_users_only
└─► authenticated_client_rbac_manage_users_only
```
---
## Test File Contents
### `api/src/backend/api/tests/test_views.py`
Main ViewSet tests covering:
- `TestUserViewSet` - User CRUD, password validation, deletion cascades
- `TestTenantViewSet` - Tenant operations
- `TestProviderViewSet` - Provider CRUD, async deletion, connection testing
- `TestScanViewSet` - Scan trigger, list, filter
- `TestFindingViewSet` - Finding queries, filters
- `TestResourceViewSet` - Resource listing with tags
- `TestTaskViewSet` - Celery task status
- `TestIntegrationViewSet` - S3/Security Hub integrations
- `TestComplianceOverviewViewSet` - Compliance data
- And many more...
### `api/src/backend/api/tests/test_rbac.py`
RBAC permission tests covering:
- Permission checks for each ViewSet
- Role-based access patterns
- `unlimited_visibility` behavior
- Provider group visibility filtering
- Self-access patterns (`/me` endpoint)
### `api/src/backend/api/tests/integration/test_rls_transaction.py`
RLS enforcement tests:
- `rls_transaction` context manager
- Invalid UUID validation
- Custom parameter names
### `api/src/backend/api/tests/integration/test_providers.py`
Provider integration tests:
- Delete + recreate flow with async tasks
- End-to-end provider lifecycle
### `api/src/backend/api/tests/integration/test_authentication.py`
Authentication tests:
- JWT token flow
- API key authentication
- Social login (SAML, OAuth)
- Cross-tenant token isolation
---
## Key Test Classes and Their Fixtures
### Standard API Tests
```python
@pytest.mark.django_db
class TestProviderViewSet:
def test_list(self, authenticated_client, providers_fixture):
# authenticated_client has JWT for tenant[0]
# providers_fixture has 9 providers in tenant[0]
...
```
### RBAC Tests
```python
@pytest.mark.django_db
class TestProviderRBAC:
def test_with_permission(self, authenticated_client_rbac, ...):
# Has all permissions
...
def test_without_permission(self, authenticated_client_no_permissions_rbac, ...):
# Has no permissions (all False)
...
```
### Cross-Tenant Tests
```python
@pytest.mark.django_db
class TestCrossTenantIsolation:
def test_cannot_access_other_tenant(self, authenticated_client, tenants_fixture):
other_tenant = tenants_fixture[2] # Isolated tenant
# Create resource in other_tenant
# Try to access with authenticated_client
# Expect 404
```
### Async Task Tests
```python
@pytest.mark.django_db
class TestAsyncOperations:
@patch("api.v1.views.Task.objects.get")
@patch("api.v1.views.some_task.delay")
def test_async_operation(self, mock_task, mock_task_get, tasks_fixture, ...):
prowler_task = tasks_fixture[0]
mock_task.return_value = Mock(id=prowler_task.id)
mock_task_get.return_value = prowler_task
# Execute and verify 202 response
```
---
## Constants Available from conftest
```python
from conftest import (
API_JSON_CONTENT_TYPE, # "application/vnd.api+json"
NO_TENANT_HTTP_STATUS, # status.HTTP_401_UNAUTHORIZED
TEST_USER, # "dev@prowler.com"
TEST_PASSWORD, # "testing_psswd"
TODAY, # str(datetime.today().date())
today_after_n_days, # Function: (n: int) -> str
get_api_tokens, # Function: (client, email, password, tenant_id?) -> (access, refresh)
get_authorization_header, # Function: (token) -> {"Authorization": f"Bearer {token}"}
)
```
---
## Running Tests
```bash
# Full test suite
cd api && poetry run pytest
# Fast fail on first error
cd api && poetry run pytest -x
# Short traceback
cd api && poetry run pytest --tb=short
# Specific file
cd api && poetry run pytest api/src/backend/api/tests/test_views.py
# Pattern match
cd api && poetry run pytest -k "Provider"
# Verbose with print output
cd api && poetry run pytest -v -s
# With coverage
cd api && poetry run pytest --cov=api --cov-report=html
# Parallel execution
cd api && poetry run pytest -n auto
```
---
## pytest Configuration
From `api/pyproject.toml`:
```toml
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "config.settings"
python_files = "test_*.py"
addopts = "--reuse-db"
```
Key points:
- Uses `--reuse-db` for faster test runs
- Settings from `config.settings`
- Test files must match `test_*.py`