DjangoでGCPを活用した実践的な開発 Part4 [ 3.x対応 ]
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を実現する方法について紹介したいと思います。