Testing with Dependency Injection
This guide explains best practices for testing applications that use dependency injection in Django Ninja Extra.
Settings Configuration for Testing
A recommended approach is to maintain separate Django settings files for development and testing. This allows you to swap out real services with mock implementations during testing.
Project Structure
your_project/
├── config/
│ ├── settings/
│ │ ├── __init__.py
│ │ ├── base.py
│ │ ├── development.py
│ │ └── testing.py
├── your_app/
│ ├── services/
│ │ ├── __init__.py
│ │ ├── todo_service.py
│ │ └── mock_todo_service.py
│ └── modules.py
Development Settings (development.py)
from .base import *
NINJA_EXTRA = {
'INJECTOR_MODULES': [
'your_app.modules.TodoModule' # Uses real implementation
]
}
Testing Settings (testing.py)
from .base import *
NINJA_EXTRA = {
'INJECTOR_MODULES': [
'your_app.modules.MockTodoModule' # Uses mock implementation
]
}
Implementing Mock Services
Create your mock services and module:
# your_app/services/mock_todo_service.py
from typing import List, Optional
from datetime import datetime
from .todo_service import TodoItem, TodoService
class MockTodoRepository:
def __init__(self):
self._todos = [
TodoItem(id=1, title="Test Todo", completed=False, created_at=datetime.now())
]
def get_all(self) -> List[TodoItem]:
return self._todos
def get_by_id(self, todo_id: int) -> Optional[TodoItem]:
return self._todos[0] if todo_id == 1 else None
def add(self, title: str) -> TodoItem:
return self._todos[0]
def toggle_complete(self, todo_id: int) -> Optional[TodoItem]:
todo = self.get_by_id(todo_id)
if todo:
todo.completed = not todo.completed
return todo
# your_app/modules.py
from injector import Module, singleton, Binder
from .services.mock_todo_service import MockTodoRepository
from .services.todo_service import TodoService, TodoRepository
class MockTodoModule(Module):
def configure(self, binder: Binder) -> None:
binder.bind(TodoRepository, to=MockTodoRepository, scope=singleton)
binder.bind(TodoService, to=TodoService, scope=singleton)
Running Tests
After setting up your mock services and configuring your test environment, you can write tests using for the controller using the TestClient
from ninja_extra.testing
. As shown in the example below:
# tests/test_todo_api.py
import pytest
from ninja_extra import testing
from your_app.controllers import TodoController
@pytest.mark.django_db
class TestTodoController:
def test_list_todos(self):
client = testing.TestClient(TodoController)
response = client.get("/api/todos")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["title"] == "Test Todo"
def test_create_todo(self):
client = testing.TestClient(TodoController)
response = client.post("/api/todos", {"title": "New Todo"})
assert response.status_code == 200
data = response.json()
assert data["title"] == "Test Todo" # Returns mock data
Best Practices
- Separate Settings Files: Maintain separate settings files for different environments (development, testing, production).
- Mock Module Design:
- Keep mock implementations simple but sufficient for testing
- Implement only the methods that are actually used in tests
- Use predictable, static data in mock responses
- Test Data:
- Initialize mock services with known test data
- Avoid dependencies on external services in tests
- Configuration Management:
- Use environment variables to switch between settings files
- Document the required environment setup for running tests
Environment Setup
To use different settings files, set the Django settings module environment variable:
# For development
export DJANGO_SETTINGS_MODULE=config.settings.development
# For testing
export DJANGO_SETTINGS_MODULE=config.settings.testing
Or in pytest.ini:
[pytest]
DJANGO_SETTINGS_MODULE = config.settings.testing
This approach ensures consistent and isolated testing environments while maintaining the ability to use real implementations in development.