python / Django
作成日:2020/3/10 更新日:2020/3/16

DjangoでGCPを活用した実践的な開発 Part4 [ 3.x対応 ]

Djangoのテストについて、pytestやfactory_boyを使ってユニットテスト、クライアントテストを行います。

Web開発におけるDjangoの効率的なテスト

DjangoをAPI,Web用途で使った場合は標準のUnittestで書くのもよいのですが、主にテストしたい部分はビューが正常に動作しているかといったクライアントテストが多くなりがちです。そのため、全てのビューをテストする関数、メソッドを作るより、Pytestの@pytest.mark.parametrizeを利用するとスマートにコーディングできます。

モデルを追加した場合のテスト実装

以下のようにモデルを変更して、どのようなテストを追加するか確認していきましょう。

--- a/src/app/models.py
+++ b/src/app/models.py
@@ -41,7 +41,20 @@ class User(AbstractUser):
     def __str__(self) -> str:
         return self.username
 
-    # FIXME: Define subscriber.
     @property
     def is_subscriber(self) -> bool:
+        if hasattr(self, 'subscription'):
+            return self.subscription.expired_at > timezone.now()
         return False
+
+
+class Subscription(models.Model):
+
+    class Meta:
+        verbose_name = _('Subscription')
+        verbose_name_plural = _('Subscriptions')
+        ordering = ['-id']
+
+    user = models.OneToOneField(
+        "app.User", verbose_name=_("Users"), on_delete=models.CASCADE)
+    expired_at = models.DateTimeField(_("Expired at"))

ユーザーモデルとリレーションを持った購読のモデルを追加して、課金ユーザーか、そうでないかをチェックするis_subscriberのプロパティにロジックを実装してみました。

factory_boyでモデルのインスタンスを簡素化

--- a/src/app/tests.py
+++ b/src/app/tests.py
@@ -1,6 +1,7 @@
 import pytest
 from django.urls import reverse
-from factory import fuzzy
+from django.utils import timezone
+from factory import fuzzy, SubFactory
 from factory.django import DjangoModelFactory
 
 from . import models
@@ -16,29 +17,54 @@ class UserFactory(DjangoModelFactory):
     password = fuzzy.FuzzyText(length=16)
 
     @classmethod
-    def create_unsubscriber(cls, username: str) -> models.User:
-        subscriber = UserFactory(username=username, password='password')
-        return subscriber
+    def create_user(cls, username: str) -> models.User:
+        user = UserFactory(username=username, password='password')
+        return user
+
+
+class SubscriptionFactory(DjangoModelFactory):
+
+    class Meta:
+        model = models.Subscription
+        django_get_or_create = ('user', 'expired_at')
+
+    user = SubFactory(UserFactory)
+    expired_at = timezone.now() + timezone.timedelta(days=1)
 
 
 @pytest.fixture(autouse=True)
 def unsubscriber() -> models.User:
-    return UserFactory.create_unsubscriber('unsubscriber')
+    return UserFactory.create_user('unsubscriber')
+
+
+@pytest.fixture(autouse=True)
+def expired_subscriber():
+    user = UserFactory.create_user('expired_subscriber')
+    SubscriptionFactory(
+        user=user, expired_at=timezone.now() - timezone.timedelta(days=1))
+    return user
 
 
-# FIXME: Define subscriber.
-# @pytest.fixture(autouse=True)
-# def subscriber():
-#     return UserFactory.create_subscriber()
+@pytest.fixture(autouse=True)
+def subscriber():
+    user = UserFactory.create_user('subscriber')
+    SubscriptionFactory(user=user)
+    return user
 
 
 @pytest.mark.django_db
 class TestModel:
 
-    # FIXME: Define subscriber.
-    def test_is_subscribe(self, unsubscriber) -> bool:
+    def test_subscriber(self, subscriber) -> bool:
+        assert subscriber.is_subscriber
+
+    def test_unsubscriber(self, unsubscriber) -> bool:
         assert not unsubscriber.is_subscriber
 
+    def test_expired_subscriber(self, expired_subscriber) -> bool:
+        assert not expired_subscriber.is_subscriber

 

fixtureを作成して、引数から使えるように関数を3つ追加しました。開発するWebサービスの多くは、ロールベース(役割)の制限は必ずと言っていいほどありますし、継続的に開発していくと、その定義も派生が生まれたり、統合されたり、なくなったりと仕様変更があった場合に、役割毎に定義して検証するモデルを基に実装しておけば、後々の実装、改修が楽になりますし、見通しが良いです。

シグナルはミュートしておき、別にテストを作って明示的にする

djangoのシグナルはモデルの振る舞いに応じて、サブプロセスで実行でき、扱いやすく、便利な機能なのですが、テストではモデルの振る舞いに応じて処理が発生してしまうと、リレーションモデルがシグナルで作られていて、テストが予期しない結果になる場合があります。ですので、シグナルはミュートしておき、別のテストで実行したほうが誤解することなく、明示的なので見通しが良いと思います。

# tests.py
# snippet

import factory
from . import signals

@factory.django.mute_signals(signals.post_save, signals.pre_delete)
class UserFactory(DjangoModelFactory):

    class Meta:
        model = models.User

外部サービスに依存しているモデルのメソッド

単純なPOSTだけではなく、サードパティのAPIからレスポンスをもらって処理というのは、APIが広く使われている今ではよくあることです。そういった場合はテスト用のAPIがあればそれを使うに越したことはないのですが、テストし辛い場合はAPI部分の処理だけを1つのメソッドにしておいて、mockを使って、レスポンスのフィクスチャーを使うと良いでしょう。

# tests.py
# snippet

from unittest import mock

class SubscriptionMock:
    status = 'canceled'

    @classmethod
    def delete(cls, args):
        return cls()

@mock.patch('Subscription.cancel', StripeSubscriptionMock.delete)
    def test_card_unsubscribe_view(self, client, subscriber):
        client.force_login(subscriber)
        resp = client.post(reverse('unsubscribe'))
        assert resp.status_code == 200
        assert subscirber.subscription.status == 'canceled'

ビューを追加した場合のテスト実装

例として、ユーザーに紐づくサブスクリプションを申し込む画面、申込みデータ作成の処理を追加するイメージの実装を追加します。以下の差分を見て、ビュー、フォーム、ルーティングを追加しましょう。

--- a/src/app/views.py
+++ b/src/app/views.py
@@ -1,14 +1,15 @@
 import logging
 
+from django.contrib.auth.decorators import login_required
+from django.contrib.auth.views import LoginView
+from django.http import HttpResponseBadRequest
 from django.urls import reverse_lazy
+from django.utils import timezone
 from django.utils.decorators import method_decorator
-from django.contrib.auth.decorators import login_required
-
-from django.views.generic.base import TemplateView
 from django.views.generic import CreateView
-from django.contrib.auth.views import LoginView
+from django.views.generic.base import TemplateView
 
-from . import forms, decorators
+from . import decorators, forms
 
 logger = logging.getLogger(__name__)
 
@@ -44,3 +45,26 @@ class InheritedLoginView(LoginView):
     def get_success_url(self) -> str:
         url = self.get_redirect_url()
         return url or reverse_lazy('app:dashboard')
+
+
+@method_decorator([login_required], 'dispatch')
+class SubscriptionCreateView(CreateView):
+    form_class = forms.SubscriptionForm
+    success_url = reverse_lazy('app:dashboard')
+    template_name = 'subscription-create.html'
+
+    def get(self, request, *args, **kwargs):
+        if request.user.is_subscriber:
+            return HttpResponseBadRequest()
+        return super().get(self, request, *args, **kwargs)
+
+    def post(self, request, *args, **kwargs):
+        if request.user.is_subscriber:
+            return HttpResponseBadRequest()
+        return super().post(self, request, *args, **kwargs)
+
+    def form_valid(self, form):
+        form.instance.user = self.request.user
+        form.instance.expired_at = timezone.now() + \
+            timezone.timedelta(days=365.25)
+        return super().form_valid(form)

--- a/src/app/urls.py
+++ b/src/app/urls.py
@@ -17,4 +17,6 @@ urlpatterns = [
         'logout', LogoutView.as_view(template_name='logout.html'),
         name='logout'
     ),
+    path('subscription-create', views.SubscriptionCreateView.as_view(),
+         name='subscription-create'),
 ]


--- a/src/app/forms.py
+++ b/src/app/forms.py
@@ -4,7 +4,7 @@ from django import forms
 from django.contrib.auth.forms import UserCreationForm, UsernameField
 from django.utils.translation import gettext_lazy as _
 
-from . models import User
+from . models import User, Subscription
 
 logger = logging.getLogger(__name__)
 
@@ -18,3 +18,10 @@ class RegistrationForm(UserCreationForm):
         model = User
         fields = ('username', )
         field_classes = {'username': UsernameField}
+
+
+class SubscriptionForm(forms.ModelForm):
+
+    class Meta:
+        model = Subscription

これらの追加によってテストを役割ベースでテストするように書き換えました。

import pytest
from django.urls import reverse
from django.utils import timezone
from factory import fuzzy, SubFactory
from factory.django import DjangoModelFactory

from . import models


class UserFactory(DjangoModelFactory):

    class Meta:
        model = models.User
        django_get_or_create = ('username', 'password')

    username = fuzzy.FuzzyText(length=16)
    password = fuzzy.FuzzyText(length=16)

    @classmethod
    def create_user(cls, username: str) -> models.User:
        user = UserFactory(username=username, password='password')
        return user


class SubscriptionFactory(DjangoModelFactory):

    class Meta:
        model = models.Subscription
        django_get_or_create = ('user', 'expired_at')

    user = SubFactory(UserFactory)
    expired_at = timezone.now() + timezone.timedelta(days=1)


@pytest.fixture(autouse=True)
def unsubscriber() -> models.User:
    return UserFactory.create_user('unsubscriber')


@pytest.fixture(autouse=True)
def expired_subscriber():
    user = UserFactory.create_user('expired_subscriber')
    SubscriptionFactory(
        user=user, expired_at=timezone.now() - timezone.timedelta(days=1))
    return user


@pytest.fixture(autouse=True)
def subscriber():
    user = UserFactory.create_user('subscriber')
    SubscriptionFactory(user=user)
    return user


@pytest.mark.django_db
class TestModel:

    def test_subscriber(self, subscriber) -> bool:
        assert subscriber.is_subscriber

    def test_unsubscriber(self, unsubscriber) -> bool:
        assert not unsubscriber.is_subscriber

    def test_expired_subscriber(self, expired_subscriber) -> bool:
        assert not expired_subscriber.is_subscriber


@pytest.mark.django_db
class TestView:

    @pytest.mark.parametrize(
        'url, status_code',
        [
            (reverse('app:index'), 200),
            (reverse('app:registration'), 200),
            (reverse('app:logout'), 200),
            (reverse('app:dashboard'), 302),
            (reverse('app:free-content'), 302),
            (reverse('app:paid-content'), 302),
            (reverse('app:subscription-create'), 302),
        ]
    )
    def test_anonymous_views(self, client, url, status_code) -> bool:

        resp = client.get(url)
        assert resp.status_code == status_code

    @pytest.mark.parametrize(
        'url, status_code',
        [
            (reverse('app:index'), 200),
            (reverse('app:registration'), 200),
            (reverse('app:logout'), 200),
            (reverse('app:dashboard'), 200),
            (reverse('app:free-content'), 200),
            (reverse('app:paid-content'), 403),
            (reverse('app:subscription-create'), 200),
        ]
    )
    def test_unsubscriber_views(self, client, unsubscriber,
                                url, status_code) -> bool:

        client.force_login(unsubscriber)
        resp = client.get(url)
        assert resp.status_code == status_code

    @pytest.mark.parametrize(
        'url, status_code',
        [
            (reverse('app:index'), 200),
            (reverse('app:registration'), 200),
            (reverse('app:logout'), 200),
            (reverse('app:dashboard'), 200),
            (reverse('app:free-content'), 200),
            (reverse('app:paid-content'), 200),
            (reverse('app:subscription-create'), 400),
        ]
    )
    def test_subscriber_views(self, client, subscriber, url,
                              status_code) -> bool:

        client.force_login(subscriber)
        resp = client.get(url)
        assert resp.status_code == status_code

    def test_subscription_create(self, client, unsubscriber):
        client.force_login(unsubscriber)
        resp = client.post(reverse('app:subscription-create'), {})
        assert resp.status_code == 302
        assert unsubscriber.is_subscriber

    def test_not_allow_subscription_create(self, client, subscriber):
        client.force_login(subscriber)
        resp = client.post(reverse('app:subscription-create'), {})
        assert resp.status_code == 400

このように全てのビュー(画面)に対して役割に応じた振る舞いを返す処理を作っておくことで、「本来使えないはず」、「アクセスできないはず」といったよくある処理漏れが発生しにくいテストを作ることができます。

次回はGCPのCloudBuildでCI/CDを実現する方法について紹介したいと思います。

関連記事

ポリシー

この記事のすべてまたは一部の複製は、著作権者の同意なしでは禁止されています。 引用については著者名と記事のURLが表示されている場合に限り認められます。