はじめに
今回は、私が普段利用している Django REST framework(DRF)におけるテスト手法 をまとめてみようと思います。実装方法から自動化までを一通り解説した記事は意外と少なく、自分でも探しきれなかったため、今回執筆するに至りました。
事前準備
インストール
pip install Django
pip install djangorestframework
バージョン
Django: 5.2.8
djangorestframework: 3.16.1
コマンド
Todo リストアプリでテスト自動化を実装する想定です。
django-admin startproject project .
django-admin startapp todo
ディレクトリ
.github
┣ workflows
┣ test.yml
project
┣ settings.py
┣ urls.py
┣ ...
todo
┣ models.py
┣ serializers.py
┣ services.py
┣ urls.py
┣ views.py
┣ tests
┣ __init__.py
┣ factories.py
┣ test_services.py
┣ test_views.py
┣ ...
私の場合、
・views.py → リクエストを受け取りレスポンスを返す
・services.py → ビジネスロジックを行う
といった切り分けをしています。
ソースコード
from django.db import models
from django.utils import timezone
class Todo(models.Model):
title = models.CharField(max_length=255)
detail = models.CharField(max_length=255, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
from django.urls import path
from todo.views import TodoListView, TodoAPIView
app_name = "todo"
urlpatterns = [
path('list/', TodoListView.as_view(), name="list"),
path('api/', TodoAPIView.as_view(), name="api"),
]
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
from todo.services import TodoService
from todo.serializers import TodoListSerializer
class TodoListView(APIView):
# DBからtodoリストを取得する
def get(self, request):
service = TodoService()
data = service.fetch_todo_list()
serializer = TodoListSerializer(data, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
class TodoAPIView(APIView):
# 外部のtodoを取得する
def get(self, request):
service = TodoService()
data = service.fetch_todo_api()
return Response(data, status=status.HTTP_200_OK)
import requests
from todo.models import Todo
from todo.serializers import TodoValidationSerializer
class TodoService:
# DBからtodoリストを取得する
def fetch_todo_list(self):
return Todo.objects.all()
# 外部APIを利用してtodoを取得する
def fetch_todo_api(self):
try:
res = requests.get('https://jsonplaceholder.typicode.com/todos/1')
res.raise_for_status()
return res.json()
except requests.exceptions.RequestException:
raise Exception # 本当はもっと丁寧なハンドリングが必要ですが、一旦これでいきます
テストコード作成
生成AIを利用してテストコードを作成し、必要な箇所を微修正するという流れがシンプルです。特に、Cursor に搭載されている AI や、VScode で Codex・GitHub Copilot などを使用すると、エディター内のコードを読み取って適切なテストコードを生成してくれるため、効率的に作業を進められます。
各アプリ内に tests ディレクトリを作成し、views や services など役割単位でテストファイルを分ける構成がおすすめです。 models や serializers に独自の処理があるならそれら専用のテストファイルを用意するのもありですね。
外部 API を利用している箇所については、patch を使ってモックに置き換えることで、外部の状態に依存しないテストが可能になります。これにより、予期せぬ挙動や課金の発生といったリスクも避けられます。
-
services、models、serializersで単体テスト → Django のTestCase -
viewsで統合テスト → rest_framework のAPITestCase
テストコードを作成したあと、以下のコマンドを実行することでテストが行われます。
python manage.py test
以下はテストコードの例です。
import requests
from unittest.mock import patch, MagicMock
from django.test import TestCase
from todo.models import Todo
from todo.services import TodoService
class TodoServiceTestCase(TestCase):
def setUp(self):
self.service = TodoService()
# DBからtodoリストを取得できるかテスト
def test_fetch_todo_list_returns_all_todos(self):
todo1 = Todo.objects.create(title="Task 1", detail="Detail 1")
todo2 = Todo.objects.create(title="Task 2", detail="Detail 2")
todos = self.service.fetch_todo_list()
self.assertEqual(todos.count(), 2)
self.assertIn(todo1, todos)
self.assertIn(todo2, todos)
# 外部APIに成功した場合のテスト
@patch("todo.services.requests.get")
def test_fetch_todo_api_success(self, mock_get):
mock_res = MagicMock()
mock_res.status_code = 200
mock_res.json.return_value = {"id": 1, "title": "Test"}
mock_res.raise_for_status.return_value = None
mock_get.return_value = mock_res
result = self.service.fetch_todo_api()
self.assertEqual(result["id"], 1)
self.assertEqual(result["title"], "Test")
# このurlが1回だけ呼ばれたかテストします
mock_get.assert_called_once_with("https://jsonplaceholder.typicode.com/todos/1")
# 外部APIに失敗した場合のテスト
@patch("todo.services.requests.get")
def test_fetch_todo_api_http_error(self, mock_get):
mock_res = MagicMock()
mock_res.raise_for_status.side_effect = requests.exceptions.HTTPError()
mock_get.return_value = mock_res
with self.assertRaises(Exception):
self.service.fetch_todo_api()
mock_get.assert_called_once()
from unittest.mock import patch
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase
from todo.models import Todo
class TodoListViewTests(APITestCase):
def setUp(self):
self.url = reverse("todo:list")
def test_get_todo_list_returns_serialized_list(self):
# DB にテストデータを用意(サービスもシリアライザも本物を使う)
t1 = Todo.objects.create(title="First", detail="aaa")
t2 = Todo.objects.create(title="Second", detail="bbb")
response = self.client.get(self.url)
# ステータスコード
self.assertEqual(response.status_code, status.HTTP_200_OK)
# 件数
self.assertEqual(len(response.data), 2)
# 中身(TodoListSerializerのfieldsに合わせて確認)
titles = [item["title"] for item in response.data]
self.assertIn("First", titles)
self.assertIn("Second", titles)
# id がちゃんと入っていることも軽く確認
ids = [item["id"] for item in response.data]
self.assertIn(t1.id, ids)
self.assertIn(t2.id, ids)
class TodoAPIViewTests(APITestCase):
def setUp(self):
self.url = reverse("todo:api")
@patch("todo.views.TodoService.fetch_todo_api")
def test_get_todo_api_returns_external_data(self, mock_fetch_todo_api):
mock_fetch_todo_api.return_value = {
"id": 1,
"title": "External todo",
"completed": False,
}
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["id"], 1)
self.assertEqual(response.data["title"], "External todo")
self.assertEqual(response.data["completed"], False)
# View がサービスメソッドを1回呼んでいることを確認
mock_fetch_todo_api.assert_called_once()
urls.py 内で app_name と path(..., name=...) を入力することで、
reverse("todo:list") のように url を設定できます。
プロジェクトが大きくなっても衝突しにくそうです。
認証機能を実装していてAPIがログイン必須の場合、このままではエラーになりますので認証をスキップすることが必要です。self.client.force_authenticate でできます。
from django.contrib.auth import get_user_model
from django.urls import reverse
from rest_framework.test import APITestCase
User = get_user_model()
class AuthenticatedAPITestCase(APITestCase):
def setUp(self):
super().setUp()
self.user = User.objects.create_user(
email="test@example.com",
password="password123",
...,
)
self.client.force_authenticate(self.user)
class TodoListViewTests(AuthenticatedAPITestCase):
def setUp(self):
super().setUp()
self.url = reverse("todo:list")
...
class TodoAPIViewTests(AuthenticatedAPITestCase):
def setUp(self):
super().setUp()
self.url = reverse("todo:api")
...
AuthenticatedAPITestCase はインポートできるように別ファイルに置いておくと良いでしょう
GET メソッドの params、POST メソッドの data、パス内のパラメータの取得を View 内でしている場合はテスト中 url を呼び出す際にそれらを渡すと良いでしょう。
# request.query_params を利用している場合
params = {...}
response = self.client.get(url, params)
# POST メソッドで request.data を利用している場合
payload = {...}
response = self.client.post(url, payload, format="json")
# /<int:pk>/ のように、パス内にパラメータを利用している場合
kwargs = {"pk": ...}
url = reverse("...", kwargs=kwargs)
response = self.client.get(url)
ファクトリーを使うことでダミーデータを簡単に作成することができます。カラムやモデルの数が増えても柔軟に対応できそうです。
pip install factory_boy
Version:3.3.3
import factory
from todo.models import Todo
class TodoFactory(factory.django.DjangoModelFactory):
class Meta:
model = Todo
title = factory.Faker("sentence")
detail = factory.Faker("text")
from todo.tests.factories import TodoFactory
...
def test_fetch_todo_list_returns_all_todos(self):
# 適当にレコードを2つ作る
TodoFactory.create_batch(2)
todos = self.service.fetch_todo_list()
self.assertEqual(todos.count(), 2)
from todo.tests.factories import TodoFactory
...
def test_get_todo_list_returns_serialized_list(self):
# 必要に応じてフィールドを上書きする
t1 = TodoFactory(title="First", detail="aaa")
t2 = TodoFactory(title="Second", detail="bbb")
response = self.client.get(self.url)
titles = [item["title"] for item in response.data]
self.assertIn("First", titles)
self.assertIn("Second", titles)
カバレッジ
ソースコードに対しテストがどれほど網羅されているかを割合で出力するためにカバレッジを測定します。これにより、「開発だけしてテストコードは書いていない」という状況を牽制できるかもしれません。
まず coverage ライブラリをインストールします。
pip install coverage
Version:7.13.0
コマンドを打てばすぐカバレッジレポートを作成できますが、表示を最適化するために .coveragerc ファイルに設定を記入します。
[run]
omit =
*/migrations/*
*/tests/*
*/__init__.py
*/admin.py
*/apps.py
*/tests.py
*/urls.py
manage.py
settings.py
[report]
show_missing = True
exclude_lines =
pragma: no cover
pass
omit にはカバレッジの対象外にしたいディレクトリやファイルを記入します。
exclude_lines にはカバレッジの対象外にしたい行を記入します。
def function() # pragma: no cover
のようにすればその部分は測定に含まれなくなります。
coverage コマンドは複数あるので、Makefile でまとめるのが便利です。
.PHONY: coverage ci-test
# カバレッジ測定を含めたテスト
coverage:
coverage run manage.py test # テストを実行する
coverage report # コンソールに結果を出力する
coverage html # 結果をHTMLにして出力する
# CI 時のテスト
ci-test:
coverage run manage.py test
coverage report
make coverage
coverage run manage.py test でテストに失敗するとその後の coverage report が実行されません。
ローカルなら常にレポートと HTML を作成して良いので、以下のようにハイフンを足して続行するようにしても良いでしょう。
-coverage run manage.py test
coverage run manage.py test で .coverage ファイルが生成されますが、これは .coveragerc ファイルに依存しますので、.coverage は Git で追跡しないようにするのが良いでしょう。
.coverage
coverage html で htmlcovディレクトリが生成されますが、このディレクトリの中に .gitignore ファイルがあり、デフォルトで Git 追跡されないようになっています。
CI/CDで自動テスト
意識せず自動でテストを実施する仕組みを作っていきます。ここでは GitHub Actions を利用してプルリクエスト作成時やマージのタイミングでテストを実施するようにします。これにより、「仕様変更におけるテストコードの修正漏れ」を牽制できるかもしれません。
name: CI
on:
push:
branches:
- main
- develop
pull_request:
branches:
- main
- develop
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run tests with coverage
run: |
make ci-test
データベースを指定する必要がありますが、テスト用とそれ以外で以下のように制御しました。
if os.environ.get("GITHUB_ACTIONS") == "true":
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
else:
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ.get("..."),
...
}
}
GITHUB_ACTIONS について
GITHUB_ACTIONS は、GitHub Actions 上でワークフローが実行されている場合、
常に true になります。
https://docs.github.com/ja/actions/reference/workflows-and-actions/variables
Github Actions 実行において、環境変数の未設定に起因するエラーが頻発しました。
Github Secrets を適切に設定するか、os.environ.get() のデフォルト値を指定するなどして対策すると良いでしょう。
おわりに
実は実務でテスト自動化を経験したことがなく、ここまでの内容は全て個人開発で実装したものとなります。テストの実施は大事であることを理解しているので、今後配属されるプロジェクトでこのナレッジを共有し導入したいと考えています。