14
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

【django】定番のテストをシンプルに実装してみた

Posted at

目的

django-restframeworkでAPIを作成したが、テストをまだ実装したことがなかったため、テストを初めて実装してみる。
その備忘録。

実施環境

ハードウェア環境

項目 情報
OS macOS Catalina(10.15.7)
ハードウェア MacBook Air (11-inch, Early 2015)
プロセッサ 1.6 GHz デュアルコアIntel Core i5
メモリ 4 GB 1600 MHz DDR3
グラフィックス intel HD Graphics 6000 1536 MB

ソフトウェア環境

項目 情報
homebrew 3.3.8
mysql Ver 8.0.27 for macos10.15 on x86_64
python 3.8.12
anaconda 4.10.1
django 3.0.0
pip 3.1.2

前提

ディレクトリの構成としてはこんな感じ。
所々省略。

.app_name
├── __init__.py
├── admin.py
├── apps.py
├── migrations
├── serializer.py
├── tests
│   ├── __init__.py
│   ├── test_job_view.py
│   ├── test_jobs_model.py
│   ├── test_jobs_url.py
│   ├── test_member_view.py
│   ├── test_members_model.py
│   └── test_members_url.py
├── urls.py
└── views.py

概要

今回実装するテストは下記のようにして実装する。

ファイル名 test概要 詳細
test_jobs_model.py, test_members_model.py 各modelでのデータの保存・取得が問題なく行えているか 初期状態でDBが綺麗な時にデータを取得しても何も取得できないか
test_jobs_model.py test_members_model.py 各modelでのデータの保存・取得が問題なく行えているか レコードを一つ作成すると、カウントが1となるか
test_jobs_model.py test_members_model.py 各modelでのデータの保存・取得が問題なく行えているか 保存前のレコードと保存後に取得したレコードに相違ないか
test_jobs_view.py test_members_view.py 各viewにリクエストを送った時に期待したresponseが返るか tokenを含めたリクエストは200が返るか
test_jobs_view.py test_members_view.py 各viewにリクエストを送った時に期待したresponseが返るか tokenを含めていないリクエストは401が返るか
test_jobs_url.py test_members_url.py 指定のurlで呼び出されるviewが正しいか 指定のurlで呼び出されるviewが正しいか

このようなテストを各model,view,urlごとに行うこととする。

これまでの設定

models.py

modelsは下記のようになっていて、Membersのjobが外部キーとなってJobsテーブルが外部テーブルとなっている。
今回はテストすることが主題のため、modelsの細かい説明は割愛。

models.py
import uuid

from django.db import models
from django.db.models.deletion import CASCADE
from django.db.models.fields import CharField

class Members(models.Model):
    GENDER_CHOICES = (
        ('M','man'),
        ('F','woman')
    )
    uuid = models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True)
    gender = models.CharField(max_length=10, choices=GENDER_CHOICES, verbose_name='性別')
    username = CharField(max_length=100, unique=True, verbose_name='名前')
    age = models.IntegerField(verbose_name='年齢')
    introduction = models.CharField(max_length=100, null=True, blank=True, verbose_name='自己紹介文')
    job = models.ForeignKey('Jobs', on_delete=CASCADE)
    created_at = models.DateTimeField(auto_now_add=True, verbose_name='登録日時')

    def __str__(self):
        return self.username

class Jobs(models.Model):
    job_name = models.CharField(max_length=20, verbose_name='職業名')
    average_salary = models.IntegerField(verbose_name='平均収入')
    paid_holiday_count = models.IntegerField(verbose_name='有給数')
    is_holiday_on_weekend = models.BooleanField(verbose_name='土日休みか')
    
    def __str__(self):
        return self.job_name

serializers.py

今回はテストすることが主題のため、serializersの細かい説明は割愛。

serializers.py
from rest_framework import serializers
from django.shortcuts import get_object_or_404
from .models import Members, Jobs
from django.contrib.auth.models import User
from django.contrib.auth.hashers import make_password

class JobSerializer(serializers.ModelSerializer):
    class Meta:
        # 対象のモデルを指定
        model = Jobs
        # 対象のフィールドを指定
        # created_atは対象外
        fields = ('id', 'average_salary', 'job_name','is_holiday_on_weekend','paid_holiday_count')

class MemberSerializer(serializers.ModelSerializer):
    class Meta:
        # 対象のモデルを指定
        model = Members
        # 対象のフィールドを指定
        # created_atは対象外
        fields = ('id', 'gender', 'username','age','introduction', 'job')
    
    # POST時はForeignKeyをpkのみ指定し、GET時はネストしたオブジェクトを展開する
    def to_representation(self, instance):
        response = super().to_representation(instance)
        response['job'] = JobSerializer(instance.job).data
        return response

class UserSerializer(serializers.ModelSerializer):
    # passwordをハッシュ化する
    def validate_password(self, value: str) -> str:
        return make_password(value)

    class Meta:
        model = User
        fields = ['id', 'username', 'password']
        extra_kwargs = {'password': {'write_only': True}}

views.py

viewsに関しても詳細は割愛。
長くなってしまうため今回の実装に関係なさそうな部分はカットする。

views.py
from django.http import response
from django.shortcuts import render
from .models import *
from django.contrib.auth.models import User
from .serializer import UserSerializer
from rest_framework import views, status
import django_filters
from rest_framework.exceptions import NotFound
from rest_framework.response import Response
from rest_framework import viewsets, filters
from rest_framework.permissions import IsAuthenticated
# @actionを利用するために必要
from rest_framework.decorators import action

from .models import Members
from django.contrib.auth.models import User
from .serializer import MemberSerializer, JobSerializer, UserSerializer

# ログ処理
import logging
from logging import getLogger, StreamHandler, Formatter

# 全てのHTTPメソッドに対応
# 一番シンプルで早く、個人開発向のModelViewSetで実装してみる
class MemberViewSet(viewsets.ModelViewSet):
    queryset = Members.objects.all()
    serializer_class = MemberSerializer
    # このViewSetはログイン済みのaccess_tokenを持つuserしかたたけない
    permission_classes = (IsAuthenticated,)

    # 取得したクエリセットを念のためdjango.logとターミナルに出力
    logger = logging.getLogger('command')
    logger.info(queryset)

class JobViewSet(viewsets.ModelViewSet):
    queryset = Jobs.objects.all()
    serializer_class = JobSerializer
    permission_classes = (IsAuthenticated,)

class UserViewSet(viewsets.ModelViewSet):
    queryset = User.objects.all()
    serializer_class = UserSerializer

urls.py

urlsに関しても詳細は割愛。
長くなってしまうため今回の実装に関係なさそうな部分はカットする。

urls.py
from django.urls import path, include
from .views import *
from django.db import router
from rest_framework import routers
from rest_framework_jwt.views import obtain_jwt_
from tinder import views

app_name = 'tinder'

# simpleRouterでGETの場合はbasename+'-list'がnameとなる
router = routers.SimpleRouter()
router.register(r'members', MemberViewSet)
# GETのname = url+'-list'
router.register(r'jobs', JobViewSet)
# Userデータの登録
router.register(r'regist', UserViewSet)

urlpatterns = router.urls

詳細

実際に概要であげた手順で上記のmodels, views, urlsをテストしていく。
もう一度テーブルを載せる。

ファイル名 test概要 詳細
test_jobs_model.py, test_members_model.py 各modelでのデータの保存・取得が問題なく行えているか 初期状態でDBが綺麗な時にデータを取得しても何も取得できないか
test_jobs_model.py test_members_model.py 各modelでのデータの保存・取得が問題なく行えているか レコードを一つ作成すると、カウントが1となるか
test_jobs_model.py test_members_model.py 各modelでのデータの保存・取得が問題なく行えているか 保存前のレコードと保存後に取得したレコードに相違ないか
test_jobs_view.py test_members_view.py 各viewにリクエストを送った時に期待したresponseが返るか tokenを含めたリクエストは200が返るか
test_jobs_view.py test_members_view.py 各viewにリクエストを送った時に期待したresponseが返るか tokenを含めていないリクエストは401が返るか
test_jobs_url.py test_members_url.py 指定のurlで呼び出されるviewが正しいか 指定のurlで呼び出されるviewが正しいか

modelsをテストする

ファイル名 関数名 test概要 詳細
test_jobs_model.py, test_members_model.py test_is_empty 各modelでのデータの保存・取得が問題なく行えているか 初期状態でDBが綺麗な時にデータを取得しても何も取得できないか
test_jobs_model.py test_members_model.py test_is_count_one 各modelでのデータの保存・取得が問題なく行えているか レコードを一つ作成すると、カウントが1となるか
test_jobs_model.py test_members_model.py test_saving_and_retrieving 各modelでのデータの保存・取得が問題なく行えているか 保存前のレコードと保存後に取得したレコードに相違ないか
test_jobs_model.py
from django.test import TestCase
from ..models import Jobs

class JobsModelTests(TestCase):
    def test_is_empty(self):
        """初期状態では何も登録されていないことをチェック"""  
        saved_jobs = Jobs.objects.all()
        self.assertEqual(saved_jobs.count(), 0)
    
    def test_is_count_one(self):
        """1つレコードを適当に作成すると、レコードが1つだけカウントされることをテスト"""
        # 1つレコードを適当に作成
        job = Jobs(
            job_name='singer',
            average_salary=10000,
            is_holiday_on_weekend=True,
            paid_holiday_count=100
        )
        # レコードをDBに保存
        job.save()
        # Jobテーブルの全レコード取得
        saved_job = Jobs.objects.all()
        # 全レコード合計: 1を確認
        self.assertEqual(saved_job.count(), 1)
    
    def test_saving_and_retrieving_jobs(self):
        """内容を指定してデータを保存し、すぐに取り出した時に保存した時と同じ値が返されることをテスト"""
        job = Jobs(
            job_name='writer',
            average_salary=10000,
            is_holiday_on_weekend=True,
            paid_holiday_count=100
        )
        job.save()
        # 保存したばかりのJobテーブルのレコード取得
        saved_job = Jobs.objects.first()
        # 保存したjobを取得したものと保存前のjobを比較
        self.assertEqual(saved_job, job)

test_is_emptyでは、Jobsのレコードを全件取得し、その件数が0であることを確認している。

なぜならdjangoでtestする時にはテスト用のDBが新規に作成され、テスト完了後にまたロールバック(削除され初期状態に戻る)されるため、何もレコードを保存していない初期状態では件数が0となる。

ちなみに関数の説明を「"""」で囲うことで、テスト実行時に関数の説明として表示することができるためかいたほうがいい。

assertEqual(a, b)でaとbがイコールなのかをテストすることができる

test_jobs_model.py
def test_is_empty(self):
        """初期状態では何も登録されていないことをチェック"""  
        saved_jobs = Jobs.objects.all()
        self.assertEqual(saved_jobs.count(), 0)

test_is_count_oneではJobsテーブルのレコードを一つ作成し、保存し、その後にまた取得し、一件入っているのか確認している。

test_jobs_model.py
def test_is_count_one(self):
        """1つレコードを適当に作成すると、レコードが1つだけカウントされることをテスト"""
        # 1つレコードを適当に作成
        job = Jobs(
            job_name='singer',
            average_salary=10000,
            is_holiday_on_weekend=True,
            paid_holiday_count=100
        )
        # レコードをDBに保存
        job.save()
        # Jobテーブルの全レコード取得
        saved_job = Jobs.objects.all()
        # 全レコード合計: 1を確認
        self.assertEqual(saved_job.count(), 1)

test_saving_and_retrieving_jobsではtest_is_count_oneと同じように、①保存したレコードを取得し、それが②保存前のレコードと相違ないかを確認する。

test_jobs_model.py
def test_saving_and_retrieving_jobs(self):
        """内容を指定してデータを保存し、すぐに取り出した時に保存した時と同じ値が返されることをテスト"""
        job = Jobs(
            job_name='writer',
            average_salary=10000,
            is_holiday_on_weekend=True,
            paid_holiday_count=100
        )
        job.save()
        # 保存したばかりのJobテーブルのレコード取得
        saved_job = Jobs.objects.first()
        # 保存したjobを取得したものと保存前のjobを比較
        self.assertEqual(saved_job, job)

他のmodelsに対しても他のファイルなどで同じように実装できればテストを実行。

$ python manage.py test アプリ名.tests.test_models.test_jobs_model

でテストを実行する。

viewsをテストする

ファイル名 関数名 test概要 詳細
test_jobs_view.py test_members_view.py test_token_auth 各viewにリクエストを送った時に期待したresponseが返るか tokenを含めたリクエストは200が返るか
test_jobs_view.py test_members_view.py test_unauth_get 各viewにリクエストを送った時に期待したresponseが返るか tokenを含めていないリクエストは401が返るか

views.pyで実装されている関数(JobViewSet, MemberViewSet)にGETリクエストを送ったときに、期待したレスポンスが返ってくるか確認するテストを行う。

AuthJobViewTestsクラスではtokenを含めた状態でリクエストを行い、200が返るか。
UnauthJobViewTestsクラスではtokenを含めた状態でリクエストを行い、401(認証エラー)が返るか。

test_jobs_view.py
from django.test import TestCase
from django.urls import reverse
from django.contrib.auth.models import User
from rest_framework.authtoken.models import Token
from rest_framework.test import APIRequestFactory
from rest_framework.test import APITestCase
from rest_framework.test import force_authenticate
from ..views import MemberViewSet
import json

class AuthMemberViewTests(APITestCase):
    """authorizedでGETメソッドでMemberViewTestsにアクセスしてステータスコード200を返されることを確認"""
    
    # ライフサイクルの初めにuser, tokenの作成を行う
    def setUp(self):
        # APIRequestFactoryのインスタンスを作成
        self.factory = APIRequestFactory()
        # Userのオブジェクトを作成、testDBに保存し、self.userへ代入
        self.user = User.objects.create_user(
            username='test', email='test@test.com', password='password')
    
    # setUp後に実行される
    def test_token_auth(self):
        # JobViewSetにtokenをつけて、GETメソッド
        request = self.factory.get('tinder:members-list')
        # requestにuser情報を追加し、認証済みrequestにする
        force_authenticate(request, user=self.user)
        # as_viewはMemberViewSetのインスタンス(member_view)を生成するようなもの
        member_view = MemberViewSet.as_view({'get': 'list'})
        # 認証済みのリクエストをMemberViewSetのインスタンス(member_view)に送る
        response = member_view(request)
        # JobViewSetのレスポンスが200であることを確認
        self.assertEqual(response.status_code, 200)

class UnauthMemberViewTests(TestCase):
  """MemberViewSetのテストクラス"""
  def test_unauth_get(self):
      """unauthorizedでGETメソッドでMemberViewTestsにアクセスしてステータスコード401を返されることを確認"""
      # tinder_appの'members-list'というurlのname(自動で決まる)にGETメソッド
      response = self.client.get(reverse('tinder:members-list'))
      # レスポンスのステータスコードが401になっているか。
      self.assertEqual(response.status_code, 401)

setUp関数は他のどのテスト関数よりも先に実行される。各関数で使われる変数などはここで定義しておくと再利用できて良い。

setUp関数では

  • APIRequestFactory(参考)のインスタンス作成
  • 新規認証ユーザーの作成

を行う

test_jobs_view.py
# ライフサイクルの初めにuser, tokenの作成を行う
    def setUp(self):
        # APIRequestFactoryのインスタンスを作成
        self.factory = APIRequestFactory()
        # Userのオブジェクトを作成、testDBに保存し、self.userへ代入
        self.user = User.objects.create_user(
            username='test', email='test@test.com', password='password')
    

test_token_auth関数では、コメントに記述してある通りJobViewSetにGETリクエストを投げ、そのレスポンスが200であることを確認している。

test_jobs_view.py
def test_token_auth(self):
        # JobViewSetにtokenをつけて、GETメソッド
        request = self.factory.get('tinder:members-list')
        # requestにuser情報を追加し、認証済みrequestにする
        force_authenticate(request, user=self.user)
        # as_viewはMemberViewSetのインスタンス(member_view)を生成するようなもの
        member_view = MemberViewSet.as_view({'get': 'list'})
        # 認証済みのリクエストをMemberViewSetのインスタンス(member_view)に送る
        response = member_view(request)
        # JobViewSetのレスポンスが200であることを確認
        self.assertEqual(response.status_code, 200)

force_authenticate(参考)を使うことで、userオブジェクトを利用し、直接認証をした状態で各viewにアクセスすることができる。

When testing views directly using a request factory, it's often convenient to be able to directly authenticate the request, rather than having to construct the correct authentication credentials.

To forcibly authenticate a request, use the force_authenticate() method.

UnauthMemberViewTestsクラスのtest_unauth_get関数ではAuthJobViewTestsと違って、認証を行わずにJobViewSetにリクエストを投げることで、認証エラーが起きるか確認している。

test_jobs_view.py
class UnauthJobViewTests(TestCase):
  """JobrViewSetのテストクラス"""
  def test_unauth_get(self):
      """unauthorizedでGETメソッドでJobViewTestsにアクセスしてステータスコード401を返されることを確認"""
      # tinder_appの'jobs-list'というurlのname(自動で決まる)にGETメソッド
      response = self.client.get(reverse('tinder:jobs-list'))
      # レスポンスのステータスコードが401になっているか。
      self.assertEqual(response.status_code, 401)
$ python manage.py test アプリ名.tests.test_models.test_jobs_view

でテストを実行する

urlをテストする

ファイル名 関数名 test概要 詳細
test_jobs_url.py test_members_url.py test_job_url, test_member_url 指定のurlで呼び出されるviewが正しいか 指定のurlで呼び出されるviewが正しいか
test_job_url.py
from django.test import TestCase
from django.urls import reverse, resolve
from ..views import JobViewSet

class TestUrls(TestCase):
    def test_job_url(self):
        """tinder_appのurl:jobのクラス名とJobViewSetクラス名が一致するか確認する"""
        # tinder_appのurl:jobのURLを代入
        view = reverse('tinder:jobs-list')
        self.assertEqual(resolve(view).func.__name__, JobViewSet.__name__)

test_job_urlではreverse(アプリ名: name)でurlsの該当箇所のviewを取得することができる。

今回で言えば最初の概要であげたurls.pyのnameの部分だ。
しかし、今回はsimplerouterを使っているため、nameを定義できない。どうするか。。。

urls.py
router.register(r'jobs', JobViewSet)

SimpleRouterを使っている場合は、公式で書いているように、simpleRouterでGETの場合はbasename+'-list'がnameとなる。
スクリーンショット 2022-01-11 14.17.21.png

そしてその{basename}は同じく公式で書いているように、

The base to use for the URL names that are created. If unset the basename will be automatically generated based on the queryset attribute of the viewset, if it has one. Note that if the viewset does not include a queryset attribute then you must set basename when registering the viewset.

自ら"basename="と定義しない場合はviewsetのクエリセットがbasenameとなる(→今回の場合は"jobs")ということかと思う。
そのため、'jobs'+'-list'でjobs-listがjobsURLの"name"ということになる。

$ python manage.py test アプリ名.tests.test_models.test_jobs_url

でテストを実行する

複数のテストをテストする

上記のようにして、それぞれのテストケースの実装、テストの実行は完了したが、実務ではこれらを一つずつ実行するのは手間かと思う。

そこでアプリ名/配下のtest.pyなどのあるディレクトリにtestsディレクトリを作成し、その中にtestファイルを全て保存している状態で、複数のテストファイルを同時に実行するためには下記のコマンドを利用する。

$ python manage.py test

で__init__.pyの含まれているtestsディレクトリ内のtest_●●.pyのテストケースが実行される。

$ python manage.py test
・
・
・
test_token_auth (tinder.tests.test_job_view.AuthJobViewTests) ... ok
test_unauth_get (tinder.tests.test_job_view.UnauthJobViewTests)
unauthorizedでGETメソッドでJobViewTestsにアクセスしてステータスコード401を返されることを確認 ... ok
test_is_count_one (tinder.tests.test_jobs_model.JobsModelTests)
1つレコードを適当に作成すると、レコードが1つだけカウントされることをテスト ... ok
test_is_empty (tinder.tests.test_jobs_model.JobsModelTests)
初期状態では何も登録されていないことをチェック ... ok
test_saving_and_retrieving_jobs (tinder.tests.test_jobs_model.JobsModelTests)
内容を指定してデータを保存し、すぐに取り出した時に保存した時と同じ値が返されることをテスト ... ok
test_job_url (tinder.tests.test_jobs_url.TestUrls)
tinder_appのurl:jobのクラス名とJobViewSetクラス名が一致するか確認する ... ok
test_token_auth (tinder.tests.test_member_view.AuthMemberViewTests) ... ok
test_unauth_get (tinder.tests.test_member_view.UnauthMemberViewTests)
unauthorizedでGETメソッドでMemberViewTestsにアクセスしてステータスコード401を返されることを確認 ... ok
test_is_count_one (tinder.tests.test_members_model.MembersModelTests)
1つレコードを適当に作成すると、レコードが1つだけカウントされることをテスト ... ok
test_is_empty (tinder.tests.test_members_model.MembersModelTests)
初期状態では何も登録されていないことをチェック ... ok
test_saving_and_retrieving_jobs (tinder.tests.test_members_model.MembersModelTests)
内容を指定してデータを保存し、すぐに取り出した時に保存した時と同じ値が返されることをテスト ... ok
test_job_url (tinder.tests.test_members_url.TestUrls)
tinder_appのurl:memberのクラス名とMemberViewSetクラス名が一致するか確認する ... ok

----------------------------------------------------------------------
Ran 12 tests in 0.600s

OK

無事全てのテストケースが実行され、テスト対象にバグがないことが確認できた。

以降は一つずつテストを行うのではなく、このように全てのテストケースを実行し、バグがないか定期的に探すこととする。

参考文献

14
13
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
14
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?