2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

今回は、私が普段利用している 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 → ビジネスロジックを行う
といった切り分けをしています。

ソースコード

todo/models.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)

todo/urls.py
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"),
]
todo/views.py
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)
todo/services.py
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 ディレクトリを作成し、viewsservices など役割単位でテストファイルを分ける構成がおすすめです。 modelsserializers に独自の処理があるならそれら専用のテストファイルを用意するのもありですね。

外部 API を利用している箇所については、patch を使ってモックに置き換えることで、外部の状態に依存しないテストが可能になります。これにより、予期せぬ挙動や課金の発生といったリスクも避けられます。

  • servicesmodelsserializers で単体テスト → Django の TestCase
  • views で統合テスト → rest_framework の APITestCase

テストコードを作成したあと、以下のコマンドを実行することでテストが行われます。

python manage.py test

以下はテストコードの例です。

todo/tests/test_services.py
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()
todo/tests/test_views.py
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_namepath(..., name=...) を入力することで、
reverse("todo:list") のように url を設定できます。
プロジェクトが大きくなっても衝突しにくそうです。


認証機能を実装していてAPIがログイン必須の場合、このままではエラーになりますので認証をスキップすることが必要です。self.client.force_authenticate でできます。

todo/tests/test_views.py
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 を呼び出す際にそれらを渡すと良いでしょう。

todo/tests/test_views.py
# 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

todo/tests/factories.py
import factory

from todo.models import Todo


class TodoFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = Todo

    title = factory.Faker("sentence")
    detail = factory.Faker("text")
todo/tests/test_services.py
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)
todo/tests/test_views.py
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 ファイルに設定を記入します。

.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 でまとめるのが便利です。

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 で追跡しないようにするのが良いでしょう。

.gitignore
.coverage

coverage htmlhtmlcovディレクトリが生成されますが、このディレクトリの中に .gitignore ファイルがあり、デフォルトで Git 追跡されないようになっています。

CI/CDで自動テスト

意識せず自動でテストを実施する仕組みを作っていきます。ここでは GitHub Actions を利用してプルリクエスト作成時やマージのタイミングでテストを実施するようにします。これにより、「仕様変更におけるテストコードの修正漏れ」を牽制できるかもしれません。

.github/workflows/test.yml
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

データベースを指定する必要がありますが、テスト用とそれ以外で以下のように制御しました。

project/settings.py
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() のデフォルト値を指定するなどして対策すると良いでしょう。

おわりに

実は実務でテスト自動化を経験したことがなく、ここまでの内容は全て個人開発で実装したものとなります。テストの実施は大事であることを理解しているので、今後配属されるプロジェクトでこのナレッジを共有し導入したいと考えています。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?