Testing¶
Django Ninja works with Django's regular test client
out of the box, since every operation is still a normal Django view. It also ships a
lightweight TestClient (and an async counterpart, TestAsyncClient) built specifically
for APIs: instead of going through URL resolution and the full middleware stack, it calls
the matching operation function directly, so tests run faster and a Router can be
exercised entirely on its own, without ever attaching it to a NinjaAPI.
Basic usage¶
from ninja import NinjaAPI, Schema
from ninja.testing import TestClient
api = NinjaAPI()
class HelloResponse(Schema):
msg: str
@api.get("/hello", response=HelloResponse)
def hello(request):
return {"msg": "Hello World"}
client = TestClient(api)
def test_hello():
response = client.get("/hello")
assert response.status_code == 200
assert response.json() == {"msg": "Hello World"}
TestClient accepts either a full NinjaAPI instance or a bare Router. get, post,
put, patch and delete all take the path first, then whatever the request needs — see
below.
Without pytest¶
TestClient doesn't care how it's called, so it works just as well from a Django
TestCase and self.assertEqual as it does from a bare assert:
from django.test import TestCase
from ninja import NinjaAPI, Schema
from ninja.testing import TestClient
api = NinjaAPI()
class HelloResponse(Schema):
msg: str
@api.get("/hello", response=HelloResponse)
def hello(request):
return {"msg": "Hello World"}
client = TestClient(api)
class HelloTest(TestCase):
def test_hello(self):
response = client.get("/hello")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"msg": "Hello World"})
Testing a router in isolation¶
Pass a Router straight to TestClient and it builds a throwaway NinjaAPI behind the
scenes to resolve URLs against, without touching the router you pass in — handy for
testing one router's operations without wiring up the rest of the API:
from ninja import Router, Schema
from ninja.testing import TestClient
router = Router()
class HelloResponse(Schema):
msg: str
@router.get("/hello", response=HelloResponse)
def hello(request):
return {"msg": "Hello World"}
client = TestClient(router)
def test_hello():
response = client.get("/hello")
assert response.json() == {"msg": "Hello World"}
Sending data¶
JSON body¶
Pass json= — a dict or a Schema instance — and it's serialized and sent as the
request body, exactly like a real client's Content-Type: application/json request:
from ninja import NinjaAPI, Schema
from ninja.testing import TestClient
api = NinjaAPI()
class Payload(Schema):
name: str
@api.post("/echo")
def echo(request, payload: Payload):
return {"name": payload.name}
client = TestClient(api)
def test_echo():
response = client.post("/echo", json={"name": "Ninja"})
assert response.json() == {"name": "Ninja"}
Form data¶
Pass data= for a application/x-www-form-urlencoded-style body instead — a plain
dict is turned into the request's POST QueryDict:
Query parameters¶
Either put the query string directly in path, or pass a dict as query_params= (only
used when path has no ? of its own). A list value sends the same key multiple
times:
response = client.get("/items?category=tools&category=outdoor")
# same as:
response = client.get("/items", query_params={"category": ["tools", "outdoor"]})
The response object¶
Every call returns a NinjaResponse wrapping the operation's return value:
status_code— the HTTP status code.json()— the response body, decoded from JSON.data— a shortcut forjson().content— the raw response bytes (for a streaming response, the streamed chunks already joined together).response["Header-Name"]— read a response header, the same way you'd index a DjangoHttpResponse.
response = client.get("/hello")
assert response.status_code == 200
assert response.data == {"msg": "Hello World"}
assert response["Content-Type"] == "application/json; charset=utf-8"
Headers and cookies¶
Pass headers=/COOKIES= when constructing TestClient for values every request should
carry, and again per request to add to or override them for that one call:
client = TestClient(router, headers={"A": "a", "B": "b"}, COOKIES={"A": "a"})
# request headers end up {"A": "na", "B": "b", "C": "nc"}
response = client.get("/test-headers", headers={"A": "na", "C": "nc"})
# request cookies end up {"A": "na"}
response = client.get("/test-cookies", COOKIES={"A": "na"})
Testing authenticated views¶
By default request.user is an unauthenticated mock (is_authenticated, is_staff and
is_superuser all False), so an operation behind auth=django_auth
gets rejected. Pass user= to set request.user to a real user instead:
from django.contrib.auth.models import User
from ninja import NinjaAPI
from ninja.security import django_auth
from ninja.testing import TestClient
api = NinjaAPI(auth=django_auth)
@api.get("/me")
def me(request):
return {"username": request.user.username}
client = TestClient(api)
def test_me(db):
user = User.objects.create(username="ninja")
response = client.get("/me", user=user)
assert response.json() == {"username": "ninja"}
Auth classes that don't rely on request.user — API key, HTTP Bearer/Basic
— need no special setup: send the real credentials as a header, query param or cookie,
the same way a live client would.
Arbitrary request attributes¶
Any keyword that isn't one of data, json, headers, COOKIES, query_params,
FILES, META, body or user is set directly on the request object — useful for
faking whatever a piece of middleware would normally attach:
File uploads¶
Pass FILES= — a dict of Django UploadedFiles (django.core.files.uploadedfile.SimpleUploadedFile
is the easiest way to build one in tests). Use a MultiValueDict instead of a plain
dict to send several files under the same field name (see File Uploads):
from django.core.files.uploadedfile import SimpleUploadedFile
from ninja import File, NinjaAPI, UploadedFile
from ninja.testing import TestClient
api = NinjaAPI()
@api.post("/upload")
def upload(request, file: UploadedFile = File(...)):
return {"name": file.name, "data": file.read().decode()}
client = TestClient(api)
def test_upload():
file = SimpleUploadedFile("test.txt", b"data123")
response = client.post("/upload", FILES={"file": file})
assert response.json() == {"name": "test.txt", "data": "data123"}
Testing async operations¶
Use TestAsyncClient for async def operations — it has the same get/post/put/patch/delete
methods, all await-able, and it also accepts either a NinjaAPI or a Router:
import pytest
from ninja.testing import TestAsyncClient
from ninja import NinjaAPI
api = NinjaAPI()
@api.get("/hello")
async def hello(request):
return {"msg": "Hello World"}
client = TestAsyncClient(api)
@pytest.mark.asyncio
async def test_hello():
response = await client.get("/hello")
assert response.status_code == 200
assert response.json() == {"msg": "Hello World"}
Note
TestAsyncClient needs an async test runner such as
pytest-asyncio (@pytest.mark.asyncio) —
it's a dev dependency of Django Ninja itself, so its own test suite uses the same
pattern.
Tips¶
TestClient/TestAsyncClientbypass URL resolution and Django's middleware to keep tests fast and focused on operation logic. If you need to exercise the full stack — real middleware, CSRF enforcement, the URL conf as configured in your project — fall back to Django's owndjango.test.Client(orTestCase.client); Django Ninja's views are ordinary Django views and work with it unmodified.- Both clients raise a plain
Exceptionifpathdoesn't match any operation on the router/API being tested — a quick way to catch a typo before it becomes a 404 assertion. - Anything touching the database needs a DB-enabled test — Django's
TestCase, or thedb/transactional_dbfixtures frompytest-django, as used above in Testing authenticated views.