LoginSignup
1
2

More than 3 years have passed since last update.

Pythonでのチームアプリ開発に参画するための学習履歴~Djangoチュートリアル5~

Last updated at Posted at 2020-04-22

はじめに

今回はアプリの機能からは離れてテストについて触れていきます。

テストについて

私は就活をするために以前 こういうもの を作ったのですが、それを実際に公開して面接で担当者の方に見て頂いた時や、触っていただいた方からフレームワークの理解が足りないという指摘を多く頂きました。
その中の1つがテストコードがないというものでした。

公式チュートリアルより

なぜテストを作成する必要があるのか
どうしてテストを作るのか?また、なぜ今なのか?
もしかしたら、 Python や Django を学ぶのに手一杯で、さらに別のことを学ぶのは大変で不必要なことだと思われるかもしれません。だって投票アプリケーションはきちんと動いているし、わざわざ自動テストを導入したところでアプリケーションがより良くなるわけではないのだから。もし Django プログラミングを学ぶ目的がこの投票アプリケーションを作ることだけならば、確かに自動テストの導入は必要ないと思います。しかし、そうではないなら、今こそ自動テストについて学ぶ絶好の機会です。

公式チュートリアルでもテストについてこのように紹介されています。
ではなぜテストを書くのかというと

  • アルゴリズムの検証を自動化して組み込んでおけば、システムを構成する要素(コンポーネント)のどこかに変化を加えた際にそれが正常に動作するかしないか、あるいはしない場合はどこにどういう問題があるかということがすぐに把握することができる。

  • アルゴリズムの検証をするコードを書けるということは逆に言えば、そのアルゴリズムに対する理解が十全であるということの証明になる

大きく分けてこの2点に尽きるのだと感じました。
前者はいわゆる完全性とか保守・メンテナンスの観点から重要だということですね。
特に我々のような初学者だと

公式チュートリアルより

テストを書くことはチームで共同作業を行う上で役に立ちます。
これまでの点は、1人の開発者でアプリケーションをメンテナンスしているという観点から書きました。しかし、複雑なアプリケーションはチームでメンテナンスされるようになるものです。テストは、あなたが書いたコードを他人がうっかり壊してしまうことから守ってくれます (そして、他の人が書いたコードをあなたが壊してしまうことからも)。Django のプログラマとして生きてゆくつもりなら、良いテストを絶対に書かなければなりません!

こういう考えが欠如しがちですが、自分1人でやっていても昨日の自分や数時間前の自分のタイプミスや変なコードで今の自分の書いたコードに支障が出るのですからチームでやるとなるとそのリスクがあるのは言われてみれば想像に難くないです。

後者に関してはつまりテストまでかけて初めてコーダーとして名乗れるということなのだなと感じました。
では、進めていきます。

テストはいつ作成するべきなのか?

  1. テストを書いてからコードを書くという意見
    → テストが書ければコードは書けるという理論に則ったもの。実現したい処理に対する問題を明らかにしてからコードを書き始めるということ。

  2. コードを書いてからテストを書くという意見
    → 自分が書いたコードが正常に動くかどうかを確かめるという理論に則って書く。

  3. 新しい機能の追加・バグチェック・既存のコンポーネントの変更を行ったときについでに書く

どれが正解なのかというのは私にはわからないですが、1ができる人はもう立派なプログラマーだと思います。
では、私達初学者が目指すのはどこなのかというと1と2の折衷のような3の段階にまず進むことを意識するということなのだと私は思います。

テストを書くことに時間を割くということは単に「動くものを作る」という目的から見れば回り道もいいところで、初学者にとって歯がゆく感じるところであります。

しかし、再三申し上げる通りテストが書けなければ、自分の書いたコードを理解していないという評価を周りから受けてしまっても致し方ないわけであり、ではそこから脱却するのにまずするべきなのはということが3に当たるのだと私は思っています。

バグチェックのテストコードを書いてみる。

では、ここからは3で書いたことを意識しながらDjangoのチュートリアルのテストの項目をやっていきます。
以前 においてmodels.pyにおいてバグがあったのを覚えているでしょうか。
いわゆる未来日付が表示されてしまうバグです。

shellコマンドを使って以下のコマンドを実行します。

import datetime
from django.utils import timezone
from polls.models import Question

future_question = Question(pub_date=timezone.now() + datetime.timedelta(days=30)) #1
future_question.was_published_recently() #2

1ではtimedeltaメソッドで未来の日付のインスタンスを作っています。
pub_date=timezone.now() + datetime.timedelta(days=30)なので、現在から数えて30日後の日付情報を持ったインスタンスを発行します。

2ではそのインスタンスを用いて、was_published_recently()メソッドを実行しています。
何を実行してるかというと

return self.pub_date >= timezone.now() - datetime.timedelta(days=1)

インスタンスの日付情報が過去日付でなければTrue、そうでなければFalseを返すというメソッドでした。
ところがこのままだと未来日付の場合でもTrueになってしまうというバグがあるというお話でした。
なので、2の結果もTrueとなります。
ではこのバグを発見するためのテストを作成しましょう。

polls/tests.py
import datetime

from django.test import TestCase
from django.utils import timezone

from .models import Question


class QuestionModelTests(TestCase):

    def test_was_published_recently_with_future_question(self):

        time = timezone.now() + datetime.timedelta(days=30)
        future_question = Question(pub_date=time)
        self.assertIs(future_question.was_published_recently(), False)

Djangoでテストを記述するファイルはtests.pyです。
冒頭でTestCaseオブジェクトをインポートしておきましょう。
テストの書き方としてはdjango.test.TestCaseを継承したクラスを作りそこにメソッドを書いていくという形になります。
ちなみに、TestCaseを継承すると同時にTransactionTestCaseSimpleTestCaseも継承します。
データベースを使わないアプリの場合はSimpleTestCaseだけを継承するのが良いそうです。
じゃあこのTestCaseがどういう働きをするのかというのを簡単に言うと、まずtests.pyが実行されるとTestCaseを継承したクラスがあるかどうかの検索が始まります。
そしてそれを発見すると

  1. テストのためのデータベースを作成してマイグレーションを適用する。
  2. testで始まるメソッドを検索して実行する。
  3. ロールバックする。
  4. 他にメソッドがあれば2と3を繰り返す。
  5. TestCaseクラス実行前までロールバックする。
  6. 1で作ったデータベースを削除する。

という処理が行われます。
つまり、テスト・及びテストにおける各メソッドはアプリケーションから見るとある種独立したものであるとも言えるわけです。
参考1
参考2

次にtest_was_published_recently_with_future_questionメソッドがなにをしているのかというと

  1. time変数に未来日付を代入。
  2. future_question変数にtime変数の日付情報を持ったQuestionインスタンスを代入する。
  3. assertIs(a, b)a=bかどうか検証するメソッド、つまりfuture_question.was_published_recently()Falseであるかを検証します。

ということになります。
ではここまで理解したらテストを実行してみます。

py manage.py test polls

複数アプリケーションがある場合はpollsの部分を任意のアプリケーションフォルダ名に置き換えてください。
実行結果としては

System check identified no issues (0 silenced).
F
======================================================================
FAIL: test_was_published_recently_with_future_question (polls.tests.QuestionModelTests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/path/to/mysite/polls/tests.py", line 16, in test_was_published_recently_with_future_question
    self.assertIs(future_question.was_published_recently(), False)
AssertionError: True is not False

----------------------------------------------------------------------
Ran 1 test in 0.001s

FAILED (failures=1)
Destroying test database for alias 'default'...

こういう類のものが出るはずです。
AssertionError: True is not Falseというエラーが出ていることからwas_published_recently()が未来日付に対してTrueを返してしまっていることがここでわかります。
バグを発見したというわけですね。

バグの修正

ではバグを発見したということなのでこれを修正するコードを書いていきます。

# return self.pub_date >= timezone.now() - datetime.timedelta(days=1)を下記に修正

def was_published_recently(self):
    now = timezone.now()
    return now - datetime.timedelta(days=1) <= self.pub_date <= now

未来日付にTrueを返してしまうというのであれば条件指定に現在の日付を加えて、それを最大値にしてしまえばいいということですね。

ちなみにより厳密なテストをするとなると

polls/tests.py
def test_was_published_recently_with_old_question(self):
    """
    was_published_recently() returns False for questions whose pub_date
    is older than 1 day.
    """
    time = timezone.now() - datetime.timedelta(days=1, seconds=1)
    old_question = Question(pub_date=time)
    self.assertIs(old_question.was_published_recently(), False)

def test_was_published_recently_with_recent_question(self):
    """
    was_published_recently() returns True for questions whose pub_date
    is within the last day.
    """
    time = timezone.now() - datetime.timedelta(hours=23, minutes=59, seconds=59)
    recent_question = Question(pub_date=time)
    self.assertIs(recent_question.was_published_recently(), True)

tests.pyに以上2つのメソッドを新たに定義します。
test_was_published_recently_with_old_question()メソッドはwas_published_recently()が過去日付でTrueを返していないか検証するメソッドで、
test_was_published_recently_with_recent_question()メソッドはwas_published_recently()が現在の日付にTrueを返しているか検証するメソッドになります。

テストコードを書いてみる(View編)

さてアルゴリズムに対するテストは行いましたがそれが実際にアプリケーションで動作をするのか気になると思います。
なので、今度はViewのテストを書いて行こうと思います。

まず、先程作ったクラスに以下のモジュールとテストクラスを追加します。

pols/tests.py
from django.urls import reverse

# 中略

def create_question(question_text, days):
    time = timezone.now() + datetime.timedelta(days=days)
    return Question.objects.create(question_text=question_text, pub_date=time)


class QuestionIndexViewTests(TestCase):
    def test_no_questions(self):
        response = self.client.get(reverse('polls:index'))
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "No polls are available.")
        self.assertQuerysetEqual(response.context['latest_question_list'], [])

    def test_past_question(self):
        # 過去日付のQuestionインスタンスを作成
        create_question(question_text="Past question.", days=-30)
        response = self.client.get(reverse('polls:index'))
        self.assertQuerysetEqual(
            response.context['latest_question_list'],
            ['<Question: Past question.>']
        )

    def test_future_question(self):
        create_question(question_text="Future question.", days=30)
        response = self.client.get(reverse('polls:index'))
        self.assertContains(response, "No polls are available.")
        self.assertQuerysetEqual(response.context['latest_question_list'], [])

    def test_future_question_and_past_question(self):
        create_question(question_text="Past question.", days=-30)
        create_question(question_text="Future question.", days=30)
        response = self.client.get(reverse('polls:index'))
        self.assertQuerysetEqual(
            response.context['latest_question_list'],
            ['<Question: Past question.>']
        )

    def test_two_past_questions(self):
        create_question(question_text="Past question 1.", days=-30)
        create_question(question_text="Past question 2.", days=-5)
        response = self.client.get(reverse('polls:index'))
        self.assertQuerysetEqual(
            response.context['latest_question_list'],
            ['<Question: Past question 2.>', '<Question: Past question 1.>']
        )

create_questionメソッドはquestion_textdaysを引数とするQuestionオブジェクトを作成するためのメソッドになります。
以後のテストクラスでQuestionオブジェクトを作成するのでその都度そのコードを書かなくていいように冒頭で定義しておきます。

ではQuestionIndexViewTestsクラスを見ていきましよう。
全編を通して出てくるclientクラスはテストで用いるクラスでブラウザでのアクセスのシミュレートをしてくれるクラスになります。
参考
例えばself.client.getのように書くとself.objects.getのような実際のリクエスト処理をテストしてくれるというわけです。

test_no_questionsQuestionオブジェクトが存在しない場合の動作を検証するメソッドです。
ではresponse = self.client.get(reverse('polls:index'))でURLを叩いて見ます。

    self.assertEqual(response.status_code, 200)
    self.assertContains(response, "No polls are available.")
    self.assertQuerysetEqual(response.context['latest_question_list'], [])

以上3つは先程も出てきたassertIs()メソッドと同じように引数の検証をします。
例えば、self.assertEqual(response.status_code, 200)だと先程URLを叩いた結果ブラウザにページが表示されているかどうかの検証になります。
200のアクセスコードは正常にページが表示されている状態の時に出るものなのでresponse.status_code == 200を検証しています。
ちなみにformなどのPOSTの動作の確認の場合はself.client.postになります。

self.assertContains()は引数に第2引数で指定したものが含まれるかどうかの検証をします。
例えば今回の場合だとresponseの中にはself.client.get(reverse('polls:index'))の戻り値にcontextが含まれているのでその中に指定した文字列があるかどうか検証をするということになります。

self.assertQuerysetEqual()はクエリセットにデータがあるかどうかを確認しています。
ちなみにクエリセットはintなどと同様にデータの型を指し、もっとざっくばらんに言うとモデルから取り出した一連の情報と思ってください。
今回はpolls:indexのパスのURLを叩くのでIndexViewクラスが実行されることになります。
そうするとIndexViewクラスでは最後にget_queryset()メソッドでデータベースからデータを取得するのでそこで取得されたそこでクエリセットが生まれるというわけです。
ちなみに
self.assertQuerysetEqual(response.context['latest_question_list'], [])なのでこの場合はlatest_question_listが空であるということ、つまり質問が何もないことを検証するということになります。

以後のテストメソッドも同様です。
定義したcreate_question()メソッドに引数であるquestion_textdaysの値を指定してQuestionオブジェクトを作成し、それを元にテストを行っていきます。
response.context()の実行結果についてですがテンプレートにおいて

<li><a href="{% url 'polls:detail' question.id %}">{{ question.question_text }}</a></li>

こういう記述をしたので引数にquestion_textを指定したので{{ question.question_text }}にはそこで指定したものが入るのは覚えていますね。
つまり例えばtest_past_question()メソッドだとPast question.がここに入ることになります。
よって

self.assertQuerysetEqual(response.context['latest_question_list'],['<Question: Past question.>'])

このような記述は戻り値でresponseの中のhtmlコンテキストの中にPast question.が含まれているか検証するという意味になるということを理解しておきましょう。
ここまでわかればtest_future_question()において

self.assertQuerysetEqual(response.context['latest_question_list'], [])

がTrueになるのもわかると思うのであとは難しいことはないと思います。

最後にテストの前にViewをを修正します。
modelのバグは修正しましたが、これまでのままだと未来日付を持つオブジェクトをしてしまうためです。
django.utilsモジュールからtimezoneオブジェクトをインポートして以下のようにIndexViewクラスを修正します。


from django.utils import timezone

class IndexView(generic.ListView):
    template_name = 'polls/index.html'
    context_object_name = 'latest_question_list'

    def get_queryset(self):
        return Question.objects.filter(
            pub_date__lte = timezone.now()
            ).order_by('-pub_date')[:5]

pub_date__lte=timezone.now()pub_date <= timezone.now()と同義と思ってください。
つまり、現在以前の日付情報を持つQuestionオブジェクトを5件づつ取得するというクエリセットが返されるという処理になります。

DetailViewのテスト

Indexまでは修正したはいいものの、このままではまだDetailViewで未来日付のデータが表示されてしまいますのでここも修正しておきましょう。
以下のように修正します。

polls/views.py

class DetailView(generic.DetailView):
    ...
    def get_queryset(self):
        return Question.objects.filter(pub_date__lte=timezone.now())

次にtests.pyに下記のテストクラスを追加します。

class QuestionDetailViewTests(TestCase):
    def test_future_question(self):
        future_question = create_question(question_text='Future question.', days=5)
        url = reverse('polls:detail', args=(future_question.id,))
        response = self.client.get(url)
        self.assertEqual(response.status_code, 404)

    def test_past_question(self):
        past_question = create_question(question_text='Past Question.', days=-5)
        url = reverse('polls:detail', args=(past_question.id,))
        response = self.client.get(url)
        self.assertContains(response, past_question.question_text

基本的には先程までのテストと理屈は同じです。違うところはDetailViewのテストなのでurl = reverse('polls:detail', args=(future_question.id,))といった形で例えば/polls/34といったURLの34の部分を予め用意してそれを個別のURLとして指定しないといけないということですね。

テストが多くなってしまったら?

さて、ここまでテストをいくつか書いてみて気づいた方もいると思いますが、例えば今回のチュートリアルで作っているアプリであるとresluts.html、つまりResultsViewクラスに対するテストも書かないといけないことがわかります。
が、そのテストの内容は今書いたDetaiViewクラスに対するテストとほぼ同じになることはわかると思います。
違うのはpolls:detailの部分をpolls:resultsにするくらいです。

これに対して公式ドキュメントではテストに重複はつきものだとして、整理するためのルールとして以下の3点を提示しています。

  • モデルやビューごとに TestClass を分割する
  • テストしたい条件の集まりのそれぞれに対して、異なるテストメソッドを作る
  • テストメソッドの名前は、その機能を説明するようなものにする

補足

今回のテストチュートリアルを実行するにあたって必要になった知識を残しておきます。

Dockerでのコンテナ・ホスト間のファイルコピー

・コンテナからホストへ

docker cp コンテナ名orID:コピーしたいディレクトリorファイルへのパス コピー先のホストのディレクトリ

・ホストからコンテナへ

docker cp コピーしたいディレクトリorファイルへのパス コンテナ名orID:コンテナへの保存先のパス

*DjangoはDockerの内部、つまり仮想環境側に入っているのでそれらのファイルへのパスはターミナル形式で叩かないと探せません。
つまり

docker exec -it コンテナ名 /bin/bash

などでコンテナの中に入り、

py -c "import django; print(django.__path__)"

で、ソースファイルのパスを検索しないといけないというわけです。
ソースファイルのパスを検索すればDjangoフォルダの場所がわかるのであとはそれをホスト側にコピーすれば最悪あとはホスト側で探せます。
もちろんファイルまでのディレクトリがわかるならそのままそれを加えて直接そのファイルだけコピーすればOKです。

最後に

今回始めてテストコードについて勉強しましたが、流石にこれが書けてようやくコーダーと名乗れるというだけあってかなり奥が深いものを感じました。
クラスベースビューもややこしかったですが、この項目はこれだけではまだまだ足りないと思います。
ただ、こうやってテストコードを書いてみると今まで自分が書いてきたコードを再確認して、あやふやだったところを含めて改めて理解が深まったのでやはり大切なことなのだと感じました。
チュートリアルの補足としてテストだけを掘り下げた章があるのでこのあとの6・7章が終わったあとに目を通して行きたいと思います。

参考

[Docker]コンテナとホスト間でファイルをコピーする
[Django] 自動テストについてのまとめ
はじめての Django アプリ作成、その 5

1
2
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
1
2