テストコードにチャレンジしよう
Djangoで独自のアプリを開発していると、テストについて考えるようになってくると思います。
「テストコードがない=品質に保証がない」となる場合があるので、簡単なテストだけでも書けるようになっておくと良いです。
テストコードは、クライアントや第三者のためにあると思われるかもしれませんが、開発者にも大きなメリットがあります。
テスト無しの場合
- コーディングする(runserverでエラーチェック)
-
http://127.0.0.1:8000/
でページや関数の数だけ手動で動作チェック - ページやviewが多いと面倒くさい
テスト有りの場合
- コーディングする(runserverでエラーチェック)
-
python manage.py test
で全てのページと関数を自動でチェック - ページやviewが多くなっても楽ちん
あとrunserverで表示されないエラーも表示されちゃう!
知っておくこと
- テストコードがアプリケーションのコードよりも大きくなってしまう場合がある。
- テストコードは多いほど良い。
- テストコードは美しくなくても良い。
アプリケーションのコードより、テストコードが大きくなってしまう事は良くあることらしく、それほど重要で便利なものとして認識されています。
どこからテストしようか?
- 何をテストすればいいの?
- コードはどう書けばいいの?
みたいな疑問が沢山あり、初めてのテストに踏み込めない方もいると思いますが、全てテストしようとすると気が滅入るので、まずは一つずつやっていきましょう。
アプリケーションを完成させてからテストコードを書くと割と大変なので、開発をしながらでもコツコツとテストコードを書くと良いです。
何をテストする?
Djangoのプロジェクトの構成を見てみましょう。
- urls.py
- views.py
- models.py
- forms.py
簡単に言うと、これらのお決まりファイルがエラーを出さず正常に動けば良いわけです。
なのでテストの対象はこれらのファイルの中身ということになります。
役割を知る
各ファイルの役割を知れば、どうテストすれば良いか明確になります。
- urls.py
pathからviewを呼び出す役割 - views.py
関数やクラスでレンダリング等を行う役割 - models.py
テーブルを作成する役割(フィールド名など) - forms.py
フォームの設定やバリデーションをする役割
テストはこれらの役割が果たせているかどうかをチェックします。
テストの準備
デフォルトでは、アプリケーション直下にtests.py
が自動で作成されます。
この中に書いても良いのですが、コードが多いアプリケーションの場合、新たにtestsディレクトリを作成し、その中にテストファイルを作成すると管理しやすいです。
自動で作成されたtests.pyは削除します。
app名
templates
app名
base.html
index.html
dashboard.html
tests(作成)
__ init __.py(作成)
test_urls.py(作成)
test_views.py(作成)
test_models.py(作成)
test_が付いているファイルをDjangoが自動でテストしてくれます。
先頭にtest_がついていればファイル名は何でもOKです。
早速各ファイルが役割を果たせているかどうかチェック(テスト)してみましょう!
ここからは自身のテストコードを書きながら進めると体感できて良いです。
urls.pyのテスト
アプリ作成済みということ前提で、まずは簡単なurls.pyのテストからやってみようと思います。
urls.pyの役割はviewを呼び出すことなので、対象のviewが呼び出せているかチェックします。
urlpatterns = [
path('', views.index, name='index'),
path('dashboard/', views.DashboardView.as_view(), name='dashboard'),
]
このようなurls.pyがあった場合
exampe.com/で、index
の関数
exampe.com/dashboard/で、DashboardView
のクラス
が呼び出せていれば良いわけです。
# TestCaseを読み込む(お決まり)
from django.test import TestCase
# reverseでnameからpathを、resolveでviewを呼び出す
from django.urls import reverse, resolve
# 全てテストする予定なので*でviewを全て呼び出す。
from .views import *
class TestsUrls(TestCase):
""" indexの関数が呼び出せているかテスト """
def test_index_url(self):
url = reverse('appname:index')
# url.funcが、indexと等しいかどうか
self.assertEqual(resolve(url).func, index)
""" DashboardViewのクラスが呼び出せているかテスト """
def test_dashboard_url(self):
url = reverse('appname:dashboard')
# url.func.view_classが、DashboardViewと等しいかどうか
self.assertEqual(resolve(url).func.view_class, DashboardView)
- (TestCase)はお決まり
- メソッド名は「test_」から始める
- assertEqualは、第一引数と第二引数の値が等しい場合に成功します。第二引数に期待する値を書きます。
- 関数(def)の場合、
.func
- クラス(class)の場合、
.func.view_class
メソッド | 内容 | 説明 |
---|---|---|
assertEqual(a, b) | a == b | aとbが等しい時 |
assertNotEqual(a, b) | a != b | aとbが等しくない時 |
一旦テストしてみましょう!
$ python manage.py test
Found 2 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
..
----------------------------------------------------------------------
Ran 2 tests in 0.01s
OK
OKがでれば成功です!
失敗した場合は以下の様な表示になり、エラーの内容とFAILED (errors=1)
などが表示されるのでそこを修正しまします。
E.
======================================================================
ERROR: test_index_url (tests.tests.TestsUrls)
----------------------------------------------------------------------
エラーの内容
FAILED (errors=1)
views.pyのテスト
viewの役割は、関数やクラスでレンダリングや様々な指示を出すことなので、ここでは正常に画面が表示されるかをチェックします。
このようなviewがあるとします。
# ログイン必須
@login_required
def index(request):
context = {}
return render(request, 'appname/index', context)
# ログイン必須
class DashboardView(LoginRequiredMixin, ListView):
model = User
template_name = 'appname/dashboard.html'
context_object_name = "users_list"
- ログインしていない場合は
ログインページ
+ログイン後のリダイレクト先
が期待値 - ログインしている場合のステータスコードは
200
が期待値
from django.test import TestCase
from .views import *
# ログインしていない場合
class TestsStatusCord(TestCase):
```トップページが/?next=付きログインページにリダイレクトされ遷移先で200を返すかテスト```
def test_index_status_code(self):
response = self.client.get('/')
self.assertRedirects(response, '/accounts/login/?next=/')
```ダッシュボードが/?next=付きでログインページにリダイレクトされ遷移先で200を返すかテスト```
def test_dashboard_status_code(self):
response = self.client.get('/dashboard/')
self.assertRedirects(response, '/accounts/login/?next=/dashboard/')
# ログインしている場合
class TestsLoginStatusCord(TestCase):
``` setUpで様々な共通の設定ができる ```
def setUp(self):
# ユーザーを作成
user = User.objects.create(username='tarou', password='12345678', email='1@gmail.com')
# ログインさせる
self.client.force_login(user)
```トップページが正常に表示できるかテスト```
def test_index_login_status_code(self):
response = self.client.get('/')
self.assertEqual(response.status_code, 200)
```ダッシュボードが正常に表示できるかテスト```
def test_dashboard_login_status_code(self):
response = self.client.get('/dashboard/')
self.assertEqual(response.status_code, 200)
$ python manage.py test
Found 6 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
......
----------------------------------------------------------------------
Ran 6 tests in 0.12s
OK
テストで作成したオブジェクトは、テスト終了時に自動で削除されます。
Client()について
擬似的なブラウザでのアクセスを実行しResponseを取得することができるテスト用モジュールです。
# Clientをインスタンス化
client = Client()
client.get()
# 上と同等
self.client.get()
setUp()について
setUpは全メソッドで毎回実行されるので、共通のコードを書きます。
以下の内容と上記は同等です。
毎回メソッドでユーザー作成をすると重複するので、setUpに書いちゃった方が良いです。
class TestsLoginStatusCord(TestCase):
```トップページが正常に表示できるかテスト```
def test_index_login_status_code(self):
+ user = User.objects.create(username='tarou', password='12345678', email='1@gmail.com')
+ self.client.force_login(user)
response = self.client.get('/')
self.assertEqual(response.status_code, 200)
```ダッシュボードが正常に表示できるかテスト```
def test_dashboard_login_status_code(self):
+ user = User.objects.create(username='tarou', password='12345678', email='1@gmail.com')
+ self.client.force_login(user)
response = self.client.get('/dashboard/')
self.assertEqual(response.status_code, 200)
setUpTestData()について
setUp
がメソッド毎に一度実行されるのに対して、setUpTestData
はクラス毎に一度実行されます。
当然、処理数が少なくなるのでserUpより早くテストが完了しますが注意が必要です。
setUpTestData
は、最初に一度だけ実行されるので、削除や更新が必要なメソッド(view)がある場合は、オブジェクトの状態が変わります。
class TestsLoginStatusCord(TestCase):
@classmethod
def setUpTestData(self):
self.user = User.objects.create(username='tarou', password='12345678', email='1@gmail.com')
self.client.force_login(self.user)
def test1(self):
# self.userに対するテスト
def test2(self):
# self.userに対する他のテスト
models.pyのテスト
モデルはデータベースにテーブルを作成するものなので、フィールドから期待した値が取得できるかチェックしてみます。
色々と省略していますが、以下のユーザーmodelがあるとします。
from django.db import models
from django.contrib.auth.models import AbstractBaseUser
class User(AbstractBaseUser):
username = models.CharField(max_length=50, unique=True)
email = models.EmailField(max_length=100, unique=True)
# 日時
date_joined = models.DateTimeField(auto_now_add=True)
# 権限
is_active = models.BooleanField(default=True)
is_staff = models.BooleanField(default=False)
is_admin = models.BooleanField(default=False)
from django.test import TestCase
from unittest import mock
from datetime import datetime
from .models import User
class UserModelTest(TestCase):
''' 初期値をテスト '''
def test_default_values(self):
# ユーザーを作成
mock_date = datetime(2022, 10, 2, 20, 1, 11, 703055)
with mock.patch('django.utils.timezone.now') as mock_now:
mock_now.return_value = mock_date
user = User.objects.create(username='tarou', password='12345678', email='1@gmail.com')
self.assertEquals(user.username, 'tarou')
self.assertEquals(user.email, '1@gmail.com')
self.assertEquals(user.date_joined, mock_date)
self.assertEquals(user.is_active, True)
self.assertEquals(user.is_staff, False)
self.assertEquals(user.is_admin, False)
self.assertEquals(user.date_joined.strftime("%Y-%m-%d"), '2022-10-02')
mock_date
の部分について少し解説。
ユーザーを作成した時、date_joined
のフィールドがauto_now_add=True
になっているので、現在の時刻が反映されてしまいます。
これだと第二引数の期待値を設定できないので、mockで仮の時刻を与えてあげます。
mock_date
で仮の日時を設定後、with内でtimezone.now
にmock_date
を代入しユーザーを作成することで、date_joined
のフィールドの日時が、mock_date
の日時で作られます。
forms.pyのテスト
フォームについては、バリデーションがメインだと思うのでバリデーションのチェックをテストします。
from Django.test import TestCase
from myapp.forms import MyForm
class TestForms(TestCase):
def test_myform_test(self):
form_data = {
'username': 'tarou', 'email': '1@gmail.com'
}
form = MyForm(form_data)
self.assertTrue(form.is_valid())
まとめ?
日時のテストは少し難しく感じたかもしれませんが、最初は簡単なところから一つだけテストコードを書いてみることをオススメします!
self.assertEquals
の形で書いているうちに、色々と理解できてくると思います。
アサートの種類についてはこちらの公式ドキュメントで確認できます。
記事に間違い等があればコメントくらさい(゚∀゚)