このページでは、Django でのテストについて解説していきます。
Django はテストフレームワークを内蔵しており、Fこれを利用することで、開発したウェブアプリのテストを簡単に実施することが可能です。そして、テストを実施することで、品質の高いウェブアプリを開発することができるだけでなく、新たな機能の導入・既存の機能の変更も行いやすくなり、より高機能なウェブアプリを安全に開発することも可能となります。なので、テストに地味なイメージを持たれている方も多いかもしれませんが、テストは品質だけでなく、機能面の向上、さらには開発効率の向上にも繋がる非常に重要なプロセスです!
ウェブアプリを公開していく上では必ずテストが必要となりますので、ウェブアプリの作り方だけでなく、Django でのテストのやり方もしっかり理解しておきましょう!
Contents
- テストの重要性
- Django のテストフレームワーク
- Django で実施するテスト
- Django でのテスト実施手順
- テストケースの実装の詳細
- テストの実行メカニズム
- Django テスト向け外部ライブラリ
- 掲示板アプリのテストを実施する
- 掲示板アプリのプロジェクト一式の公開先
- テストファイルの作成
- forum/tests/test_models.py
- forum/tests/test_forms.py
- forum/tests/test_views.py
- forum/tests/test_urls.py
- forum/tests/test_mixins.py
- forum/tests/integration/test_auth.py
- forum/tests/integration/test_post.py
- forum/tests/integration/test_ui.py
- api/tests/test_permissions.py
- api/tests/integration/test_auth.py
- api/tests/integration/test_comments.py
- テストの実施
- まとめ
テストの重要性
最初に、皆さんのテストへのモチベーションを上げるため、テストの重要性について説明していきたいと思います。
ウェブアプリの品質が向上する
まずは、皆さんご存知の通り、テストを実施することでウェブアプリの品質が向上します。
テストはアプリ内に潜在しているバグを洗い出す作業であり、このバグをテストで発見し、そしてそれを修正することで、ウェブアプリの品質を向上させることができます。
どれだけ機能的に優れているウェブアプリでも、品質が悪ければウェブアプリの評判が下がり、いずれはユーザーも離れていくことになります。もはや、現在では「品質は高くて当たり前」という風潮にもなっているため、多くのユーザーを獲得していくためには、ウェブアプリの品質向上が必須となります。
スポンサーリンク
ウェブアプリの機能が安全に開発・変更できる
また、テストの実施により、ウェブアプリの機能が安全に開発・変更できるようになります。
機能の新規開発や機能の変更で一番怖いのは、既存の機能・他の機能への悪影響になります。例えば、新たな機能を開発したり、新たな仕様を取り入れることで、既存の機能が正常に動作しなくなるようなことが起こりえます。つまり、今まで上手く正常に動作していた機能が動かなくなります。こういった現象はデグレ(デグレーション)と呼ばれます。
こういったデグレが発生する可能性があるため、一度リリースしたウェブアプリへの新機能追加や従来機能の変更は消極的になってしまいがちです。
ですが、既存の機能に対してテストが十分に実施されていれば、テストを実施することで新機能の開発による既存の機能への影響がテスト結果として確認できるようになります。たとえば、元々テスト結果が OK (Pass) であったテストが新機能の開発によって Fail になっているのであればデグレが発生したと考えられます。そのため、変更前後でしっかりテストを実施するようにしておけば、変更後のテストでグレに気付くことができ、デグレを修正した上でリリースすることが可能となります。
つまり、テストがデグレを検出する仕組みとなるわけですね。このような仕組みがあれば、デグレを恐れる必要が無くなるため、新機能の開発にも積極的に取り組むことができ、それがウェブアプリの高機能化につながることになります。
また、ここでは新機能の追加・既存の機能の変更に注目して解説しましたが、他の環境に移植した場合の悪影響、リファクタリングを実施した場合の悪影響、さらには Django や他のライブラリのアップデートによる悪影響に関しても、十分なテストを準備しておけば検知することが可能です。
ウェブアプリの開発効率が向上する
さらに、テストの実施によって、ウェブアプリの開発効率も向上させることができます。
ソフトウェア開発では流用開発を行うことが多いです。この流用開発では、既存のソフトウェアのモジュール(もしくは、もっと大きな単位)を他のソフトウェアに流用することで開発効率の向上が図られます。ウェブアプリにおいても流用開発が行われる機会は多いです。
ですが、この流用開発には大きな落とし穴があります。それは、流用するモジュールの品質が低いと、流用先のウェブアプリの品質が低下してしまうという落とし穴になります。要は、流用対象のモジュールにバグがあると、そのバグも流用されることになります。せっかく開発効率の向上のために流用開発を行っているのに、流用するモジュールの品質が低いと、今度はウェブアプリの品質向上のための作業が別途必要となり、開発効率が低下してしまう可能性があります。
逆に、流用するモジュールの品質が高ければ流用開発の効果を最大限に発揮することができ、ウェブアプリの開発効率が向上して短納期開発も実現することができます。
後述でも解説しますが、テストにはユニットテストが存在し、このテストを十分に実施することでモジュールの品質を向上させることができます。特にユニットテストでは、現在開発中のウェブアプリの品質を向上させることだけでなく、モジュールの他のウェブアプリへの流用も視野に入れてテストを行い、流用に耐えうる品質を担保しておくことが重要です。
ここまで説明してきたように、テストを十分に実施することは、ウェブアプリの品質の向上だけでなく、ウェブアプリの高機能化、さらにはウェブアプリの開発効率向上にもつながります。テストは、より多くのユーザーを獲得するための基盤ともなるプロセスなので、テストも積極的に実施するようにしていきましょう!
Django のテストフレームワーク
ただし、テストは単に実施するだけではなく、効率的に実施することも重要です。先ほど説明したようなデグレの検出のためのテストが「手動」で、さらに「長時間」かけて実施する必要があるのであれば、その分の人員・労力も必要となりますし、リリースまでの期間も長くなってしまいます。このようなテストであれば、今度はテストを実施することに消極的になってしまいます。特に、デグレを検出するためにはテストを繰り返し実施することが重要ですので、テストを実施することに消極的になるような状況は避けるべきです。
そのため、可能な限り、テストは簡単な手順で自動的に実施できるようにしておく必要があります。これが実現できれば、ソースコードを変更するたびにデグレチェックをしたり、デイリーでテストを実施したり、リリース前にテストを実施したりする等、短周期でのテストの繰り返しを人員・労力を割くことなく実施することができるようになります。そして、これが、前述の通りウェブアプリの品質向上・高機能化につながります。
こういった、テストを効率的に実施するためのフレームワークが Django には内蔵されており、これを上手く利用することで、全てのテストをコマンド1つで実施したり、テストの実行 〜 テストレポートの作成までの工程を全て自動で実施することが可能となります。
ここでは、そんな Django のテストフレームワークの概要について解説していきたいと思います。
スポンサーリンク
コードでのテストケースの作成
まず、Django のテストフレームワークを利用したテストを実施するためにはテストファイルを作成する必要があります。このテストファイルに「テストケース」を実装しておくことで、テストファイルをテストフレームワークが読み込み、そこに実装されたテストケースをテストフレームワークが自動的に実行してくれるようになります。
テストケースとは、特定の機能・モジュールやコードが期待通りに動作することを確認するために作成される一連の手順や条件のことを言います。簡単に言えば、テストごとに下記をまとめたものになります。
- テストの目的(内容)
- テストの前提条件
- テストの手順
- テストの期待結果
このテストケースの実装の仕方に関しては後述で解説しますが、Django のテストフレームワークにおいては、このテストケースをテストクラスのメソッドとして実装することで作成することになります。つまり、Python のコードを記述してテストケースを実装していくことになります。
例えば、下記のようなテストケースであれば、前提条件を満たすようにデータベース操作を行う処理をコーディングし、手順通りの HTTP リクエストの送信を行う処理をコーディングし、さらに、手順を実施することで得られた HTTP リクエストが期待結果を満たしているかどうかを検証する処理のコーディングが必要となります。要は、テストケースに記載された内容を実施・検証するプログラムを開発することになります。
- 前提条件:
Comment
のテーブルに下記の2つのレコードを作成しておく- 本文が
Hello world
のレコード - 本文が
Good bye
のレコード
- 本文が
- 手順:
GET /forum/comments/
の HTTP リクエストを送信する
- 期待結果:
- HTTP レスポンスのステータスコードが
200
であること - HTTP レスポンスのボディに下記の2つの文字列が存在すること
Hello world
Good bye
- HTTP レスポンスのステータスコードが
このようなテストケースを実装したテストファイルを作成しておけば、Django のテストフレームワークがテストファイルを読み込み、そこに実装されているテストケースに従ってテストの実行やテストレポートの作成を行なってくれるようになります。
コーディングを伴うため手間に感じるかもしれませんが、プログラムとしてテストを開発することで、コマンド1つで Django テストフレームワークから全テストケースが自動的に実行されるようになり、これによって手作業でのテストが不要となりますし、テストの実施時間も短縮されます。
テストケースをコーディングするためには、まずはウェブアプリの品質を担保するのに必要なテストケースの列挙が必要となります
このテストケースの列挙の仕方も重要ではあるのですが、このページでは、テストケースの列挙の仕方についての詳細な解説は省略し、特に Django テストフレームワークでのテストケースのコーディングの仕方に焦点を当てた解説を行います
コマンド実行でのテスト実施
Django のテストフレームワークでのテストの実行手順は非常に簡単で、以下のコマンドを実行するだけで、テストファイルに実装された全テストケースが実行されます。
% python manage.py test
このコマンド一つで全テストケースが自動で実行されるため、テストを簡単・自動・短時間で実施できます。これによって反復的なテストが可能となり、テストと必要に応じた修正を繰り返すことで、高品質・高機能なウェブアプリを開発できるようになります。
実際には、下記のようなタイミングで反復的にテストを実施することが多いと思います。
- ソースコードを変更するたびにテストを実施する
- デイリーでテストを実施する
- リリース前にテストを実施する
また、詳細は省略しますが、テストがコマンド一つで実施可能であるため、CI(継続的インテグレーション)ツールにも組み込みやすいです。これにより、コードがリポジトリにマージされるタイミングで自動的にテストを実施することも可能となります。
ここまでの説明からも分かるように、テストは1度だけではなく、繰り返し実施するものです。そのテストを、毎回複雑な手順が必要&手作業が必要となるとテストに要する時間が長くなり、開発の長期化やテスト不足による品質低下が発生する可能性もあります。できるだけ簡単にテストが実施できることは、ソフトウェアの開発にとっては非常に重要となります。
unittest
ベースのテストフレームワーク
Django のテストフレームワークは unittest
という Python の標準モジュールをベースに開発されています。ここまで解説してきた特徴に関しては、unittest
と共通のものになります。
また、unittest
をベースとしているため、テストケースの作り方も unittest
とほとんど同じです。そのため、unittest
でのテストの実施経験がある方は、Django でのテストケースもスムーズに実装することができると思います。
また、unittest
に用意された mock
の仕組みを利用し、特定の関数やメソッド・データ等をモック化することも可能です。
unittest
や mock
に関しては下記ページで詳細を解説していますので、詳しく知りたい方は下記ページを参照してください。
スポンサーリンク
テスト用データベースの自動管理
先ほど説明した通り、Django のテストフレームワークは unittest
をベースに開発されているのですが、Django のテストフレームワークにしか存在しない、Django で開発したウェブアプリをテストするのに特化した機能も多く存在します。
その1つが、この節の題名となっている「テスト用データベースの自動管理」となります。
Django のテストフレームワークでは、テストを実行する際に「専用のテスト用データベース」を自動的に作成し、このデータベースにレコードの作成等を行いながらテストが実施されるようになっています。そして、このテスト用データベースはテスト終了後に自動的に削除されるようになっています。そのため、テストの開始前に手動でデータベースの中身を空にしたり、大事なレコードが保存されているデータベースをテスト前に退避したりするようなデータベースの管理が不要となります。
また、テストケース毎にデータベースが自動的にロールバック(リセット)されるようになっています。したがって、各テストケースでデータベースの状態は独立することになり、あるテストケースでデータベースへのレコードの作成・更新を行った場合でも、次のテストケースは、それらのレコードの作成・更新がロールバック(リセット)された状態で開始されることになります。
そのため、各テストケースは同じデータベースの状態から開始されることになり、意図しないレコードが存在する状態でのテストの開始を防止することができます。
意図しないレコードが残っている状態でテストを開始すると、テストの結果も意図しないものになる可能性があります。また、毎回テスト時にデーターベースのテーブルの中身が異なるとテスト結果も毎回異なってしまう可能性もあり、テスト結果に一貫性が無くなってしまいます。そういったことが起きないように、テストフレームワーク側でデータベースの管理が自動的に行われるようになっています。
テスト用クライアントが利用可能
また、Django のテストフレームワークにはテスト用クライアントのクラス Client
が定義されています。この Client
を利用することで、より高速&より詳細なテストを実現することができます。
Client
は HTTP リクエストをシミュレートするためのクラスとなります。簡単に言ってしまえば、HTTP リクエストを送信するのではなく、HTTP リクエストを模倣するデータを作成し、それを引数としてビュー等の関数・メソッドを呼び出すことで、ウェブアプリを動作させます。
シミュレートするだけで、実際には HTTP リクエストの送信のための通信は行われないため、通信にかかる時間を短縮し、より高速なテストを実現することができます。また、HTTP リクエストを受信するための HTTP サーバーを起動する必要もないため、TestCase
のサブクラスとして定義された テストクラス
ではテスト実行時に HTTP サーバーを起動しないようになっており、これによって HTTP サーバー起動に必要となる時間の分、テスト時間が短縮できるようになっています。
また、Client
で HTTP リクエストをシミュレートしてウェブアプリを動作させた場合も、ウェブアプリから結果をレスポンスとして受け取ることが可能です。そして、このレスポンスには、通常の HTTP レスポンスには含まれないデータが含まれています。そのため、通常の HTTP レスポンスからは得られない結果に対する検証を実施することが可能です。
例えば、Client
が HTTP リクエストをシミュレートすることで得られるレスポンスには、ビューが使用したテンプレートファイルのファイルパスや、ビューが生成したコンテキスト等が含まれます。これらは、通常の HTTP レスポンスには含まれないデータです。このような「通常の HTTP レスポンスには含まれないデータ」に対する検証も Client
を利用することで実施することができるようになり、より詳細なテストが実現できるようになります。
例えば Python であれば、他のテスト手段として requests
ライブラリを利用するという手もあります。下記ページでも解説したように、requests
ライブラリからは HTTP リクエストを送信することができ、この HTTP リクエストの送信によってウェブアプリを動作させることができます。また、HTTP リクエストの応答として受信する HTTP レスポンスから、そのウェブアプリの動作の結果を得ることもできます。
そのため、requests
ライブラリを利用して HTTP リクエストを送信することでもウェブアプリのテストを実施することは可能です。そして、そのテスト結果は HTTP レスポンスを検証することで確認可能です。
ただし、このような HTTP リクエストの送信によるテストは、実際に HTTP リクエスト・HTTP レスポンスの送受信が必要になるため負荷が高く、テストに要する時間が長くなります。また、結果が HTTP レスポンスからしか検証できないため、詳細なテスト(例えば使用したテンプレートファイルのパスやコンテキストの検証など)を実施することができません。
こういった、実際に HTTP リクエストを送信するテストは、Django 以外で開発したウェブアプリのテストも実施可能で汎用性が高いというメリットもあるのですが、その反面、上記のような課題もあります。それに対し、Client
を利用したテストは Django で開発したウェブアプリに特化したものになりますが、特化している分、高速かつ詳細なテストを行うことができ、Django で開発したウェブアプリに対するテストの効率化、およびウェブアプリの品質向上が図りやすいです。
豊富なテスト結果の検証手段
また、テストを実施する際には、テストを実施するだけでなく、そのテスト結果を検証することも重要です。例えば、関数やメソッドを実行して得られた返却値がテストケースに応じた「期待結果」を満たしていること、また HTTP リクエストのシミュレートによって得られたレスポンスのステータスコード、ボディのデータ等が、テストケースに応じた「期待結果」を満たしていることを検証する必要があります。
このような検証は、Django のテストフレームワークでは assert
系メソッドを利用して実施することになります。例えば下記のように assertEqual
メソッドを実行すれば、第1引数で指定した 実行結果
が第2引数で指定した 期待結果
と一致しているかどうかを検証することができます。この assert
系メソッドは、1つのテストケースで複数実行することが可能です。
self.assertEqual(実行結果, 期待結果)
さらに、この assert
系メソッドでの検証結果に基づいて、次の節で説明するテストレポートが作成されることになります。具体的には、assert
系メソッドでの検証で、実行結果が全ての期待結果を満たしていると判断された場合には「テスト結果:OK (or Pass)」、1つでも満たしていなければ「テスト結果:FAIL (or ERROR)」としてテストレポートに記載されることになります。
また、この assert
系メソッドには様々な種類のものが定義されており、その種類ごとに異なる検証を実施することが可能です。Django テストフレームワークのベースとなっている unittest
でも assert
系メソッドが定義されているのですが、Django テストフレームワークでは、Django 向けの assert
系メソッドが用意されており、それらのメソッドを利用することで効率的にテストの検証を実施することができるようになっています。
例えば、ウェブアプリからはリダイレクトレスポンスが返却されることがありますが、Django テストフレームワークには assertRedirects
が定義されており、これを利用することで、ビューから返却されたレスポンスがリダイレクトレスポンスであり、さらに、そのリダイレクト先の URL が期待結果と一致しているかどうかを確認することが可能です。
self.assertRedirects(レスポンス, 'リダイレクト先URLの期待結果')
具体的な assert
系メソッドの種類については追って解説していきますので、まずは Django テストフレームワークには Django に特化した assert
系メソッドが定義されており、それを利用することで効率的にテストケースの開発を行うことができるという点を覚えておいてください。
スポンサーリンク
テストレポートの自動生成
また、簡易的なものではありますが、Django テストフレームワークではテスト実行後にテストレポートが出力されるようになっています。
テストでは、テストを単に実施するだけでなく、テスト結果をレポートとしてまとめておくことも重要です。このレポートにより、もちろんバグや問題のある箇所を特定することもできるようになりますし、ウェブアプリの品質レベルを一目で確認することもできるようになります。
先ほども少し説明しましたが、このレポートは、基本的には assert
系メソッドでの検証結果に基づいて出力されることになります。実際に出力される結果は下記のようなものになります。
簡易的なレポートとなりますが、レポートには「テストケースの総数」・「失敗したテストケースの総数」・「検証で FAIL (もしくは ERROR) と判断された箇所」が出力されるようになっていますので、このレポートからウェブアプリの品質レベルや、FAIL と判断された理由を確認することができるようになっています。
====================================================================== FAIL: test_with_login_err (forum.tests.CommentListTest.test_with_login_err) ---------------------------------------------------------------------- Traceback (most recent call last): File "略\testproject\forum\tests.py", line 59, in test_with_login_err self.assertEqual(response.status_code, 201) AssertionError: 200 != 201 ---------------------------------------------------------------------- Ran 3 tests in 1.437s FAILED (failures=1)
また、Django テストフレームワークで出力されるレポートは上記のような簡易的なものとなりますが、他のライブラリを導入することで、このレポートを HTML 形式で出力することもできるようになります。
例えば、pytest・pytest-django・pytest-html を導入することで、下の図のような見栄えの良いレポートをウェブブラウザから閲覧することもできるようになります。
このレポートをサーバーから公開するようにすれば、テスト実施者だけでなくチームのメンバー全員がテスト結果を確認し、ウェブアプリの品質レベルをメンバー全員で共有しながら開発することができます。そして、これによって円滑にプロジェクトを進めることもできるようになります。
Django で実施するテスト
次に、Django のテストフレームワークを利用して実施するテストの種類について解説していきます。
ユニットテスト(単体テスト)
まず、実施すべきテストの1つはユニットテストとなります。このユニットテストでは、モジュール単位(もしくは関数単位・メソッド単位)でのテストを実施します。
基本的には、このユニットテストでは、クラスのメソッドや関数を直接実行する形式でテストを行うことになります。HTTP リクエストを送信(シミュレート)してウェブアプリを動作させる形式のテストは、次に説明するインテグレーションテストとなると考えて良いと思います。
そして、ユニットテストでは、各種メソッドや関数が意図した通りに動作することや、各クラスが定義した通りに利用できることを確認します。例えば、モデルに対するテストであれば、必須フィールドが無しの状態のレコードの新規登録を行うと例外が発生すること等を確認します。
このユニットテストを実施することで、各モジュールの品質が向上するだけでなく、次に説明する結合テストもスムーズに行うことが可能となります。さらに、モジュールの品質が向上することで、モジュール単位での再利用(他のウェブアプリへの流用)しやすくなり、それによってウェブアプリの開発効率を向上させることもできます。
各モジュールに対するユニットテストで確認する項目としては下記のようなものが挙げられます。
モジュール | 確認項目の例 |
models.py |
|
forms.py |
|
views.py |
|
urls.py |
|
その他 |
|
テンプレートファイルに関しては単体で動作するものではなく、ビューや Django フレームワークから利用されるものとなるため、基本的にはユニットテストは実施せず、インテグレーションテストでレスポンスのボディを検証することで動作を確認することになります。
スポンサーリンク
インテグレーションテスト(統合テスト)
ユニットテストがモジュール単体に対するテストであるのに対し、インテグレーションテストは複数のモジュールを結合した状態で実施するテストになります。主に、複数のモジュールが正しく連係動作して、機能が正常に実現できていることを確認することを目的に実施するテストとなります。そのため、機能単位でテストを行うことが多いです。
また、このインテグレーションテストは、基本的には各モジュールが単体で正しく動作することが確認できている状態で実施します。つまり、ユニットテストよりも後のフェーズで実施するテストになります。
Django で開発したウェブアプリにおいては、基本的にはインテグレーションテストは HTTP リクエストを送信(or シミュレート)して実施することになります。ウェブアプリは、HTTP リクエストを受け取ればビューが動作し、さらにビューとモデル・テンプレート・フォーム等が連係動作することで機能が実現されるようになっています。後は、HTTP リクエストの中身に応じた結果(HTTP レスポンスや機能の実行結果など)が得られることを検証してやれば、複数のモジュールが正しく連係動作していることを確認するテスト、すなわちインテグレーションテストが実施できることになります。
テスト用クライアントが利用可能 でも説明したように、Django のテストフレームワークでは Client を利用して HTTP リクエストをシミュレートすることが可能で、これによりインテグレーションテストを効率的・詳細に実施することが可能です。
例えば、インテグレーションテストで確認する項目としては下記のようなものが挙げられます。
機能 | 確認項目の例 |
レコードの新規登録 |
|
レコードの更新 |
|
レコードの削除 |
|
レコードの取得 |
|
ログイン |
|
アクセス制限 |
|
正常系テスト・異常系テスト
また、テストでは、正常系テストだけでなく、異常系テストも実施する必要があります。
正常系テスト
正常系テストとは、有効な入力(手順・操作)に対するウェブアプリの動作を確認するためのテストになります。
この正常系テストは、ウェブアプリが開発者の想定する動作を実現できていることを確認することを目的に実施します。
例えば「ユーザー登録フォーム」で、各種フィールドに有効な値を入力してフォームからデータを送信することで、それらのフィールドに応じたレコードがデータベースのユーザーを管理するテーブルに新規登録されることを確認するようなテストは正常系テストとなります。
また、正常系テストを繰り返し実施することで、新機能の追加等によってデグレが発生していないことを確認することもできます。
異常系テスト
異常系テストとは、無効な入力(手順・操作)・想定外の入力(手順・操作)に対するウェブアプリの動作を確認するためのテストになります。
この異常系テストは、無効な入力・想定外の入力が与えられた時に、ウェブアプリが想定外の動作をしないことを確認することを目的に実施します。例えば、無効な入力・想定外の入力が与えられた時にウェブアプリが暴走したりしないかどうか、また本来であればエラーとするべき入力の場合に意図せず成功したりしないかどうかを確認します。
例えば「ユーザー登録フォーム」の例であれば、入力が必須のフィールドが空の状態でデータが送信された場合や、各種フィールドに無効な値が入力された状態でデータが送信された場合等に、意図通りエラーのレスポンスが返却されることや、レコードが新規登録されていないことを確認します。
ウェブアプリは有効な入力が与えられることを想定して開発することが多いため、正常系テストよりも異常系テストの方がバグが見つかりやすいです。ユーザーが間違って無効な入力を行ったり、無効な手順でウェブアプリを操作するような場合もありますし、悪意あるユーザーから意図的に想定外の手順でウェブアプリの操作が行われる場合もあります。そういった入力・操作が行われた時にも、ウェブアプリが異常な動作をしないことを確認するために異常系テストを十分に実施し、バグがあればそれらを修正しておく必要があります。
また、元々無効な入力が与えられた時にエラーが発生するようになっていたのに、コードの変更によってエラーが発生せずに正常終了してしまうようになってしまうこともあります。そして、これが開発者の意図と反した正常終了なのであれば、これもデグレの1つであると考えられます。異常系のテストも十分に実施しておけば、この異常系に対するデグレも検出することができるようになりますので、そういった意味でも異常系のテストが重要となります。
Django でのテスト実施手順
次は、Django でのテストの実施手順について解説していきます。
ここでは、テストを実施するために最低限必要となる手順のみを解説していきますので、この解説を読んで、まずはテストを実施する手順を理解していただければと思います。
スポンサーリンク
テストファイルの用意
では、Django でのテストの実施手順について解説していきます。
まず、Django でテストを実施するためには、テストケースの実装先となるテストファイルが必要となります。
自動生成される tests.py
を利用する
このテストファイルは、startapp
コマンドでのアプリの作成時に、自動的にアプリフォルダ内に tests.py
という名称で作成されることになります。したがって、アプリに対するテストケースを tests.py
の1つのファイルに全て実装する場合は、テストファイルの用意の手順は不要となります。
tests
フォルダを作成する
この tests.py
にテストケースを全て実装するのでも良いのですが、特にアプリの規模が大きくなるとテストケースのコード量が膨大になり、1つのファイルで管理することが困難となります。そのため、特にアプリの規模が大きい場合は、テストファイルは複数に分割した方が管理が楽になります。
このように、複数のテストファイルを作成してテストケースを実装する場合は、下記の手順でテストファイルを用意するのが一般的です。
- アプリ内の
tests.py
を削除する - アプリ内に
tests
フォルダを作成する tests
フォルダ内に__init__.py
を作成する(中身は空で OK)tests
フォルダ内にtest_
から始まる名称のテストファイルを作成する(拡張子は.py
)
つまり、下記のような構成となるように各種フォルダ・ファイルを作成すればよいことになります。テストファイルの数が多くなる場合は、下記構成のように、tests
フォルダ内にも適切にフォルダを作成してテストファイルの配置場所を整理することをオススメします。
アプリフォルダ/ ├── __init__.py ├── models.py ├── views.py ├── tests/ │ ├── __init__.py │ ├── test_models.py │ ├── test_views.py │ ├── integration/ │ ├── __init__.py │ ├── test_auth.py │ ├── test_create.py └── other_files.py
実際には、Django のテストフレームワークにおいては、デフォルトでパッケージ内の test*.py
形式の名前のファイルがテストファイルとして扱われるようになっています。なので、ファイル名が test*.py
形式であれば、テストファイルの置き場所はアプリ内であればどこでも良いことになるのですが、慣習的に上記のような構成になるようにテストファイルを設置することが一般的となります。
テストファイルを配置したフォルダを Django のテストフレームワークにパッケージとして認識させるために、テストファイルを配置するフォルダ内には __init__.py
が必要となります
そして、後述で説明する「テストを開始するコマンド」を実行すれば、Django のテストフレームワークがテストファイルを探索し、そのテストファイルに実装されたテストケースを実行してくれることになります。
テストファイルの名称には、そのテストファイルで実施するテストの「テスト対象」や「テストの目的」等を反映したものを付けるのが良いと思います。また、テストファイルの数が多くなる場合は、先ほど示したフォルダ・ファイル構成のように、tests
フォルダ内も適切にフォルダを作成してテストファイルの配置場所を整理することをオススメします。
例えば、上記のファイル構成の例であれば、test_models.py
ではモデルに対するユニットテスト、test_forms.py
ではフォームに対するユニットテストであることがファイル名から理解できると思いますし、integration/test_auth.py
では認証関連のインテグレーションテストが実施されることがファイル名から理解できると思います。
また、tests.py
を削除するのはモジュールの競合を避けるためになります。同じ階層に tests
フォルダと tests.py
が存在すると、モジュールが競合して下記のようなエラーが発生することがありますので、それを避けるために、tests.py
を削除するようにしています。
ImportError: 'tests' module incorrectly imported from 略
テストケースの実装
テストファイルを作成したら、テストケースを実装していくことになります。
Django テストフレームワークから実行可能なテストケースとして実装するためには、いくつか決まりごとがありますので、それについて解説を行っていきます。
また、ここでは、モデルクラス Comment
に対するユニットテストとして、下記のテストケースを実装する例を交えながら、テストケースの実装手順を解説していきます。
- 目的:レコードの更新に成功することを確認する
- 前提条件:
Comment
に下記のレコードが存在するid:1
author:'Taro'
text:'Hello'
- 手順:
id
が1
のレコードを下記のように更新するauthor
:'Hanako'
text
:'Good'
- 期待結果:テーブルに保存されている
id
が1
のレコードの各種フィールドが下記であることauthor:'Hanako'
text:'Good'
TestCase
のサブクラスを定義する
テストケースは、基本的には django.test
で定義される TestCase
のサブクラスの「メソッド」ととして実装していくことになります。
テストケースは、実施したいテストの内容によっては TestCase
以外のサブクラスのメソッドとして実装する場合もあります
それについては後述で補足していきますので、まずは TestCase
のサブクラスのメソッドとしてテストケースを実装することを前提に解説を進めさせていただきます
そのため、テストケースを実装していくためには、まずは TestCase
のサブクラスを定義する必要があります。以降では、この TestCase
のサブクラスのことを テストクラス
と呼ばせていただきます。
この テストクラス
は、ユニットテストであればテスト対象のクラス毎に、インテグレーションテストであればテスト対象の機能毎に用意することが多いです。ただし、これは目安であって、テストの目的毎にクラスを定義するなど、もっと自由な考え方で テストクラス
を定義して問題ありません。
テストクラス
を複数定義する理由は、細かな単位でテストを実施するためになります。Django のテストフレームワークでは、テストクラス
単位でテストを実施することが可能です。なので、1つの テストクラス
に全てのテストケースを実装するのではなく、複数の テストクラス
にテストケースを実装するようにすることで、必要なテストのみを選んで実施するようなことができます。そして、それによってテストに要する時間を短縮することもできます。
また、この テストクラス
は、Test
で始まるクラス名や Test
で終わるクラス名を付けてやると、テスト用のクラスであることが分かりやすくなって可読性の高いコードにすることができます。
今回は、Comment
というモデルクラスに対するテストケースを実装していこうとしているので、まずは下記のように CommentTest
という テストクラス
を定義してやれば良いことになります。
from django.test import TestCase
class CommentTest(TestCase):
pass
テストクラス
のメソッドを定義する
そして、先ほども少し説明した通り、テストケースは テストクラス
のメソッドとして実装していくことになります。
前述の通り、テストケースとは下記のような内容をまとめたものであり、これらをコードとして テストクラス
のメソッドに実装していくことになります。以降では、テストケースとして実装されたメソッドのことを テストメソッド
と呼ばせていただきます。
- テストの目的(内容)
- テストの前提条件
- テストの手順
- テストの期待結果
テストケースとなるメソッドを定義する上でポイントになるのがメソッド名です。Django のテストフレームワークにテストケースとして認識されるためには、メソッド名は test
から始まる必要があります。これさえ守れば、そのメソッドはテストケースとして認識され、テスト実施用のコマンド実行時にテストフレームワークから自動的に実行されるようになります。逆に、テストケースとして認識されたくないようなメソッド、例えば、各種テストケースのメソッドから共通に呼び出されるようなユーティリティ的なメソッドに関しては、test
から始まらないメソッド名を付けてやれば良いです。
また、これは必須ではないのですが、テストケースにおける「テストの目的」をメソッド名に反映するようにすれば、メソッド名からテストの目的が理解できるようになり、可読性の高いコードを実現することができます。さらに、メソッド定義の先頭に docstring
として記述した内容はテストレポートにも出力されるようになるので、メソッド名のみで表現できない目的を docstring
で補うことで、テストの目的や確認する内容がテストレポートから読み取りやすくなります。
今回の例であれば、テストの目的が「レコードの更新に成功することを確認する」なので、下記のような テストメソッド
を定義すれば、テストケースの目的が理解しやすくなると思います。
from django.test import TestCase
class CommentTest(TestCase):
def test_update_record_success(self):
"""各種フィールドが正常な場合にレコードの更新に成功することを確認する"""
pass
「前提条件」を満たすための処理を実装する
続いて、この定義した テストメソッド
に、「前提条件」を満たすための処理を実装していきます。
テストでは、テストを実施するための「前提条件」が設定されていることが多いです。前提条件と聞くとイメージが湧かないかもしれませんが、単純にテストを実施するための準備と考えてもらっても良いと思います。
例えば、今回のテストケースの例のように、レコードの更新を行うためには、事前にレコードをテーブルに登録しておく必要があります。また、インテグレーションテストでは、ウェブアプリにログイン機能が搭載されている場合、特定の機能のテストを実行するためには事前にログインを実施しておく必要があります。
これらの例のように、テストの手順を実施するためには、事前に前提条件を満たしておく必要があることも多いです。Django のテストにおいては、この前提条件を満たすための処理もコードとして テストメソッド
に実装する必要があります。
前述の通り、この前提条件として設定されていることが多いのが、テーブルのレコードに関するものになります。レコードを N
個登録しておく、テーブルを空にしておく、などの前提条件が設定されていることが多いです。こういったレコードに関する前提条件を満たすための処理は、基本的にはモデルクラスを直接利用して実施することが多いです。
例えば今回のテストケースの例であれば、下記のように テストメソッド
に処理を実装してやれば良いことになります。Comment
は app
というアプリの models.py
に定義されていること前提としたコードとなっています。もちろん、下記の実装内容にこだわる必要はなく、Comment
のインスタンスに save
メソッドを実行させてレコードを新規登録するのでも問題ありません。前提条件さえ満たすことができれば、前提条件を満たすための処理は自由に実装してよいです。
from django.test import TestCase
from app.models import Comment
class CommentTest(TestCase):
def test_update_record_success(self):
"""各種フィールドが正常な場合にレコードの更新に成功することを確認する"""
"""前提条件"""
Comment.objects.create(
author='Taro',
text='Hello'
)
また、前提条件を満たすために HTTP リクエストの送信が必要となる場合もあります。例えば前述でも挙げたログインは、HTTP リクエストの送信を行って実施する必要があります。このような場合は、テスト用クライアントが利用可能 で紹介した Client
クラスを利用する必要があります。この Client
クラスの使い方に関しては、後述の テストケースの実装の詳細 で解説を行います。
「手順」を実施する処理を実装する
前提条件を満たすことができれば、次は「手順」を実施する処理の実装を行います。この処理の実行結果が、次の「検証」に利用されることになります。
基本的には、ユニットテストの場合は、手順は「テスト対象のメソッドや関数を直接実行する」ことで実施することになります。例えば、モデルに対するユニットテストの場合は、モデルクラスのメソッドを実行して実施することになります。また、フォームに対するユニットテストの場合は、フォームクラスのメソッド(例えば is_valid
)を実行して実施することになります。
今回のテストケースの場合は、レコードの更新を行うわけですから、手順を実施する処理は下記の太字部分のように実装することができます。
from django.test import TestCase
from app.models import Comment
class CommentTest(TestCase):
def test_update_record_success(self):
"""各種フィールドが正常な場合にレコードの更新に成功することを確認する"""
"""前提条件"""
Comment.objects.create(
author='Taro',
text='Hello'
)
"""手順の実施"""
comment = Comment.objects.get(id=1)
comment.author = 'Hanako'
comment.text = 'Good'
comment.save()
インテグレーションテストの場合は、手順は Client
クラスを利用した HTTP リクエストの送信によって実施することが多いです。これにより、HTTP リクエストの受信から HTTP レスポンスの返却までの一連の動作に対するテストを実施することができるようになります。先ほども説明したように、この Client
クラスの使い方に関しては、後述の テストケースの実装の詳細 で解説を行います。
assert
系のメソッドで「期待結果」を満たしているかを検証する
最後に、手順を実施することで得られた結果が「期待結果」を満たしているかどうかの検証を実施します。前述でも少し説明しましたが、この検証は TestCase
クラスに定義された assert
系のメソッドで実施します。
この assert
系のメソッドで実施した検証の結果が、後述で説明するテストレポートにテスト結果として反映されることになります。単純に条件分岐で期待結果を満たしているかどうかの検証を行うことも可能ではありますが、それだとテストレポートに検証結果が反映されないので注意してください。
assert
系のメソッドでは、引数で指定したデータが特定の条件を満たしているかどうかの検証が実施され、満たしていない場合は例外 AssertionError
が発生するようになっています。前述でも説明した通り、この例外が発生した場合、その assert
系のメソッドを実行したテストケース(テストメソッド
)のテスト結果が Fail としてテストレポートに出力されることになり、例外が発生しなかった場合はテスト結果が OK としてテストレポートに出力されることになります。
この assert
系のメソッドの1つに assertEqual
があり、このメソッドでは第1引数に指定したデータが第2引数に指定したデータと一致するかどうかの検証を実施することができます。今回のテストケースの例では、id
が 1
のレコードの各種フィールドの値が「期待結果」に記載されたものと一致するかどうかを検証すればよいため、この assertEqual
を利用して下記の太字部分のように検証を行う処理を追加すればよいことになります。
from django.test import TestCase
from app.models import Comment
class CommentTest(TestCase):
def test_update_record_success(self):
"""各種フィールドが正常な場合にレコードの更新に成功することを確認する"""
"""前提条件"""
comment = Comment.objects.create(
author='Taro',
text='Hello'
)
"""手順の実施"""
comment = Comment.objects.get(id=1)
comment.author = 'Hanako'
comment.text = 'Good'
comment.save()
"""結果の検証"""
comment = Comment.objects.get(id=1)
self.assertEqual(comment.author, 'Hanako')
self.assertEqual(comment.text, 'Good')
TestCase
クラスには様々な assert
系のメソッドが定義されており、様々な検証を実施することが可能です。これに関しても、後述の テストケースの実装の詳細 で解説を行いたいと思います。
また、検証対象となるデータはテストの内容・テストの目的によって変化するという点には注意してください。例えば、上記の例においてはデータベースに保存されているレコードの各種フィールドが検証対象となります。そのため、レコードを取得し、そのレコードのフィールドに対して検証を実施する必要があります。
ですが、例えばメソッドや関数のユニットテストを行う場合は、それらの返却値が検証対象となることが多いです。要は、条件や手順に対して適切な値が返却されているかどうかを検証することになります。
また、メソッドや関数の作りによっては、期待結果が「例外が発生すること」となることもあり、この場合は例外の発生の有無や発生した例外の種類を検証することになります。さらには、統合テストの場合は、結果として得られたレスポンスのステータスコードやボディを検証するようなことも必要となります。
このように、テストケースによって検証対象も異なりますし、検証の仕方(利用する assert
系のメソッド)も異なることになりますので、テストケースに応じて適切な検証を実施する テストメソッド
を実装する必要があります。
テストの実施
テストケースが実装できれば、テストを実施する準備が整ったことになります。
ということで、次は、Django のテストフレームワークでテストを実施する手順について解説していきます。Django のテストフレームワークからは、下記のように様々な単位でテストを実施することができるようになっています。
- プロジェクト内の全テストを実施する
- アプリ単位でテストを実施する
- ファイル単位でテストを実施する
- クラス単位でテストを実施する
- テストケース単位でテストを実施する
プロジェクト内の全テストを実施する
まず、プロジェクト内に実装した全テストケース(テストメソッド
)の実行は、下記のコマンドによって実施することができます。
% python manage.py test
具体的には、このコマンドにより、プロジェクト内の test*.py
のパターンに該当するファイル名のファイルが探索され、それらのファイルに実装された全てのテストケースが実行されることになります。
アプリ単位でテストを実施する
また、コマンドの test
の後ろ側に アプリ名
を指定することで、そのアプリ内の test*.py
のパターンに該当するファイル名のファイルに実装されたテストのみを実施することができます。
% python manage.py test アプリ名
例えば、アプリ名
が app
の場合は、下記のコマンドを実行することで、app
内に実装されたテストケースのみが実行されることになります。
% python manage.py test app
ファイル単位でテストを実施する
さらに、コマンドの test
の後ろ側に アプリ名.モジュール名
を指定することで、モジュール単位、すなわちファイル単位でのテストを実行することが可能です。
% python manage.py test アプリ名.モジュール名
例えば、app/tests/test_models.py
に実装されたテストケースのみを実行したい場合は、下記のコマンドを実行すればよいことになります。
% python manage.py test app.tests.test_models
クラス単位でテストを実施する
コマンドの test
の後ろ側に アプリ名.モジュール名.クラス名
を指定することで、クラス単位(テストクラス
単位)でのテストを実行することも可能です。
% python manage.py test アプリ名.モジュール名.クラス名
例えば、app/tests/test_models.py
に定義された CommentTest
のテストケースのみを実行したい場合は、下記のコマンドを実行すればよいことになります。
% python manage.py test app.tests.test_models.CommentTest
テストケース単位でテストを実施する
コマンドの test
の後ろ側に アプリ名.モジュール名.クラス名.メソッド名
を指定することで、テストケース単位(テストメソッド
単位)でのテストを実行することも可能です。これがテストの実施の最小単位となります。
% python manage.py test アプリ名.モジュール名.クラス名.メソッド名
例えば、app/tests/test_models.py
に定義された CommentTest
の test_update_record_success
のみを実行したい場合は、下記のコマンドを実行すればよいことになります。
% python manage.py test app.tests.test_models.CommentTest.test_update_record_success
当然のことながら、実行するテストケースが多ければ多いほどテストに時間がかかることになります。ここまで説明してきたように、Django では様々な単位でテストを実施することが可能ですので、適切な単位でテストを実施してテスト時間の短縮を図るようにしましょう!
スポンサーリンク
テスト結果の確認
テストを実施してテストが完了すると、下記のようなテスト結果をまとめたレポートが標準出力に出力されることになります。
テストを実施した後は、このレポートを確認し、テスト結果が FAIL or ERROR となっているテストケースがあるのであれば、そのテスト結果が OK になるように、ウェブアプリ or テストケースの修正を行うことになります。基本的には、全てのテスト結果が OK になるまで、ウェブアプリ or テストケースの修正を繰り返し行います。
Found 3 test(s). Creating test database for alias 'default'... System check identified no issues (0 silenced). EF. ====================================================================== ERROR: test_delete_record_success (app.tests.test_models.CommentTest.test_delete_record_success) ---------------------------------------------------------------------- Traceback (most recent call last): File "略\test_project\app\tests\test_models.py", line 48, in test_delete_record_success comment = Comment.objects.create( ^^^^^^^^^^^^^^^^^^^^^^^ File "略\ppData\Local\Packages\PythonSoftwareFoundation.Python.3.12_qbz5n2kfra8p0\LocalCache\local-packages\Python312\site-packages\django\db\models\manager.py", line 87, in manager_method return getattr(self.get_queryset(), name)(*args, **kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "略\AppData\Local\Packages\PythonSoftwareFoundation.Python.3.12_qbz5n2kfra8p0\LocalCache\local-packages\Python312\site-packages\django\db\models\query.py", line 677, in create obj = self.model(**kwargs) ^^^^^^^^^^^^^^^^^^^^ File "略\AppData\Local\Packages\PythonSoftwareFoundation.Python.3.12_qbz5n2kfra8p0\LocalCache\local-packages\Python312\site-packages\django\db\models\base.py", line 567, in __init__ raise TypeError( TypeError: Comment() got unexpected keyword arguments: 'auther' ====================================================================== FAIL: test_update_record_ng (app.tests.test_models.CommentTest.test_update_record_ng) ---------------------------------------------------------------------- Traceback (most recent call last): File "略\test_project\app\tests\test_models.py", line 43, in test_update_record_ng self.assertEqual(comment.author, 'Hanako') AssertionError: 'Taro' != 'Hanako' - Taro + Hanako ---------------------------------------------------------------------- Ran 3 tests in 9.218s FAILED (failures=1, errors=1) Destroying test database for alias 'default'...
少しだけテストのレポートの詳細について説明しておくと、各テストケースのテスト結果としては、下記の3種類が存在します。
- OK (PASS):例外が発生しなかった
- FAIL:
assert
系のメソッドで例外が発生した(手順を実施して得られた結果が期待結果を満たしていない) - ERROR:
assert
系のメソッド以外で予期せぬ例外が発生した
テスト結果が FAIL の場合は、どの箇所の assert
系のメソッドで例外が発生したのかをレポートから確認することができますので、その情報から、手順の実施によって得られた結果が期待結果を満たしていない理由を調べていく必要があります。
また、テスト結果が ERROR の場合は、assert
系のメソッドでの検証以外の箇所で例外が発生していることになります。ウェブアプリ側で予期しない例外が発生している可能性もありますし、テストメソッド
のコードに不備があって例外が発生している可能性もあります。例外の発生個所はレポートから確認することができますので、その例外が発生しないように or その例外を意図して発生させているのであれば例外をキャッチするように修正する必要があります。
いずれにせよ、テスト結果が FAIL や ERROR になるのは、もちろんテスト対象のモジュール・ウェブアプリが原因である可能性もありますが、テストケースのコードに問題があって発生している可能性もあります。レポートの結果をしっかり確認し、問題のある個所を特定して全テストケースのテスト結果が OK となるように修正していくようにしましょう。
テストケースの実装の詳細
さて、先ほどはテストを実施するために最低限必要となる手順を解説しましたが、ここからはテストケースの実装に焦点を絞り、テストケースを実装する時に利用することの多い便利な機能やクラス・メソッド等を紹介していきたいと思います。
Client
クラス
まずは、Client
クラスについて解説していきます。この Client
クラスは django.test
で定義されているクラスで、HTTP リクエストをシミュレートするクラスになります。
つまり、この Client
クラスを利用することで、実際に HTTP リクエストを送信することなく、ウェブアプリを動作させることが可能です。また、この Client
クラスで HTTP リクエストをシミュレートした場合、実際の HTTP レスポンスよりも多くの情報が含まれたレスポンスを得ることができます。そのため、より細かな検証を行うことができ、その分、詳細なテストケースを実現することが可能です。
テストメソッド
毎に自動的にインスタンスが生成される
この Client
クラスのインスタンスは各 テストメソッド
の開始前に自動的にインスタンスが生成され、self.client
から参照されるようになっています。したがって、Client
クラスのインスタンスを生成するような処理を実装することなく、テストメソッド
の中で self.client
を利用して Client
クラスのメソッドを実行することが可能です。
# self.client = Client() は不要
response = self.client.post('/', data)
また、self.client
の参照する Client
クラスのインスタンスは テストメソッド
の開始前に毎回生成されるようになっているため、self.client
の状態は テストメソッド
の開始前にリセットされることになります。そのため、self.client
の変更は他の テストメソッド
に影響を及ぼさないことになり、テストケース間での独立性が保たれることになります。
HTTP リクエストをシミュレートするメソッド
Client
には、HTTP リクエストのメソッド毎に下記のようなメソッドが用意されており、これらのメソッドを実行することで、任意のメソッドの HTTP リクエストをシミュレートすることが可能です。シミュレートする HTTP リクエストの URL に関してはメソッドの第1引数に指定します。
get
:メソッドがGET
のリクエストを送信するpost
:メソッドがPOST
のリクエストを送信するput
:メソッドがPUT
のリクエストを送信するpatch
:メソッドがPATCH
のリクエストを送信するdelete
:メソッドがDELETE
のリクエストを送信するhead
:メソッドがHEAD
のリクエストを送信するoptions
:メソッドがOPTIONS
のリクエストを送信する
これらのメソッドでは、必要に応じて data
引数(第2引数)で送信するデータ(辞書)を指定することも可能です。これにより、フォームからのデータの送信をシミュレートすることが可能です。したがって、フォームからのデータを受信した時のウェブアプリの動作をテストするようなテストケースも作成することができます。
また、先ほど示したメソッドの返却値は HTTP レスポンスを拡張したデータとなります。この返却値には、通常の HTTP レスポンスに比べて下記のようなデータが含まれているため、より詳細な検証を含むテストを実現することが可能です。
- 使用したテンプレートファイルのパス
- 生成されたコンテキスト
Client
クラスを利用した テストメソッド
の実装例は下記のようになります。ビューやモデル・フォームの実装例に関しては省略させていただきますが、下記のコードだけでも大体何をやっているかは理解していただけるのではないかと思います。
from django.test import TestCase, Client
from app.models import Comment
class CreateTest(TestCase):
@classmethod
def setUpTestData(cls):
super().setUpTestData()
# レコードを1つ作成しておく
comment = Comment()
comment.author = 'Hanako'
comment.text = 'Hello'
comment.save()
def test_create_success(self):
"""各種フィールドが正常な場合にレコードの作成に成功することを確認する"""
"""手順の実施"""
data = {
'author': 'Jiro',
'text': 'Yes'
}
response = self.client.post('/', data)
"""結果の検証"""
# '/'へのリダイレクトレスポンスが返却されていることを確認
self.assertRedirects(response, '/')
# レコードの数が増えていることを確認
comments = Comment.objects.all()
self.assertEqual(len(comments), 2)
# 指定したフィールドに応じたレコードが登録されていることを確認
comment = comments[1]
self.assertEqual(comment.author, 'Jiro')
self.assertEqual(comment.text, 'Yes')
def test_create_invalid_no_author(self):
"""authorフィールドが存在しない場合にエラーが発生することを確認する"""
"""手順の実施"""
data = {
'text': 'Yes'
}
response = self.client.post('/', data)
"""結果の検証"""
# ステータスコードが正しいことを確認(ページの再表示)
self.assertEqual(response.status_code, 200)
# authorフィールドにエラーが発生していることを確認
form = response.context['form']
self.assertFormError(form, 'author', 'This field is required.')
client.post
を実行することで、第1引数の URL に対する POST
リクエストのシミュレートを行っています。また、第2引数に辞書を指定することで、リクエストのボディにセットするデータを指定することができます。
post
のような、前述で示した Client
クラスのメソッドを実行すれば HTTP リクエストがシミュレートされるため、本物の HTTP リクエストを受け取ったとき同様にビューやモデル・フォーム・テンプレートが連携して動作することになります。そのため、インテグレーションテストの実施に向いています。
また、HTTP リクエストをシミュレートすることで結果としてレスポンスを得ることができ、そのレスポンスからステータスコードやボディ・リダイレクト先の検証を行うことができます。
特定の用途向けのメソッド
さらに、Client
には下記のような特定の用途向けのメソッドが用意されており、これらを実行することで簡単にログイン・ログアウトも実施することができるようになります。例えば、テストケースに「ログイン中であること」という前提条件があるのであれば、login
メソッドを実行することで、その前提条件を満たすことが可能です。
login
:ログインを実施するlogout
:ログアウトを実施する
例えば、下記は、login
メソッドでログインを実施した後に手順を実施するテストケース(テストメソッド
)の実装例となります。ウェブアプリではログイン機能を搭載しているものも多く、この login
メソッドを利用する機会も多いので、是非 login
メソッドについては覚えておきましょう!
from django.test import TestCase, Client
from forum.models import Comment, CustomUser
class AuthIntegrationTest(TestCase):
@classmethod
def setUpClass(cls):
"""各テストケースで共通で必要となる変数の準備"""
super().setUpClass()
cls.login_url = '/forum/login/'
cls.user_credentials = {
'username': 'Hanako',
'password': 'ppaassss'
}
@classmethod
def setUpTestData(cls):
"""各テストケースで共通で必要となるデータの準備"""
# ユーザーの作成
cls.user = CustomUser.objects.create_user(
username='Hanako',
password='ppaassss',
age = 20,
email='hanako@example.com'
)
# コメントの作成
Comment.objects.create(
user=cls.user,
text='Hello'
)
def test_forum_comments_access_ok(self):
"""ログインしている場合に/forum/comments/に正常にアクセスできることを確認する"""
"""前提条件"""
# ログイン実施
self.client.login(**self.user_credentials)
"""手順を実施"""
# /forum/comments/にアクセス
response = self.client.get('/forum/comments/')
"""結果の検証"""
# リダイレクトされていないことを検証
self.assertEqual(response.status_code, 200)
# ユーザーがログイン中状態になっていることを検証
self.assertTrue(response.context['user'].is_authenticated)
# コメント一覧ページに表示されるべき情報がボディに含まれていることを検証
self.assertContains(response, 'Hanako')
self.assertContains(response, 'Hello')
スポンサーリンク
APIClient
クラス
また、Django REST Framework には、先ほど説明した Client
に似たクラスとして、APIClient
クラスが定義されています。この APIClient
は、rest_framework.test
に定義されています。
この APIClient
も、Client
同様に HTTP リクエストをシミュレートするクラスであり、Client
同様の HTTP リクエストをシミュレートするメソッドが用意されています(get
や post
など)。
ただし、APIClient
には下記の特徴があるため、APIClient
を利用する方が Web API のテストケースを楽に実装することができます。(下記では送信という言葉を使っていますが、実際にはシミュレートされるだけになります)。
- JSON フォーマットのデータの送信が容易:
- HTTP リクエスト送信メソッドに
format='json'
を指定するだけで、データが JSON フォーマットにシリアライズされ、さらにヘッダーにContent-Type: application/json
が自動でセットされるようになる
- HTTP リクエスト送信メソッドに
- レスポンスのボディのデシリアライズが不要:
- HTTP リクエスト送信メソッドの返却値(レスポンス)のデータ属性
data
には、JSON をデシリアライズした結果がセットされている
- HTTP リクエスト送信メソッドの返却値(レスポンス)のデータ属性
- トークン記録機能あり:
credentials
メソッドを実行することでトークンを記録することができ、以降の HTTP リクエスト送信メソッドを実行した際に、自動的にトークンが送信されるようになる
もちろん、Client
でも Web API のテストは可能なのですが、効率的にテストを実施していくために APIClient
を利用することをオススメします。
ただし、Client
の場合とは異なり、APIClient
のインスタンスは テストメソッド
毎に自動的に生成されるようにはなっていないので注意してください。つまり、APIClient
を利用する場合は、APIClient
のインスタンスを生成する処理を開発者自身が実装しておく必要があります。テストの独立性を保つため、APIClient
のインスタンスを生成する処理は setUp
メソッドに実装しておくことをオススメします。
例えば下記は、トークン認証を実施する GET /api/comments/
の Web API に対するテストケース(テストメソッド
)の例となります。
from django.test import TestCase
from rest_framework.test import APIClient
from rest_framework.authtoken.models import Token
from forum.models import Comment, CustomUser
class AuthIntegrationTest(TestCase):
@classmethod
def setUpClass(cls):
"""各テストケースで共通で必要となる準備"""
super().setUpClass()
cls.user_credentials = {
'username': 'Hanako',
'password': 'ppaassss'
}
@classmethod
def setUpTestData(cls):
"""各テストケースで共通で必要となるレコードの準備"""
cls.user = CustomUser.objects.create_user(
username='Hanako',
password='ppaassss',
age = 20,
email='hanako@example.com'
)
Comment.objects.create(
user=cls.user,
text='Hello'
)
Comment.objects.create(
user=cls.user,
text='ABCDEFGHIJKLMNOPQRSTUVWXYZ'
)
token_record = Token.objects.create(user=cls.user)
cls.token = token_record.key
def setUp(self):
"""各テストケースで毎回で必要となる初期化"""
# APIClientのインスタンス生成
self.client = APIClient()
def test_get_api_comments_success(self):
"""正しいトークンが送信された場合にGET /api/comments/の実行に成功することを確認する"""
"""前提条件"""
# トークンの記録
self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
"""手順を実施"""
# GET /api/comments/を実行(記録したトークンも送信される)
response = self.client.get('/api/comments/')
"""結果の検証"""
# レスポンスのステータスコードが200であることを検証
self.assertEqual(response.status_code, 200)
# 全レコードが取得できていることを検証(デシリアライズは不要)
self.assertEqual(len(response.data), 2)
self.assertEqual(response.data[0]['text'], 'Hello')
self.assertEqual(response.data[0]['user'], 1)
self.assertEqual(response.data[1]['text'], 'ABCDEFGHIJKLMNOPQRSTUVWXYZ')
self.assertEqual(response.data[1]['user'], 1)
初期化処理・終了処理の共通化用メソッド
本格的にテストを実施しようと思うと、実装する テストメソッド
の量も増え、テストの工数が膨大になります。そのため、効率的に テストメソッド
を実装していくことも重要となります。
そのための1つの仕組みとして、テストクラス
には初期化処理・終了処理の共通化用メソッドが用意されています。これを利用することで、各 テストメソッド
で実施する初期化処理・終了処理が1つのメソッドに集約され、テストメソッド
の実装の効率化を図ることができます。
setUp
メソッドと tearDown
メソッド
この初期化処理・終了処理の共通化用メソッドとして、テストクラス
では setUp
メソッドと tearDown
メソッドを定義できるようになっています。これらのメソッドは特別で、これらを定義しておけば、setUp
メソッドは各 テストメソッド
の実行前に、tearDown
メソッドは各 テストメソッド
の実行後に自動的に呼び出されるようになっています。
したがって、setUp
メソッドに各テストケースでテスト前に毎回必要となる初期化処理を、tearDown
メソッドに各テストケースでテスト後に毎回必要となる終了処理をそれぞれ実装しておけば、わざわざ テストメソッド
に同じ処理を実装する必要がなくなり、各メソッドの実装量を減らすことができます。
例えば「各 テストメソッド
の実行前と実行後のログの出力」は各 テストメソッド
の先頭と末尾で print
を実行することでも実現できるのですが、これだと全ての テストメソッド
に同じような処理を記述する必要があって非効率です。それに対し、setUp
と tearDown
を定義し、これらのメソッド内で print
を実行するようにすれば、2つのメソッドの変更のみで同じことが実現でき、効率的にテストの実装を行うことができます。
例えば、下記のように setUp
メソッドと tearDown
メソッドを定義しておけば、テストの実行前後に テストメソッド
の名称が出力されるようになります。
from django.test import TestCase
from app.models import Comment
class CommentTest(TestCase):
def setUp(self):
print(f"Satrt : {self._testMethodName}")
def tearDown(self):
print(f"Finish : {self._testMethodName}")
def test_update_record_success(self):
# 略
def test_get_record_success(self):
# 略
setUpClass
メソッドと tearDownClass
メソッド
同様に、初期化処理・終了処理の共通化用メソッドとして、テストクラス
では setUpClass
メソッドと tearDownClass
メソッドを定義できるようになっています。
setUpClass
と tearDownClass
はクラスメソッドとして定義する必要があります。また、setUpClass
からはスーパークラスの setUpClass
を、tearDownClass
からはスーパークラスの tearDownClass
を必ず呼び出すようにする必要があるため、この点には注意してください。
先ほど紹介した setUp
メソッドと tearDown
メソッドは「テストケース単位」で実行されるメソッドとなりますが、setUpClass
メソッドと tearDownClass
メソッドは「クラス単位」で実行されるメソッドになります。つまり、setUpClass
メソッドは、テストクラス
全体の テストメソッド
を実行する前に1度だけ実行され、tearDownClass
は テストクラス
全体の テストメソッド
を実行した後に1度だけ実行されます。
例えば、各テストケースに共通となる初期化処理・終了処理が必要である場合、その初期化処理を setUpClass
メソッドに、終了処理を tearDownClass
メソッドに実装しておけば、各テストケースに同じような処理を実装する必要が無くなり、効率的にテストケースの実装を行うことができるようになります。
例えば、LiveServerTestCase でも解説するように、テストをウェブブラウザを利用して実施するような場合があり、各テストケースが共通にウェブブラウザを利用するものであるのであれば、ウェブブラウザを起動する処理を setUpClass
メソッドに、ウェブブラウザを終了する処理を tearDownClass
メソッドに実装しておけば良いことになります。
もちろん、同じようなことは setUp
メソッドと tearDown
メソッドでも実現することが可能です。ですが、これらのメソッドはテストケースごとに実行されることになるため、テストケースごとにウェブブラウザを開く / 閉じる処理が実行されることになり、テストの実行時間が長くなってしまいます。setUpClass
メソッドと tearDownClass
メソッドでは、テストクラス
の開始 / 終了のタイミングでのみ実行されることになるため、ウェブブラウザを開く / 閉じる処理も1度ずつのみ行われることになり、テストの実行時間を短くすることができます。
setUpTestData
メソッド
また、TestCase
のサブクラスにおいては setUpTestData
メソッドを定義することが可能で、このメソッドは TestCase
の setUpClass
から呼び出されるメソッドとなります。この setUpTestData
メソッドに関してもクラスメソッドとして定義する必要があります。
この setUpTestData
は、主にデータベースのセットアップ(レコードの新規登録など)を行うことを目的に定義するメソッドとなります。setUpClass
から呼び出されるため、setUpClass
同様に テストクラス
全体の テストメソッド
を実行する前に1度だけ実行されるメソッドとなります。そのため、各テストケース間で共通のレコードの新規登録を行う必要があるのであれば、この setUpTestData
でレコードの新規登録を行うようにしておけば、各 テストメソッド
に同様の処理を実装する必要が無くなり、効率的にテストケースを実装することができます。また、1度だけ実行されるメソッドであり、毎回データベースのセットアップが行われるわけではないので、テストの実行時間的な効率化を図ることもできます。
さらに、この setUpTestData
でセットアップされたデータベースは、各テストケース実行後に setUpTestData
でセットアップされた状態に自動的にロールバックされるようになっています。また、setUpTestData
でセットアップされたデータベースは、クラス終了時に自動的に削除されるようになっています。そのため、各テストケースでデータベースをロールバックするような処理や、クラス終了時にデータベースを削除するような処理は不要となります。
同様のことは setUp
でも実現しようと思えば実現できますが、setUp
はテストケース開始前に毎回実行されることになるため、毎回データベースのセットアップが実行されることになります。なので、その分テストの実行に時間がかかります。
また、setUpClass
でも同様のことが実現でき、この場合はテストの実行時間的な効率も setUpTestData
と変わらないのですが、setUpClass
で同様のことを実装した場合、実装の仕方によってはテスト完了後のデータベースのロールバックが正しく動作しない可能性があります。なので、setUpTestData
でデータベースのセットアップを行うようにした方が安全です。このあたりについては、テストの実行メカニズム で詳細を解説します。
まずは、TestCase
のサブクラスにおいては、データベースのセットアップは setUpTestData
で実施するということを覚えておいてください。
例えば下記のように setUpTestData
メソッドを定義しておけば、CommentTest
の開始時に setUpTestData
が実行されることになり、レコードが新規登録されることになります。そして、test_update_record_success
のテストケースも test_get_record_success
のテストケースも、そのレコードが新規登録された状態で開始されることになります。また、たとえ test_update_record_success
が先に実行されてレコードが作成・更新されたとしても、test_get_record_success
実行時には setUpTestData
実行後のデータベースの状態にロールバックされることになり、これらのテストケースは同じ状態から開始されることになります。
from django.test import TestCase
from app.models import Comment
class CommentTest(TestCase):
@classmethod
def setUpTestData(self):
Comment.objects.create(
author='Hanako',
text='Hello'
)
def test_update_record_success(self):
# 略
def test_get_record_success(self):
# 略
Fixture 機能
次は、Fixture 機能について紹介していきます。この Fixture も、先ほど紹介した setUpTestData
同様、テスト開始前のデータベースのセットアップに関する機能になります。
Fixture 機能とは
Django のテストフレームワークには Fixture 機能が用意されており、この機能を利用することで、用意したファイルの中身に応じたレコードをテストケース開始前にインポートすることができるようになります。この Fixture 機能を利用すれば、setUpTestData
にレコードを新規登録する処理を実装しなくても、ファイルさえ用意しておけばテスト前にレコードを新規登録することができるようになります。
各種フィールドの値が大きく異なる大量のレコードを新規登録するような処理を実装するのは非現実的なので、このような場合は Fixture 機能が活躍することになります。
ただし、この Fixture 機能によるレコードのインポートは setUp
同様にテストケースの開始前に毎回実施されることになります。なので、setUpTestData
に比べるとテストに時間がかかることになります。この辺りのバランスを考えて、Fixture 機能の利用の可否を決めれば良いと思います。
Fixture 機能の利用方法
この Fixture 機能は、テストクラス
にクラス変数 fixtures
を定義することで利用することができ、fixtures
にはファイルパスの文字列を要素とするリストを指定します。これにより、テスト前にリスト内のファイルパスのファイルが読み込まれ、それらがデータベースにレコードとしてインポートされることになります。
この Fixture 機能で読み込み可能なファイルのフォーマットとしては JSON や XML 等が挙げられ、例えば JSON の場合は下記のような構造のファイルである必要があります。
[
{
"model": "アプリ名.モデルクラス名",
"pk": プライマリーキー1,
"fields": {
"フィールド名1": 値1,
"フィールド名2": 値2,
// 略
}
},
{
"model": "アプリ名.モデルクラス名",
"pk": プライマリーキー1,
"fields": {
"フィールド名1": 値1,
"フィールド名2": 値2,
// 略
}
},
// 略
]
例えば下記は、Fixture 機能を利用し、test_data.json
に記録されたレコードをテストケース開始前にデータベースにインポートする例となります。
from django.test import TestCase
from app.models import Comment
class CommentTest(TestCase):
fixtures = ['test_data.json']
def test_update_record_success(self):
# 略
def test_get_record_success(self):
# 略
下記は、その読み込み対象となる test_data.json
の例となります。下記の場合は、Comment
のテーブルに2つのレコードが登録されることになります。
[
{
"model": "app.Comment",
"pk": 1,
"fields": {
"author": "Hanako",
"text": "Hello"
}
},
{
"model": "app.Comment",
"pk": 2,
"fields": {
"author": "Taro",
"text": "Good bye"
}
}
]
テスト用データのダンプ
また、この Fixture 機能に利用可能な JSON ファイルは、下記のコマンドでデータベースにテーブルから抽出することも可能です。
% python manage.py dumpdata アプリ名.モデルクラス名 --output=ファイル名.json
スポンサーリンク
assert
系メソッド
次は、テスト結果の検証に利用する assert
系メソッドについて解説していきます。
テストは、単に手順を実施するだけでなく、その手順を実施することで得られた結果を検証することも重要です。例えば、関数に対するテストであれば、テストケースの手順に応じた引数を指定して関数を実行し、その関数の返却値が期待結果と一致していることを検証するような処理や、モデルに対するテストであれば、テストケースの手順に応じたデータベース操作を実行し、その後にデータベースからレコードを取得して期待する結果と一致していることを検証するような処理が必要となります。
そして、ここまでの説明の通り、これらの検証は assert
系メソッドを実行して実施する必要があります。assert
系メソッドを利用して検証を実施することで、その検証結果がテスト結果としてテストレポートに反映されることになります。
この assert
系メソッドは、TestCase
クラス (もしくは Django のテストフレームワークで定義されたテスト用のクラス) で定義されており、テストメソッド
の引数 self
から実行することが可能です。
TestCase
には様々な assert
系メソッドが定義されており、メソッドによって実施される検証内容が異なります。ここでは、Django テストフレームワークで利用可能な全 assert
系メソッドの調べ方、および、利用する機会の多い assert
系メソッドについて解説していきます。
全 assert
系メソッドの調べ方
まず、Django のテストフレームワークで利用可能な全 assert
系メソッドの調べ方について解説しておきます。
前述の通り、assert
系メソッドは TestCase
で定義されているメソッドとなりますので、単純に下記のように help(TestCase)
を実行することで、定義されている全 assert
系メソッド、および、それらのメソッドの使い方を調べることが可能です。ただし、assert
以外のメソッドの情報も含まれるので、その点は注意してください。
from django.test import TestCase
help(TestCase)
また、下記を実行することで、assert
系メソッドのみのメソッド名をリストアップして表示することも可能です。
from django.test import TestCase
methods = [method for method in dir(TestCase) if method.startswith('assert')]
print(methods)
上記の処理はメソッド名のみがリストアップされることになりますが、リストアップされたメソッドから興味のあるメソッドを選び、後は下記の処理を実行することで、そのメソッドのヘルプのみを出力することも可能です。
from django.test import TestCase
help(TestCase.メソッド名)
上記のような方法で、Django のテストフレームワークで利用可能な assert
系メソッドや、そのメソッドの使い方も調べることができますので、実施したい検証に合わせて適切なメソッドを利用するようにしましょう。
よく利用する assert
系メソッド
続いて、利用する機会の多い assert
系メソッドを下記に示しておきます。
assert
系メソッドを上手く使いこなすことが、効率的なテストケースの実装・詳細なテスト・効果的なテストレポートの作成に繋がりますので、これらのメソッドについてはしっかり覚えておきましょう!
下記における response
は、Client
or APIClient
の HTTP リクエスト送信メソッドの返却値となりますので、この点はご注意ください。
メソッド | 検証内容 |
assertEqual(a, b) |
a と b が値として一致すること |
assertNotEqual(a, b) |
a と b が値として一致しないこと |
assertIn(member, container) |
container に member 要素が含まれていること |
assertNoIn(member, container) |
container に member 要素が含まれていないこと |
assertContains(response, text) |
response のボディに text が含まれること |
assertNotContains(response, text) |
response のボディに text が含まれないこと |
assertTemplateUsed(response, template) |
response のボディ生成時にtemplate が使用されたこと |
assertTemplateNotUsed(response, template) |
response のボディ生成時にtemplate が使用されなかったこと |
assertRedirects(response, url) |
response がリダイレクト先を url とするリダイレクトレスポンスであること |
assertFormError(form, field, err) |
form の field フィールドで err のエラーが発生したこと |
assertRaises(exception) |
exception の例外が発生すること |
RequestFactory
RequestFactory
は、ビューに入力する「リクエスト」を生成するためのクラスになります。django.test
で定義されるクラスで、特にビューのユニットテストを実施する時に利用します。
通常のウェブアプリ利用時には、ビューには適切なリクエストのデータが引数で渡されるように Django フレームワークが動作するようになっています。なので、ビューの引数に指定するリクエストを開発者が用意するような必要はありません。
ですが、ビューに対するユニットテストを実施する際には、そのテストの目的や内容に応じたリクエストを用意し、それをビューに引数で渡すようにする必要があります。このリクエストを生成するときに便利なのが RequestFactory
になります。
この RequestFactory
クラスには下記のようなメソッドが用意されており、第1引数に URL を指定して実行することで、そのメソッド・URL の HTTP リクエストを受け取った時に Django フレームワークが生成するリクエストと同等のものを返却値として取得することができます。
get
:メソッドがGET
のリクエストを生成するpost
:メソッドがPOST
のリクエストを生成するput
:メソッドがPUT
のリクエストを生成するpatch
:メソッドがPATCH
のリクエストを生成するdelete
:メソッドがDELETE
のリクエストを生成するhead
:メソッドがHEAD
のリクエストを生成するoptions
:メソッドがOPTIONS
のリクエストを生成する
また、第2引数(data
引数)に辞書を指定することで、それをボディとするリクエストを生成することも可能です。
さらに、生成されたリクエストのデータ属性に値やオブジェクトを参照させることで、よりリクエストを詳細に設定することができます。
リクエストは HttpRequest
クラスのコンストラクタを実行することでも生成することができるのですが、ビューに入力するリクエストとして適切なものになるよう自分自身で全てデータ属性の設定を行う必要があります。ですが、RequestFactory
クラスのメソッドを利用すれば、ある程度ビューに入力するリクエストとして適切なデータを自動的に生成してくれることになるため、ユニットテストの実装が楽になります。
例えば下記は、URL が /forum/post/
・メソッドが POST
・text
フィールドの値が 'Yes'
であるリクエストを RequestFactory
を利用して生成し、このような HTTP リクエストを送信したときに実行されるビューのクラス Post
のユニットテストを実施する例となります。下記のように、生成したリクエストの user
にユーザーをセットすることで、リクエストの送信者を設定することも可能です。
from django.test import TestCase, RequestFactory
from forum.models import CustomUser
from forum.views import Post
class PostTest(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = CustomUser.objects.create_user(
username='Hanako',
password='ppaassww',
age = 20,
email='hanako@example.com'
)
def setUp(self):
self.factory = RequestFactory()
def test_post_forum_comments_success(self):
"""Postの実行に成功した場合のステータスコードが302であることを確認するテスト"""
"""手順の実施"""
# リクエストを生成
data = {
'text': 'Yes'
}
request = self.factory.post('/forum/post/', data)
request.user = self.user
# ビューを実行
view = Post.as_view()
response = view(request)
"""結果の検証"""
# ステータスコードの検証
self.assertEqual(response.status_code, 302)
# リダイレクト先の検証
self.assertEqual(response['Location'], '/forum/post/')
また、ここでは特にビューに焦点を当てて RequestFactory
について解説しましたが、ビュー以外でも、リクエストを引数とする関数やメソッドのユニットテスト時には、この RequestFactory
が利用可能です。
LiveServerTestCase
ここまで説明してきたように、基本的にはテストケースは TestCase
のサブクラスに テストメソッド
として実装していくことになります。これにより、ウェブアプリに対して実施すべきテストの大部分をカバーできるはずです。
また、これも前述で説明した通り、TestCase
では HTTP リクエストを受信するための HTTP サーバーが起動されないため、HTTP リクエストを実際に送信して実施するテストは実現できませんが、Client
クラスで HTTP リクエストをシミュレートすることで、HTTP リクエストの送信を模擬したテストを実施することが可能です。
LiveServerTestCase
とは
ですが、実際に HTTP リクエストを送信してウェブアプリのテストを実施したいようなケースも存在し、この場合は HTTP サーバーの起動が必要となります。前述の通り、TestCase
を利用してテストを実施する場合は HTTP サーバーは起動されないため、別の手段でテストを実施する必要があります。そして、その別の手段が、LiveServerTestCase
を利用したテストになります。
この LiveServerTestCase
のサブクラスとして テストクラス
を定義した場合、各 テストメソッド
は HTTP サーバーが起動した状態で実行されることになります。なので、各テストケースで実際に HTTP リクエストを送信してウェブアプリの動作を確認するようなテストも Django のテストフレームワークで実現できることになります。
LiveServerTestCase
を利用する機会が多いのが、ウェブアプリの UI に対するテストを自動的に実行したい場合になります。例えば「ウェブアプリのフォームのボタンをクリックすることで、フォームの各種フィールドに入力されたデータが HTTP リクエストのボディとしてウェブアプリに送信されること」を確認したい場合は、HTTP サーバーを起動して HTTP リクエストを受信できるようにしておく必要があります。そのため、このようなテストは LiveServerTestCase
を利用して実現する必要があります。
Selenium と組み合わせた UI テストの自動化
また、LiveServerTestCase
は、Selenium というライブラリと組み合わせて利用されることが多いです。Selenium はウェブブラウザの操作を自動化するライブラリで、この Selenium を利用することで、上記のようなフォームのボタンのクリックを自動で実行するようなことができます。
つまり、LiveServerTestCase
を継承するサブクラスを定義し、Selenium を利用してフォームのボタンのクリックを行うような テストメソッド
を実装すれば、ウェブアプリの UI に対するテストも Django のテストフレームワークで実施することができます。そして、こういった UI に対するテストも自動で実施することが可能となります。また、JavaScript はウェブブラウザから実行されることになりますので、Selenium を利用することで JavaScript に対するテストも実施することができるようになります。
例えば下記は、LiveServerTestCase
と Selenium を利用したテストケースの例となります。
from django.contrib.staticfiles.testing import LiveServerTestCase
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from app.models import Comment
import time
class FormTests(LiveServerTestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
# ドライバーを生成(Chromeが起動)
cls.driver = webdriver.Chrome()
@classmethod
def tearDownClass(cls):
# ドライバーを終了(Chromeが終了)
cls.driver.quit()
super().tearDownClass()
def test_click_send_button(self):
"""フォームから各種フィールドの値が送信されることを確認する"""
"""手順の実施"""
# フォームを表示するURLを設定
url = self.live_server_url + '/app/create/'
# ウェブブラウザでurlを開く
self.driver.get(url)
# authorフィールドを取得(表示されるまで待機)
author_field = WebDriverWait(self.driver, 10).until(
EC.presence_of_element_located((By.NAME, 'author'))
)
# authorフィールドに文字列を入力
author_field.send_keys('Jiro')
# textフィールドを取得(表示されるまで待機)
text_field = WebDriverWait(self.driver, 10).until(
EC.presence_of_element_located((By.NAME, 'text'))
)
# textフィールドに文字列を入力
text_field.send_keys('Yes!')
# 送信ボタンを取得(クリックできるようになるまで待機)
send_button = WebDriverWait(self.driver, 10).until(
EC.element_to_be_clickable((By.XPATH, '//input[@value="送信"]'))
)
# send_buttonをクリック
send_button.click()
# データーベースへの操作結果が反映されるまで待つ
time.sleep(2)
"""結果の検証"""
# ボタンクリックによってコメントが追加されていることを確認
comment = Comment.objects.all().last()
self.assertEqual(comment.author, 'Jiro')
self.assertEqual(comment.text, 'Yes!')
このスクリプトを実行するためには、Selenium を事前にインストールしておく必要があるので注意してください。Selenium は下記コマンドでインストール可能です。
% python -m pip install selenium
また、このスクリプトは、下図のような Comment
の新規登録フォームが GET /app/create/
の HTTP リクエストを送信することで表示されることを想定したものとなっています。各フィールドに文字列を入力した後に 送信
ボタンをクリックし、その後、各フィールドに入力された文字列に応じたレコードがデータベースに保存されていることを検証することで、送信
ボタンクリック時の動作のテストを実施しています。
このように、LiveServerTestCase
を利用することで HTTP サーバーが起動され、さらに Selenium を利用することでウェブアプリの UI に対するテストも実施可能であることは覚えておきましょう!
データベースの自動管理
ただし、この LiveServerTestCase
でのデータベースの自動管理は、TestCase
におけるデータベースの自動管理の仕方と異なるので注意してください。
どちらかというと、TestCase
におけるデータベースの自動管理が特殊で、TestCase
では、次の節の テストの実行メカニズム で解説するように、テストケース毎に「テストケース開始前の状態」へデータベースがロールバックされるようになっています。なので、テストケース開始前にデータベースをセットアップした場合、より具体的には、setUpClass
や setUpTestData
でデータベースをセットアップした場合、テストケース開始時には、そのセットアップ後の状態にロールバックされることになります。
それに対し、LiveServerTestCase
では、テストケース毎にデータベースがリセット(トランケート)されて空になるようになっています。したがって、例えば LiveServerTestCase
のサブクラス内のテストケースで共通に必要となるデータベースのセットアップであっても、そのデータベースのセットアップはテストケースごとに毎回実施する必要があることになります。例えば setUpClass
でデータベースのセットアップを行ったとしても、そのデータベースは最初のテストケース終了時にリセットされて空になってしまうことになります(また、LiveServerTestCase
では setUpTestData
は利用不可となります)。
そのため、基本的には、LiveServerTestCase
においては各テストケースで共通に必要となるデータベースのセットアップは setUp
メソッドで実施することになります。LiveServerTestCase
においても各テストケース開始時(すなわち、データベースがリセットされた後)に setUp
メソッドが実行されるようになっています。もしくは、Fixture 機能を利用するのでも良いです。
ただ、これらの方法ではデータベースのセットアップが各テストケース開始時に毎回実施されることになるので、テスト時間が長くなってしまいます。これに関しては許容するしかないと思います…。したがって、HTTP リクエストの送信が必要となるテストのみ LiveServerTestCase
を利用し、それ以外は TestCase
を利用してテストを実施するようにした方が良いと思います。
ここまでの説明の通り、TestCase
と LiveServerTestCase
のいずれにおいてもテストケース間でデータベースの状態が独立するようデータベースの自動管理が行われるのですが、その管理の仕方が異なる点には注意してください。
静的ファイルの配信と StaticLiveServerTestCase
また、LiveServerTestCase
によって起動する HTTP サーバーは開発用ウェブサーバーとは異なるという点にも注意してください。特に注意が必要となるのが「静的ファイルの配信」になります。下記ページで解説したように、開発用ウェブサーバーでは アプリ名/static/
フォルダ以下(+STATICFILES_DIRS
に指定されたフォルダ以下)に設置されたファイルが配信されるようになっています。
ですが、LiveServerTestCase
によって起動する HTTP サーバーは開発用ウェブサーバーではないため、アプリ名/static/
以下にファイルを設置してもファイルの配信が行われません。LiveServerTestCase
によって起動する HTTP サーバーでは、settings.py
で STATIC_ROOT
に指定したフォルダ以下のファイルのみが配信されるようになっています。そのため、LiveServerTestCase
を利用したテスト時に静的ファイルの配信も行われるようにしたいのであれば、あらかじめ collectstatic
コマンドを実行して静的ファイルを STATIC_ROOT
に指定したフォルダ以下に集約しておく必要があります。
つまり、LiveServerTestCase
を利用したテストでの静的ファイルの配信を実施するためには、まず settings.py
に STATIC_ROOT
の定義を追記し、
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') # 静的ファイルの設置先を設定
その後、manage.py
を利用して下記のように collectstatic
コマンドを実行する必要があります。
% python manage.py collectstatic
これらを忘れると、LiveServerTestCase
を利用したテストでは静的ファイルの配信が行われないので注意してください。
また、上記のような手順を実施するのが面倒であれば、LiveServerTestCase
ではなく、StaticLiveServerTestCase
のサブクラスとして テストクラス
を定義するようにしてください。StaticLiveServerTestCase
で起動する HTTP サーバーは、開発用ウェブサーバーと同じ仕組みで静的ファイルの配信が行われるようになっています。つまり、アプリ名/static/
以下に設置したファイルが静的ファイルとして配信されるようになっています。なので、StaticLiveServerTestCase
で テストクラス
を定義した場合は、先ほど示した STATIC_ROOT
の定義や collectstatic
コマンドの実行なしで静的ファイルの配信が行われることになります。
また、StaticLiveServerTestCase
は LiveServerTestCase
に対して静的ファイルの扱い方のみを拡張したクラスとなりますので、LiveServerTestCase
の時と同様の手順でテストケースが実装可能です。
ただし、TestCase
や LiveServerTestCase
とは import
先のモジュールが異なるので注意してください。これらは django.test
で定義されたクラスになりますが、StataicLiveServerTestCase
に関しては django.contrib.staticfiles.testing
で定義されたクラスとなります。そのため、下記のように django.contrib.staticfiles.testing
から import
を行う必要があります。
from django.contrib.staticfiles.testing import StaticLiveServerTestCase
スポンサーリンク
テストの実行メカニズム
次に、Django のテストフレームワークでテストの実行メカニズムについて解説しておきます。
テストファイルの探索
テストの実施 でも解説したように、Django のテストフレームワークではコマンドによって様々な単位でテストを実行することが可能です。
また、特にプロジェクトの全てのテスト or アプリ単位でのテストを実行する場合、テストファイルの用意 で解説した通り、各パッケージ内の特定のパターンに該当するファイル名のファイルが探索され、そのファイルに実装された テストメソッド
が実行されることになります。この特定のパターンは、デフォルトでは test*.py
となります。
ただし、このファイル名のパターンは変更可能で、具体的にはテスト実行時のコマンドに --pattern
オプションを指定することでファイル名のパターンを test*.py
以外のものに変更することが可能です。例えば下記のようにコマンドを実行すれば、プロジェクト内の *_tests.py
のパターンに該当するファイル名のファイルに実装された テストメソッド
が実行されるようになります。
% python manage.py test --pattern *_tests.py
テスト実行の並列度
また、各テストケースは、デフォルトではシリアルに実行されるようになっています。つまり、テストケースは1つずつ逐次的に実行されることになり、複数のテストが同時に並列で実行されることはありません。
ですが、これもコマンドの引数によって変更可能で、--parallel
オプションを指定することで、テストケースの実行を並列化することが可能です。具体的には、--parallel
オプションを指定することでテストケースがマルチプロセスで実行されることになり、これによってテストケースが並列に実行されることになります。PC のスペックが高い場合、この並列化を行うことでテストの実行時間を短縮することが可能です。
単に --parallel
オプションを指定した場合は、テストを実行する PC の CPU コア数に基づいてテストプロセスの数(並列度)が自動的に決定されます。また、--parallell N
のように引数に整数を指定した場合、その指定された数のテストプロセスでテストケースが並列に実行されることになります。
例えば、下記のように --parallel
オプションを指定すれば、テスト用のプロセスが4つ起動し、最大4の並列度でテストが実施されることになります。
% python manage.py test --parallel 4
ただし、並列化は テストクラス
単位で実施されることになります。したがって、異なる テストクラス
に実装されたテストケースは並列に実行されることになりますが、同じ テストクラス
に実装されたテストケースは並列には実行されず、逐次的に実行されることになります。そのため、並列化によるテスト時間の短縮を図るためには、テストケースを1つの テストクラス
に実装するのではなく、複数の テストクラス
に分けて実装する必要があります。
また、このように複数のテストプロセスを作成してテストケースを並列実行する場合、テストプロセスごとにテスト用のデータベースが作成されることになります。したがって、並列実行されるテストケース間でもデータベースの競合が起こるようなことはありません。
ただし、データベース以外のデータはテストケース間で競合が発生する可能性があるので、この点には注意してください。例えば、各テストケースで同じファイルの編集を行なったりすると、その編集が競合してテスト結果が意図しないものになる可能性もあります。
さらに、並列度が高すぎると(テストプロセスの数が多すぎると)、負荷が高くなって逆にテストの実行時間が長くなってしまう可能性もありますので、この点にも注意してください。
要は、データベースがプロセス単位で独立しているという点を除けば、基本的にはマルチプロセスプログラミングの時と同様のことに気をつけてテストケースを実装する必要があります。
スポンサーリンク
データベースのロールバック
また、TestCase
のサブクラスで実施するテストでは、適切にデータベースのロールバックが行われるようになっており、各テストケース間でデータベースは独立した状態でテストが実施されるようになっています。
このロールバックについて、簡単に説明しておきます。
テストケース単位でのロールバック
まず、このロールバックは、テストケース単位で実施されるようになっています。テストケース開始時にデータベースの状態が保存され、テストケース終了後に、その保存された状態へロールバックされるようになっています。もう少し専門的な言葉で説明すると、テストケース開始時にトランザクションを開始し、テストケース終了時にトランザクション内で実施されたデータベース操作をロールバックするようになっています。
このように、テストケースごとにロールバックが実施されるようになっており、テストケース内で実施されたデータベース操作はテストケース終了時に取り消されることになります。そのため、テストケース内でデータベース操作を実施したとしても、他のテストケースへの影響はありません。
テストクラス
単位でのロールバック
また、このロールバックは、テストクラス
単位でも実施されるようになっています。TestCase
の setUpClass
実行時に、テストケース単位でのロールバック用のものとは異なるトランザクションが開始され、TestCase
の tearDownClass
実行時に、そのトランザクション内で実施されたデータベース操作がロールバックされるようになっています。そのため、各 テストクラス
間でもデータベースが独立した状態でテストが実施されるようになっています。
で、この テストクラス
単位でのロールバックを上手く動作させるためには、まず TestCase
の setUpClass
メソッドと TestCase
の tearDownClass
メソッドの実行が必要となります。そのため、テストクラス
(すなわち TestCase
のサブクラス) で setUpClass
/ tearDownClass
を定義する場合、これらのメソッド内で必ずスーパークラス (すなわち TestCase
) の setUpClass
/ tearDownClass
を呼び出すようにする必要があります。あくまでもロールバックの仕組みが導入されているのは TestCase
の setUpClass
メソッドと tearDownClass
メソッドとなりますので、これらが実行されないと テストクラス
単位でのロールバックが正常に動作しなくなります。
例えば下記のように TestCase
のサブクラスに setUpClass
メソッドと tearDownClass
メソッドを定義してしまうと、ロールバックが正常に動作しなくなります(そもそもテストも上手く実施できないと思います)。
from django.test import TestCase
class InvalidTest(TestCase):
@classmethod
def setUpClass(self):
pass
@classmethod
def tearDownClass(self):
pass
また、テストクラス
単位でのロールバックを上手く動作させるためには、データベース操作は「TestCase
の setUpClass
実行後」に実施する必要があります。あくまでも、TestCase
の tearDownClass
で実施されるロールバックは、TestCase
の setUpClass
でトランザクションを開始した時点のデータベースの状態へのロールバックとなります。したがって、TestCase
の setUpClass
を実行する前に実施されたデータベース操作はロールバックされません。そのため、次に他の テストクラス
でテストが実施される際には、前に実行された テストクラス
でのデータベース操作が残った状態のデータベースでテストが実施されることになります。そうなると、テストケースの設計と話が合わなくなる可能性があり、意図したテストが実施できなくなる可能性があります。
このように、テストクラス
内で共通となる初期化処理でデータベース操作が必要である場合は、TestCase
の setUpClass
を実行した後に初期化処理を実施する必要があるという点に注意してください。
例えば、下記のように テストクラス
の setUpClass
を実装してしまうと、TestCase
の setUpClass
を実行する前に作成した Comment
のレコードが残った状態で次の テストクラス
のテストが実行されることになります。
from django.test import TestCase
from .models import Comment
class InvalidTest(TestCase):
@classmethod
def setUpClass(self):
comment = Comment()
# 各種フィールドの設定
comment.save()
super().setUpClass()
setUpTestData
メソッドを利用する理由
setUpTestData メソッド の節で「テストクラス
内のテストケースで共通に必要となるデータベースのセットアップは setUpTestData
メソッドで実施すべき」と説明したのは、上記のような背景があるからになります。 setUpTestData
メソッドは、必ず TestCase
の setUpClass
でトランザクションが開始された後に実行されるようになっています。したがって、setUpTestData
メソッドで実施したデータベース操作は、TestCase
の tearDownClass
実行時に必ずロールバックされることになります。つまり、上記のような背景を知らなくても、setUpTestData
メソッドでデータベース操作を行うようにする限り、そのデータベース操作が他の テストクラス
のテストに影響を及ぼすことはありません。
ということで、TestCase
のサブクラスでテストケースを実装する場合、クラス内で共通となるデータベースのセットアップに関しては setUpTestData
で行うようにした方が良いです。
TestCase
以外でのロールバック
ここまでの解説は、TestCase
でのロールバックに関する説明になります。
LiveServerTestCase の節でも解説したように、LiveServerTestCase
でもテストケース毎にデータベースが独立する仕組みが存在しますが、これはロールバックによって実現されているのではなく、単にデータベースをリセットすることで実現されています。つまり、単純にデータベースのレコードがテストケース毎に削除されて空っぽになります。
他にも、Django のテストフレームワークには TransactionTestCase
が定義されており、このクラスにおいても、LiveServerTestCase
同様にテストケース毎にデータベースがリセットされるようになっています。
したがって、LiveServerTestCase
や TransactionTestCase
の場合は、クラス単位でのデータベースのセットアップに関しては考える必要がなく、単純に必要に応じてデータベースのセットアップをテストケース毎に実施するようにすれば良いだけです。テストケース毎にセットアップが行われることになってテストの時間はかかりますが、考え方に関しては LiveServerTestCase
の方がシンプルではないかと思います。
Django テスト向け外部ライブラリ
続いて、Django でのテスト時に利用する機会の多い外部ライブラリについて解説しておきます。
Selenium
1つ目が、LiveServerTestCase の節で紹介した Selenium になります。この Selenium はウェブブラウザ操作を Python スクリプトから実行するためのライブラリであり、これを利用することで、ウェブブラウザ操作を伴うテストケースが実現できることになります。つまり、ウェブブラウザからの下記のような操作が必要となるテストも自動で実行することができるようになります。
- フォームのフィールドへの値の入力
- フォームのボタンのクリック
また、Selenium ではページが実際にウェブブラウザに表示され、さらに JavaScript も実行されることになるため、JavaScript の動作確認を含むテストも自動で実施することができるようになります。
Selenium は pip
を利用して下記のコマンドによりインストール可能です。
% python -m pip install selenium
Selenium に関しては下記ページで詳細を解説していますので、詳しく知りたい方は下記ページを参照していただければと思います。
【Python】Seleniumとは? 【Python】Seleniumの使い方【ウェブブラウザ操作の自動化】スポンサーリンク
pytest・pytest-django・pytest-html
pytest・pytest-django・pytest-html を導入することで、Django のテストを pytest から実行したり、テストレポートを HTML 形式で出力することができるようになります。
pytest-django が Django のテストを pytest から実行できるようにするためのライブラリで、pytest-html が pytest で実行して出力されるテストレポートを HTML 形式で出力するためのライブラリとなります。
pytest・pytest-django・pytest-html は、pip
を利用して下記のコマンドにより一括でインストール可能です。
% python -m pip install pytest pytest-django pytest-html
テストレポートの HTML 化
ここでは、pytest・pytest-django・pytest-html を利用したテストレポートの HTML 化の手順について説明しておきたいと思います。
pytest・pytest-django・pytest-html をインストールしておけば、テストレポートの HTML 化は、下記の手順によって実施することができます。
pytest.ini
を作成する- pytest からテストを実行する
- ローカルサーバーを起動する(必要に応じて)
最初に pytest.ini
の作成について説明します。pytest.ini
はプロジェクトフォルダ内、すなわち manage.py
と同じ階層に作成します。まずは、pytest.ini
には下記のような設定を記述しておくのでよいと思います。
[pytest]
DJANGO_SETTINGS_MODULE = プロジェクト名.settings
python_files = test*.py
DJANGO_SETTINGS_MODULE
には settings.py
をモジュールとして考えたときのモジュール名を指定します。例えばプロジェクト名が testproject
であれば、testproject.settings
を指定すればよいです。ここはプロジェクトに応じて適宜変更してください。
また、python_files
には、テストファイルとみなすファイルのファイル名のパターンを指定します。Django のテストフレームワークでは test*.py
のパターンに該当するファイル名のファイルをテストファイルとみなすようになっていますので、python_files = test*.py
を指定しておけば、 Django のテストフレームワークと同様のルールでテストファイルが探索されてテストケースが実行されるようになります。もちろん、必要に応じて python_files
に指定するパターンの変更・追加を行っても問題ありません(python_files
には半角スペース区切りで複数のパターンを指定することも可能です)。
pytest.ini
が用意できれば、後は pytest.ini
を作成したフォルダ内で下記のコマンドを実行すればテストが開始され、テスト終了時に --html
オプションで指定したパスにテストレポートが HTML 形式で作成されることになります。下記の場合は、コマンドを実行したフォルダに report.html
というファイルが作成されることになります。
% pytest --html=report.html
これが HTML 形式のテストレポートになります。ウェブブラウザで作成されたファイルを開けば、下の図のようなレポートが表示されることになります。
ただ、Windows PC を使用している場合、このテストレポートをウェブブラウザから開くとセキュリティ上の問題でエラーが発生することがあります。この場合、下の図のようにテスト結果のリストが表示されません。
このような現象が発生した場合は、ローカルの HTTP サーバーを立ててテストレポートを表示するようにする必要があります。
具体的には、まずコンソールアプリでテストレポート(上記の場合は report.html
)の出力先のフォルダに移動し、下記のコマンドを実行します。8080
は起動する HTTP サーバーで使用するポート番号になります。コマンドの実行でエラーが発生する場合は適当なポート番号に変更してください。このコマンドによって HTTP サーバーが PC 上で起動することになります。
% python -m http.server 8080
続いてウェブブラウザで下記の URL を開いてみてください。前述の通り 8080
は HTTP サーバーが使用しているポート番号になります。さらに、report.html
は pytest
コマンドの -html
オプションに指定したファイル名になります。これらは、ここまで実行してきたコマンドに応じて適切に変更してください。
http://localhosst:8080/report.html
この手順を踏んでテストレポートを表示すれば、先ほど表示されなかったテスト結果のリストが表示されるようになっているはずです。
HTTP サーバーを起動している限り、先ほど示した pytest
コマンドからテストを実行するたびに HTML が更新され、ページ表示の更新で新たなテスト結果をウェブブラウザから確認することが可能です。また、HTTP サーバーを終了させたい場合は、HTTP サーバーを起動するときに実行したコマンドに対して ctrl
+ c
を入力して強制終了させてください。
掲示板アプリのテストを実施する
最後に、ここまで解説してきた内容を踏まえ、掲示板アプリのテストを実施していきたいと思います。
この Django 入門 の連載の中では簡単な掲示板アプリを開発してきており、前回の連載(下記ページ)の 掲示板アプリで JavaScript を扱う では、掲示板アプリを JavaScript が扱えるように変更し、動的なページを実現しました。
【Django入門21】JavaScriptの扱い方今回は、前回の連載で開発したアプリに対してテストを実施していきたいと思います。
必要なテストケースを全て作成するのは大変なので、ポイントを絞ってテストケースを作成していきたいと思います。また、どんなテストを実施しているかはソースコードの処理やコメントから理解していただけると思いますので、基本的には文章でのテストケースの詳細な説明は省略させていただきます。この点はご了承ください。
また、基本的にはテスト対象となるモジュールのソースコードは紹介しませんので、テスト対象のモジュールのソースコードを確認したい場合は、今まで開発してきた掲示板アプリのソースコード or 下記のリンク先から取得したソースコードを参照していただければと思います。
掲示板アプリのプロジェクト一式の公開先
この Django 入門 の連載を通して開発している掲示板アプリのプロジェクトは GitHub の下記レポジトリで公開しています。
https://github.com/da-eu/django-introduction
また、前述のとおり、ここでは前回の連載の 掲示板アプリで JavaScript を扱う で作成したプロジェクトをベースに変更を加えていきます。このベースとなるプロジェクトは下記のリリースで公開していますので、必要に応じてこちらからプロジェクト一式を取得してください。
https://github.com/da-eu/django-introduction/releases/tag/django-javascript
さらに、ここから説明していく内容の変更を加えたプロジェクトも下記のリリースで公開しています。以降では、基本的には前回からの差分のみのコードを紹介していくことになるため、変更後のソースコードの全体を見たいという方は、下記からプロジェクト一式を取得してください。
https://github.com/da-eu/django-introduction/releases/tag/django-test
スポンサーリンク
テストファイルの作成
まずは、テストファイルを作成していきたいと思います。
今回は、forum
アプリと api
アプリの両方にテストファイルを作成していきます。まずは、下記の3つの手順を、それぞれのアプリフォルダで実施してください。__init__.py
は空のファイルで問題ありません。
tests.py
を削除するtests
フォルダを作成するtests
フォルダ内に__init__.py
を作成するtests
フォルダ内にintegration
フォルダを作成するintegration
フォルダ内に__init__.py
を作成する
以上によって、testproject
フォルダ内が下記のような構成になるはずです。
testproject/ ├─ testproject │ ├─ ~略~ │ ├─ forum │ ├─ __init__.py │ ├─ ~略~ │ ├─ tests/ │ ├─ __init__.py │ ├─ integration/ │ ├─ __init__.py │ │ ├─ api ├─ __init__.py ├─ ~略~ ├─ tests/ ├─ __init__.py ├─ integration/ ├─ __init__.py
続いて、テストフォルダ内にテストファイルを作成していきます。まずは空のテストファイルを作成し、以降で各テストファイルの実装例を示していきます。
今回は、下記のように各フォルダにテストファイルを作成したいと思います。tests/
直下に設置したファイルが「ユニットテスト」向けのテストファイルで、integration/
直下に設置したファイルが「インテグレーション」テスト向けのテストファイルになります。
forum/tests/
:test_models.py
test_forms.py
test_views.py
test_urls.py
test_mixins.py
forum/tests/integration/
:test_auth.py
test_post.py
test_ui.py
api/tests/
:test_permissions.py
api/tests/integration
:test_auth.py
test_comments.py
ここからは、各テストファイルに対するテストケースの実装例を紹介していきます。
forum/tests/test_models.py
まずは、forum/tests/
に設置した test_models.py
の実装例を示していきます。このファイルは、ファイル名からも分かるように models.py
に対するユニットテストの実装先となっており、ここでアプリ内で定義した各モデルクラスに対するテストを実施していきます。Django で実施するテスト でも示したように、ここでは下記のような観点を確認するテストを実施するのがよいと思います。
- CRUD 操作:CRUD 操作が正しく行われること
- フィールドの制約:
null=False
やunique=True
等の制約が反映されていること - 定義したメソッド:入力に応じて正しい結果が得られること
これを踏まえた test_models.py
の実装例は下記のようなものになります。下記では、CRUD 操作における Create と Read (一覧取得) に対するテストしか行っていませんが、当然 Update と Delete に対するテストも実施した方がよいです。また、下記ではモデルクラスの Comment
に対してテストを実施していますが、掲示板アプリでは CustomUser
も定義していますので、本来であれば CustomUser
に対するテストも必要となります。こんな感じで、あくまでも、ここで示すのは テストケース(テストメソッド
)の実装例であり、今後紹介する実装例でもテストケースに漏れがあるので注意してください。
from django.test import TestCase
from django.utils import timezone
from django.db.utils import IntegrityError
from forum.models import Comment, CustomUser
class CommentTest(TestCase):
@classmethod
def setUpTestData(cls):
"""各テストケースで共通で必要となるレコードの準備"""
# ユーザーの作成
cls.user = CustomUser.objects.create_user(
username='Hanako',
password='ppaassss',
age = 20,
email='hanako@example.com'
)
# コメントの作成
Comment.objects.create(
user=cls.user,
text='Hello'
)
Comment.objects.create(
user=cls.user,
text='ABCDEFGHIJKLMNOPQRSTUVWXYZ'
)
def test_create_comment_success(self):
"""各種フィールドが正常な場合にレコードの新規登録に成功することを確認する"""
"""手順の実施"""
# 時間計測のための現在時刻を取得
now = timezone.now()
# レコードの新規登録
comment = Comment()
comment.user = self.user
comment.text = 'Yes'
comment.save()
"""結果の検証"""
# 新規登録したレコードを取得
comment = Comment.objects.get(id=3)
# 各種フィールドの値が正しいことを検証
self.assertEqual(comment.user.username, 'Hanako')
self.assertEqual(comment.text, 'Yes')
# 作成日時がほぼ現在時刻であることを検証
self.assertTrue(comment.date >= now)
self.assertTrue(comment.date < now + timezone.timedelta(seconds=1))
def test_create_comment_text_null(self):
"""textフィールドが無しの場合にレコードの新規登録に失敗することを確認する"""
"""手順の実施&結果の検証"""
# textフィールドが無しのレコードを作成
comment = Comment()
comment.user = self.user
comment.text = None
# レコードの新規登録の実行&例外発生の検証
with self.assertRaises(IntegrityError):
comment.save()
def test_get_comments_success(self):
"""全てのコメントが取得できることを確認する"""
"""手順の実施"""
# 全コメントの取得
comments = Comment.objects.all()
"""結果の検証"""
# コメントの数が正しいことを検証
self.assertEqual(len(comments), 2)
def test_str_success(self):
"""__str__メソッドが正常に動作することを確認する"""
"""手順の実施"""
# __str__メソッドの実行
comment = Comment.objects.get(id=2)
result = comment.__str__()
"""結果の検証"""
# __str__から正しい文字列が返却されることを検証
self.assertEqual(result, 'ABCDEFGHIJ')
最初のテストの実装例なので、ポイントをいくつか説明しておきます。
1つ目のポイントが setUpTestData
の定義になります。TestCase
のサブクラスとして テストクラス
を定義する場合、この setUpTestData
の中でレコードの新規登録を行っておけば、その登録されたレコードが同じ テストクラス
の全ての テストメソッド
から利用することができます。テストメソッド
毎にレコードを新規登録する処理を実装する手間が省けますし、テストクラス
内で1度のみの新規登録で全 テストメソッド
からレコードが利用できるようになるため、テストの実行時間の短縮も図ることができます。
2つ目のポイントが異常系のテストになります。上記の場合、test_create_comment_text_null
が異常系のテストの テストメソッド
となります。Django で実施するテスト でも解説したように、もちろん正常系のテストも重要なのですが、バグを洗い出すという観点では異常系のテストが重要となりますので、異常系のテストも実施できるようテストファイルを実装しましょう。
3つ目のポイントがモデルクラスに追加で定義したメソッドに対するテストになります。上記の場合、test_str_success
が、Comment
に追加で定義した __str__
メソッドに対するテストになります。データベース操作に対するテストだけでなく、定義したメソッドに対するテストも忘れずに実施するようにして下さい。モデルだけでなく、他のモジュールでもクラスにメソッドを定義することは多いと思いますので、それらのメソッドに関しても忘れずテストを実施するようにしましょう。
forum/tests/test_forms.py
2番目に、forum/tests/
に設置した test_forms.py
の実装例を示していきます。このファイルは forms.py
に対するユニットテストの実装先となっており、このファイルにアプリ内で定義した各フォームクラスに対するテストのテストケースを実装していきます。Django で実施するテスト でも示したように、ここでは下記のような観点を確認するテストを実施するのがよいと思います。
- 妥当性の検証:
is_valid
メソッドから入力に応じて正しい結果が得られること - 定義したメソッド:入力に応じて正しい結果が得られること
これを踏まえた test_forms.py
の実装例は下記のようなものになります。下記では、PostForm
に対するテストのみを実施しています。
from django.test import TestCase
from forum.models import Comment, CustomUser
from forum.forms import PostForm
class PostFormTest(TestCase):
@classmethod
def setUpTestData(cls):
"""各テストケースで共通で必要となるレコードの準備"""
# ユーザーの作成
cls.user = CustomUser.objects.create_user(
username='Hanako',
password='ppaassww',
age = 20,
email='hanako@example.com'
)
# コメントの作成
Comment.objects.create(
user=cls.user,
text='Hello'
)
def test_is_valid_text_equal_max_len(self):
"""各種フィールドの値が正常な場合にis_validがTrueを返却することを確認する"""
"""手順の実施"""
# textフィールドの文字列長が256文字のフォームデータを作成
data = {
'text': 'x' * 256
}
# is_validメソッドの実行
form = PostForm(data)
result = form.is_valid()
"""結果の検証"""
# Trueが返却されることを検証
self.assertTrue(result)
def test_is_valid_text_equal_min_len(self):
"""各種フィールドの値が正常な場合にis_validがTrueを返却することを確認する"""
"""手順の実施"""
# textフィールドの文字列長が256文字のフォームデータを作成
data = {
'text': 'x'
}
# is_validメソッドの実行
form = PostForm(data)
result = form.is_valid()
"""結果の検証"""
# Trueが返却されることを検証
self.assertTrue(result)
def test_is_valid_text_greater_than_max_len(self):
"""textフィールドの文字列長が256を超える場合にis_validがFalseを返却することを確認する"""
"""手順の実施"""
# textフィールドの文字列長が257文字のフォームデータを作成
data = {
'text': 'x' * 257
}
# is_validメソッドの実行
form = PostForm(data)
result = form.is_valid()
"""結果の検証"""
# 結果がFalseであることを検証
self.assertFalse(result)
# エラーメッセージが含まれていることを検証
self.assertIn('text', form.errors)
def test_is_valid_text_less_than_min_len(self):
"""textフィールドの文字列長が1未満の場合にis_validがFalseを返却することを確認する"""
"""手順の実施"""
# textフィールドの文字列長が257文字のフォームデータを作成
data = {
'text': ''
}
# is_validメソッドの実行
form = PostForm(data)
result = form.is_valid()
"""結果の検証"""
# 結果がFalseであることを検証
self.assertFalse(result)
# エラーメッセージが含まれていることを検証
self.assertIn('text', form.errors)
上記における一番のポイントは境界値テストを実施しているという点になります。境界値テストとは、正常とみなす閾値と異常とみなす閾値の境界に対するテストになります。PostForm
では、text
フィールドの最大長を 256
と定義しています(Comment
の text
から引き継がれる)。これに対する境界値テストととして、下記の2つの テストメソッド
を実装しています。
test_is_valid_text_equal_max_len
:text
フィールドの文字列長が256
の場合のテストtest_is_valid_text_greater_than_max_len
:text
フィールドの文字列長が257
の場合のテスト
このような境界値テストを実施することで、仕様として定義された「閾値」が機能しているかどうかを確認することが可能です。また、何らかのタイミングで閾値が変更された場合に、それをテストで検知することが可能となります。
同様に、文字列を扱うフィールドの最小長はデフォルトで 1
に設定されるため、これらの境界に対するテストも上記の test_forms.py
では実施するようにしています。
この境界値テストはバグを洗い出すために利用されることの多いテスト手法となりますので、境界値テストについては理解しておき、積極的にテストを実施するようにしましょう。
スポンサーリンク
forum/tests/test_views.py
3番目に、forum/tests/
に設置した test_views.py
の実装例を示していきます。このファイルは views.py
に対するユニットテストの実装先となっており、このファイルにアプリ内で定義したビューのテストのテストケースを実装していきます。Django で実施するテスト でも示したように、ここでは下記のような観点を確認するテストを実施するのがよいと思います。
- 定義したメソッド:入力に応じて正しい結果が得られること
ビューに対するユニットテストは結構ややこしくて、その理由の1つとしてインテグレーションテストとの切り分けが難しいという点が挙げられます。例えば、ステータスコードやビューから利用したテンプレートファイル等を検証するテストも必要となるのですが、ビューはモデルクラスやフォームクラス、さらにはテンプレート等も利用して動作することになりますので、本当の意味での「ユニットテスト」を実施するのが困難で、どちらかというと「インテグレーションテスト」でテストを行うことが多いと思います。
もちろん、モックの仕組みを利用してモデルクラスやフォームクラス等からビューを分離させてユニットテストを行うことも可能なので、できればユニットテストもしっかり実施した方が良いのですが、どこまでやるかはプロジェクトの方針に応じて変化することになると思います。
今回に関しては、ビューのユニットテストでは、各ビューで定義したメソッドに対するテストのみを実施するようにしたいと思います。
test_views.py
の実装例は下記のようなものになります。メソッドを定義しているビューが Post
・Login
・UserDetail
であるため、これらのメソッドに対するテストを実施しています。
from unittest.mock import MagicMock, patch
from django.test import TestCase, RequestFactory
from django.core.paginator import EmptyPage
from forum.models import CustomUser
from forum.views import Post, Login, UserDetail
from forum.forms import PostForm
class PostTest(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = CustomUser.objects.create_user(
username='Hanako',
password='ppaassww',
age = 20,
email='hanako@example.com'
)
def setUp(self):
self.factory = RequestFactory()
def test_form_valid_success(self):
"""form_validでリクエスト送信者がコメントの投稿者に設定されることを確認"""
"""前提条件"""
# 事前にフォームのis_validを成功させておく
data = {
'text': 'Yes'
}
form = PostForm(data)
form.is_valid()
"""手順の実施"""
view = Post()
# リクエストを生成
request = self.factory.post('/forum/post/', data)
request.user = self.user
view.request = request
# スーパークラスのform_validにモックを仕掛けた状態でform_validを実行
with patch('django.views.generic.CreateView.form_valid') as mock:
mock.return_value = None
view.form_valid(form)
"""結果の検証"""
# リクエスト送信者がコメントの投稿者に設定されていることを検証
self.assertIs(form.instance.user, self.user)
class LoginTest(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = CustomUser.objects.create_user(
username='Hanako',
password='ppaassww',
age = 20,
email='hanako@example.com'
)
def setUp(self):
self.factory = RequestFactory()
def test_get_success_url_success(self):
"""get_success_urlでリクエスト送信者のIDがリダイレクト先のURLにセットされることを確認"""
"""手順の実施"""
view = Login()
# リクエストを生成
request = self.factory.post('/forum/login/')
request.user = self.user # ID1のユーザー
view.request = request
url = view.get_success_url()
"""結果の検証"""
# URLが正しく生成されていることを検証
self.assertEqual(url, '/forum/user/1')
class UserDetailTest(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = CustomUser.objects.create_user(
username='Hanako',
password='ppaassww',
age = 20,
email='hanako@example.com'
)
def setUp(self):
self.factory = RequestFactory()
def test_get_context_data_page_1(self):
"""get_context_dataでページネーション結果のページ1がコンテキストにセットされることを確認"""
"""手順の実施"""
view = UserDetail()
view.object = self.user
# リクエストを生成
request = self.factory.get('/forum/comments/?p=1')
request.user = self.user
view.request = request
# 5件分のレコードのモックを返却するモックのメソッドを作成
mock_comments = [MagicMock() for _ in range(5)]
mock_queryset = MagicMock()
mock_queryset.order_by.return_value = mock_comments
# filterメソッドにモックを仕掛けた状態でget_context_dataを実行
with patch('forum.models.Comment.objects.filter') as mock:
mock.return_value = mock_queryset
context = view.get_context_data()
"""結果の検証"""
# contextにページネーション結果がセットされていることを検証
self.assertIn('page_obj', context)
self.assertEqual(context['page_obj'].object_list, mock_comments[:3])
def test_get_context_data_page_2(self):
"""get_context_dataでページネーション結果のページ2がコンテキストにセットされることを確認"""
"""手順の実施"""
view = UserDetail()
view.object = self.user
# リクエストを生成
request = self.factory.get('/forum/comments/?p=2')
request.user = self.user
view.request = request
# 5件分のレコードのモックを返却するモックのメソッドを作成
mock_comments = [MagicMock() for _ in range(5)]
mock_queryset = MagicMock()
mock_queryset.order_by.return_value = mock_comments
# filterメソッドにモックを仕掛けた状態でget_context_dataを実行
with patch('forum.models.Comment.objects.filter') as mock:
mock.return_value = mock_queryset
context = view.get_context_data()
"""結果の検証"""
# contextにページネーション結果がセットされていることを検証
self.assertIn('page_obj', context)
self.assertEqual(context['page_obj'].object_list, mock_comments[3:])
def test_get_context_data_not_found(self):
"""get_context_dataでページネーション結果が存在しない場合に例外が発生することを確認"""
"""手順の実施&結果の検証"""
view = UserDetail()
view.object = self.user
# リクエストを生成
request = self.factory.get('/forum/comments/?p=3')
request.user = self.user
view.request = request
# 5件分のレコードのモックを返却するモックのメソッドを作成
mock_comments = [MagicMock() for _ in range(5)]
mock_queryset = MagicMock()
mock_queryset.order_by.return_value = mock_comments
# filterメソッドにモックを仕掛けた状態でget_context_dataを実行
with patch('forum.models.Comment.objects.filter') as mock:
mock.return_value = mock_queryset
# ページネーション結果が存在しない場合にEmptyPageが発生することを検証
with self.assertRaises(EmptyPage):
view.get_context_data()
ポイントは2点です。1点目はリクエストの生成になります。クラスベースビューにおけるメソッドや関数ベースビューでは引数にリクエストの指定が必要となるものが多いです。このリクエストは、RequestFactory で紹介した RequestFactory
で簡単に生成することができるので、この RequestFactory
を積極的に利用しましょう、というのがポイントの1点目になります。
2点目はモックの利用です。モックとは「特定の関数やデータ等を模倣する仮のオブジェクト」のことです。例えば、好きな返却値を返却するモックを作成し、そのモックで本物の関数と差し替えるようなことができます。モックに関しては下記ページで詳細を解説していますので、詳しく知りたい方は下記ページを参照してください。
【Python/unittest】mockの基本的な使い方例えば、views.py
で定義されている UserDetail
の get_context_data
メソッドの定義は下記のようになっています。ご覧の通り、get_context_data
内では Comment.objects.filter
が実行されているため、このメソッドはモデルに依存していることになります。それに対し、本来ユニットテストはモジュール単体(or 関数 / メソッド単体)に対して実施するテストなので、このメソッドをそのまま実行するテストは、本来のユニットテストの定義には当てはまらないことになります。
class UserDetail(LoginRequiredMixin, DetailView):
# 略
def get_context_data(self, **kwargs):
comments = Comment.objects.filter(user=self.object).order_by('date')
paginator = Paginator(comments, 3)
number = int(self.request.GET.get('p', 1))
page_obj = paginator.page(number)
return super().get_context_data(user=self.object, page_obj=page_obj)
ですが、Comment.objects.filter
をモックに差し替えてやれば、Comment.objects.filter
が呼び出されないようになってモデルへの依存を排除し、本来の意味でのユニットテストを実施することができるようになります。 そのため、UserDetailTest
の各 テストメソッド
においては、Comment.objects.filter
を「クエリーセットもどきのモック」を返却するモックに差し替えるようにしています。
また、本物の Comment.objects.filter
が利用される場合は、テストケースによっては事前に Comment
のテーブルにレコードを登録しておく必要があるのですが、モックに差し替えた場合はモデルが動作しないため(データベース操作が行われない)、この事前準備も不要となります。
このように、モックを利用することでユニットテストが実施しやすくなりますし、さらにデータベースや引数に指定するデータ等の事前準備も省略することができるようになります。
今回紹介しているテストケースの実装例においてはモックの利用が不十分なところもあるのですが、まずは上記の例で、モックを利用することでユニットテストが実現できるようになることや、効率的にテストが実装できるようになることを理解していただければと思います。
forum/tests/test_urls.py
4番目に、forum/tests/
に設置した test_urls.py
の実装例を示していきます。
このファイルは urls.py
に対するユニットテストの実装先となっています。Django で実施するテスト でも示したように、このファイルでは下記のような観点を確認するテストのテストケースを実装していきます。
- マッピング:URL にマッピングされているビューのクラス(関数)が正しいこと
- 逆引き:名前から逆引きされる URL が正しいこと
test_urls.py
の実装例は下記のようなものになります。
from django.test import TestCase
from django.urls import resolve, reverse
from forum import views
class UrlsTest(TestCase):
def test_url_forum_login_resolve_to_view(self):
"""/forum/login/のURLがLoginにマッピングされていることを確認する"""
"""手順の実施"""
# URLを解決
found = resolve('/forum/login/')
"""結果の検証"""
# マッピングされているビューがLoginであることを検証
self.assertEqual(found.func.view_class, views.Login)
def test_name_login_reverse_to_url(self):
"""loginという名前から/forum/login/のURLが逆引きされることを確認する"""
"""手順の実施"""
# URLを逆引き
url = reverse('login')
"""結果の検証"""
# loginという名前から/forum/login/が逆引きされることを検証
self.assertEqual(url, '/forum/login/')
def test_url_forum_register_resolve_to_view(self):
"""/forum/register/のURLがRegisterにマッピングされていることを確認する"""
"""手順の実施"""
# URLを解決
found = resolve('/forum/register/')
"""結果の検証"""
# マッピングされているビューがRegisterであることを検証
self.assertEqual(found.func.view_class, views.Register)
def test_name_register_reverse_to_url(self):
"""registerという名前から/forum/register/のURLが逆引きされることを確認する"""
"""手順の実施"""
# URLを逆引き
url = reverse('register')
"""結果の検証"""
# registerという名前から/forum/register/が逆引きされることを検証
self.assertEqual(url, '/forum/register/')
上記のコードからも分かるように、resolve
の返却値より「URL にマッピングされたビュー」を、reverse
の返却値より「名前からの逆引き結果となる URL」をそれぞれ取得することができ、これらを利用することで、urls.py
に対するユニットテストが実施できるようになります。
forum/tests/test_mixins.py
5番目に、forum/tests/
に設置した test_mixins.py
の実装例を示していきます。基本的に、アプリは models.py
・forms.py
・views.py
・urls.py
から構成されることになるのですが、それ以外のモジュールを追加で作成することも多いです。その場合は、追加したモジュールに対してもテストを実施する必要があります。
その一例として、今回は mixins.py
に対するユニットテストの実装例を示したいと思います。Django のモジュールではクラスを定義することが多いので、基本的には下記の観点でのテストを実施することになることになると思いますが、必要に応じて他のテストも適切に追加するようにしてください。
- 定義したメソッド:入力に応じて正しい結果が得られること
test_mixins.py
の実装例は下記のようなものになります。
from unittest.mock import MagicMock, patch
from django.test import TestCase, RequestFactory
from forum.models import Comment, CustomUser
from forum.mixins import AuthorRequiredMixin
class AuthorRequiredMixinTest(TestCase):
@classmethod
def setUpTestData(cls):
"""各テストケースで共通で必要となるレコードの準備"""
# ユーザーの作成
cls.user1 = CustomUser.objects.create_user(
username='Hanako',
password='ppaassww',
age = 20,
email='hanako@example.com'
)
cls.user2 = CustomUser.objects.create_user(
username='Taro',
password='ppaassww',
age = 20,
email='taro@example.com'
)
# user1によるコメントの作成
cls.comment = Comment.objects.create(
user=cls.user1,
text='Hello'
)
def setUp(self):
self.factory = RequestFactory()
def test_test_func_true(self):
"""操作対象のレコードの作成者とリクエストユーザーが一致する場合にTrueを返却することを確認する"""
"""手順の実施"""
mixin = AuthorRequiredMixin()
# リクエストユーザーをuser1に設定
mixin.request = self.factory.delete('/forum/comment/1')
mixin.request.user = self.user1
# get_objectをモックに差し替えてtest_funcメソッドを実行
mock = MagicMock(return_value=self.comment)
mixin.get_object = mock
result = mixin.test_func()
"""結果の検証"""
# Trueが返却されることを検証
self.assertTrue(result)
def test_test_func_false(self):
"""操作対象のレコードの作成者とリクエストユーザーが一致しない場合にFalseを返却することを確認する"""
"""手順の実施"""
mixin = AuthorRequiredMixin()
# リクエストユーザーをuser2に設定
mixin.request = self.factory.delete('/forum/comment/1')
mixin.request.user = self.user2
# get_objectをモックに差し替えてtest_funcメソッドを実行
mock = MagicMock(return_value=self.comment)
mixin.get_object = mock
result = mixin.test_func()
"""結果の検証"""
# Falseが返却されることを検証
self.assertFalse(result)
基本的には、test_views.py
同様に、RequestFactory
やモックを利用するという点がポイントになります。
特に、テスト対象の test_func
メソッド内で実行される get_object
メソッドが AuthorRequiredMixin
には定義されていないという点に注意してください。この get_object
メソッドは、AuthorRequiredMixin
と一緒にビューに継承させる Viewのサブクラス
側で定義されているメソッドになります。
そのため、test_func
メソッドに対するユニットテストを実施するためには get_object
をモックにしておくことが必要となります。また、この get_object
は AuthorRequiredMixin
には定義されていないため、test_views.py
の時に利用した patch
関数によるメソッドのモックへの差し替えではなく、上記のように MagicMock
のインスタンス(モック)を作成し、それを AuthorRequiredMixin
のインスタンスの get_object
に参照させるような処理が必要となります。
スポンサーリンク
forum/tests/integration/test_auth.py
次は、forum
アプリに対するインテグレーションテストの実装例を紹介していきます。まず、test_auth.py
は、認証関連のインテグレーションテストを実装するテストファイルとなっています。
掲示板アプリはログイン機能を備えており、非ログインユーザーからのアクセスが制限されるようになっています。そのため、そのアクセス制限が正常に機能しているかどうか、すなわち、「ログインしないとアクセス不可のページ」がログインユーザーからはアクセスできるけれども、非ログインユーザーからはアクセスできないということをテストしておく必要があります。
このようなテストの実装例は、下記の test_auth.py
のようになります。下記は全て /forum/comments/
のページに対してアクセス可 or アクセス不可を確認するためのテストケースになっていますが、他のページに対しても同様のテストが必要となります。また、今回は実装例は示しませんが、ログインやログアウト自体に関しても、このファイル or 他のテストファイルにインテグレーションテストに実装が必要となります。
from django.test import TestCase, Client
from forum.models import Comment, CustomUser
class AuthIntegrationTest(TestCase):
@classmethod
def setUpClass(cls):
"""各テストケースで共通で必要となる変数の準備"""
super().setUpClass()
cls.login_url = '/forum/login/'
cls.user_credentials = {
'username': 'Hanako',
'password': 'ppaassss'
}
@classmethod
def setUpTestData(cls):
"""各テストケースで共通で必要となるデータの準備"""
# ユーザーの作成
cls.user = CustomUser.objects.create_user(
username='Hanako',
password='ppaassss',
age = 20,
email='hanako@example.com'
)
# コメントの作成
Comment.objects.create(
user=cls.user,
text='Hello'
)
def test_forum_comments_access_ok(self):
"""ログインしている場合に/forum/comments/に正常にアクセスできることを確認する"""
"""前提条件"""
# ログイン実施
self.client.login(**self.user_credentials)
"""手順を実施"""
# /forum/comments/にアクセス
response = self.client.get('/forum/comments/')
"""結果の検証"""
# リダイレクトされていないことを検証
self.assertEqual(response.status_code, 200)
# ユーザーがログイン中状態になっていることを検証
self.assertTrue(response.context['user'].is_authenticated)
# コメント一覧ページに表示されるべき情報がボディに含まれていることを検証
self.assertContains(response, 'Hanako')
self.assertContains(response, 'Hello')
def test_redirect_if_not_logged_in(self):
"""ログインしていない場合に/forum/comments/にアクセス不可であることを確認する"""
"""手順を実施"""
# /forum/comments/にアクセス
response = self.client.get('/forum/comments/')
"""結果の検証"""
# ログインページにリダイレクトされていることを検証
self.assertRedirects(response, '/forum/login/?next=/forum/comments/')
def test_redirect_if_logged_out(self):
"""ログアウト後に/forum/comments/にアクセス不可であることを確認する"""
"""前提条件"""
# ログイン実施
self.client.login(**self.user_credentials)
# /forum/comments/にアクセス
self.client.get('/forum/comments/')
"""手順を実施"""
# ログアウト実施
self.client.logout()
# /forum/comments/にアクセス
response = self.client.get('/forum/comments/')
"""結果の検証"""
# ログインページにリダイレクトされていることを検証
self.assertRedirects(response, '/forum/login/?next=/forum/comments/')
ポイントは self.client
を利用している点になります。テストクラス
では、テストケース開始時に毎回 Client
クラスのインスタンスが生成され、そのインスタンスを self.client
が参照するようになっています。なので、Client
のインスタンスを生成するような処理を実装することなく、self.client
から Client
のインスタンスを利用することができます。
また、Client
には HTTP リクエストのシミュレートを行うメソッドが用意されており、それを実行することで、実際に HTTP リクエストを送信した時と同様のテストを実施することができます。さらに、login
メソッドや logout
メソッドの実行で簡単にログイン・ログアウトを実施することもできるようになっているため、ログインが必要となるテストも、これらを利用すれば簡単に実施することができます。
HTTP リクエストのシミュレートによって、ビュー・モデル・テンプレート等の様々なモジュールが連携して動作することになるため、複数のモジュールを結合した状態でテストを実施するインテグレーションテストに向いています。
また、Client
のインスタンスはテストケース開始時に毎回新たに生成されることになるため、Client
のインスタンスの状態はテストケース間で独立することになります。つまり、前のテストケースで login
メソッドを実行してログイン中の状態になったとしても、次のテストケースでは元の状態、すなわち非ログイン中の状態に戻るようになっています。そのため、テスト終了時に毎回 Client
のインスタンスの状態を元に戻すような処理は不要です。
forum/tests/integration/test_post.py
続いて、test_post.py
の実装例を紹介していきます。このファイルは、コメント投稿に関するインテグレーションテストを実装するテストファイルとなっています。
掲示板アプリでは、コメント投稿フォームでコメントの投稿が実施できるようになっています。この機能に対するインテグレーションテストとしては、フォームが表示されること(GET
の HTTP リクエスト送信時)、フォームの送信でコメントのレコードが新規登録されること(POST
の HTTP リクエスト送信時)、さらには、フォームから送信される各種フィールドの値が不正な場合にレコードの新規登録に失敗することを確認するようなテストケースが必要となります。
test_post.py
の実装例は下記のようなものになります。この例は、あくまでもコメント投稿機能に関するテストとなりますが、他にもユーザー登録機能であったり、コメントの一覧表示・ユーザーの詳細表示など、アプリが備えている全機能に対して同様にテストを実施する必要があります。
from django.test import TestCase
from forum.models import Comment, CustomUser
from forum.forms import PostForm
class PostIntegrationTest(TestCase):
@classmethod
def setUpClass(cls):
"""各テストケースで共通で必要となる変数の準備"""
super().setUpClass()
cls.login_url = '/forum/login/'
cls.user_credentials = {
'username': 'Hanako',
'password': 'ppaassss'
}
@classmethod
def setUpTestData(cls):
"""各テストケースで共通で必要となるデータの準備"""
# ユーザーの作成
cls.user = CustomUser.objects.create_user(
username='Hanako',
password='ppaassss',
age = 20,
email='hanako@example.com'
)
# コメントの作成
Comment.objects.create(
user=cls.user,
text='Hello'
)
def test_get_create_comment_ok(self):
"""GET /forum/post/のリクエスト送信でコメント新規登録ページが表示されることを確認する"""
"""前提条件"""
# ログイン実施
self.client.login(**self.user_credentials)
"""手順を実施"""
# GET /forum/post/を送信
response = self.client.get('/forum/post/')
"""結果の検証"""
# コメント新規登録ページが表示されることを検証
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'forum/post.html')
self.assertIsInstance(response.context['form'], PostForm)
def test_post_create_comment_success(self):
"""POST /forum/post/のリクエスト送信でコメントが新規登録されることを確認する"""
"""前提条件"""
# ログイン実施
self.client.login(**self.user_credentials)
"""手順を実施"""
data = {
'text': 'Yes'
}
# POST /forum/post/を送信
response = self.client.post('/forum/post/', data=data)
"""結果の検証"""
# リダイレクトされることを検証
self.assertEqual(response.status_code, 302)
self.assertRedirects(response, '/forum/comments/')
# コメントが新規登録されていることを検証
comment = Comment.objects.last()
self.assertEqual(comment.id, 2)
self.assertEqual(comment.text, 'Yes')
self.assertEqual(comment.user, self.user)
def test_post_create_comment_text_greater_than_max_len(self):
"""textの文字列長が257以上のコメントの新規登録に失敗することを確認する"""
"""前提条件"""
# ログイン実施
self.client.login(**self.user_credentials)
"""手順を実施"""
data = {
'text': 'A' * 257
}
# POST /forum/post/を送信
response = self.client.post('/forum/post/', data=data)
"""結果の検証"""
# 再度コメント新規登録ページが表示されることを検証
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'forum/post.html')
form = response.context['form']
self.assertIsInstance(form, PostForm)
self.assertFormError(form, 'text', 'この値は 256 文字以下でなければなりません( 257 文字になっています)。') # textフィールドにエラーがあることを検証
self.assertEqual(form.data['text'], 'A' * 257) # 入力値が保持されていることを検証
# コメントが新規登録されていないことを検証する
comment = Comment.objects.all()
self.assertEqual(len(comment), 1)
def test_post_create_comment_text_less_than_min_len(self):
"""textの文字列長が1未満のコメントの新規登録に失敗することを確認する"""
"""前提条件"""
# ログイン実施
self.client.login(**self.user_credentials)
"""手順を実施"""
data = {
'text': ''
}
# POST /forum/post/を送信
response = self.client.post('/forum/post/', data=data)
"""結果の検証"""
# 再度コメント新規登録ページが表示されることを検証
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'forum/post.html')
form = response.context['form']
self.assertIsInstance(form, PostForm)
self.assertFormError(form, 'text', 'この項目は必須です。') # textフィールドにエラーがあることを検証
self.assertEqual(form.data['text'], '') # 入力値が保持されていることを検証
# コメントが新規登録されていないことを検証する
comment = Comment.objects.all()
self.assertEqual(len(comment), 1)
def test_post_create_comment_no_text(self):
"""textの文字列長が257以上のコメントの新規登録に失敗することを確認する"""
"""前提条件"""
# ログイン実施
self.client.login(**self.user_credentials)
"""手順を実施"""
data = {}
# POST /forum/post/を送信
response = self.client.post('/forum/post/', data=data)
"""結果の検証"""
# 再度コメント新規登録ページが表示されることを検証
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'forum/post.html')
form = response.context['form']
self.assertIsInstance(form, PostForm)
self.assertFormError(form, 'text', 'この項目は必須です。') # textフィールドにエラーがあることを検証
# コメントが新規登録されていないことを検証する
comment = Comment.objects.all()
self.assertEqual(len(comment), 1)
この test_post.py
に関しては特にポイントはありません。当然、こういった機能に対するインテグレーションテストも必要となりますので、その一例を示すために実装例を紹介しました。
forum/tests/integration/test_ui.py
forum
アプリの最後のテストファイルとして、test_ui.py
の実装例を紹介していきます。このファイルは、UI に対するテストファイルとなっています。
LiveServerTestCase でも解説したように、LiveServerTestCase
や StaticLiveServerTestCase
のサブクラスとして テストクラス
を定義することで、テスト実施時に HTTP サーバーが起動するようになり、シミュレートではなく実際の HTTP リクエストの送信によってテストを実施することができるようになります。さらに、Selenium を利用することで、Python スクリプトからのウェブブラウザの自動操作が可能となります。
そのため、手順にウェブブラウザ操作が必要となるようなテストケース、例えば「フォームのボタンのクリック」や「フォームのフィールドへの値の入力」が必要となるようなテストケースも テストメソッド
として実装可能となり、こういったテストも Django のテストフレームワークから自動で実施することが可能となります。
また、Selenium を利用することで実際にウェブブラウザが起動するようになるため、ウェブブラウザで実行される JavaScript の動作確認を含めたテストも実施可能となります。
こういった、UI や JavaScript に対するテストを実施する test_ui.py
の実装例は下記のようなものになります。LoginUITest
では、ログインフォームに対する UI 操作、PostUITest
では投稿フォームに対する UI 操作、CommentsUITest
では JavaScript による動的更新に対するテストケースを実装するようにしています。
from django.contrib.staticfiles.testing import StaticLiveServerTestCase
from rest_framework.authtoken.models import Token
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from forum.models import Comment, CustomUser
import time
class LoginUITest(StaticLiveServerTestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
# ドライバーを生成(Chromeが起動)
cls.driver = webdriver.Chrome()
@classmethod
def tearDownClass(cls):
# ドライバーを終了(Chromeが終了)
cls.driver.quit()
super().tearDownClass()
def setUp(self):
"""各テストケースで毎回必要になる準備"""
# ユーザーの作成
self.user = CustomUser.objects.create_user(
username='Hanako',
password='ppaassss',
age = 20,
email='hanako@example.com'
)
def test_check_login_form_send_button(self):
"""ログインフォームから各種フィールドの値が送信されることを確認する"""
"""手順の実施"""
# ログインページの表示
url = self.live_server_url + '/forum/login/'
self.driver.get(url)
# ユーザー名とパスワードを入力
username_field = WebDriverWait(self.driver, 10).until(
EC.presence_of_element_located((By.ID, 'id_username'))
)
username_field.send_keys('Hanako')
password_field = WebDriverWait(self.driver, 10).until(
EC.presence_of_element_located((By.ID, 'id_password'))
)
password_field.send_keys('ppaassss')
# ログインボタンをクリック
login_button = WebDriverWait(self.driver, 10).until(
EC.element_to_be_clickable((By.XPATH, '//input[@type="submit"]'))
)
login_button.click()
time.sleep(2)
"""結果の検証"""
# 詳細情報ページにリダイレクトされることの検証
self.assertEqual(self.driver.current_url, self.live_server_url + f'/forum/user/{self.user.id}')
# ウェブブラウザに正しいトークンが保存されていることを確認
saved_token = self.driver.execute_script("return window.localStorage.getItem('access_token');") # JavaScript実行
answer_token = Token.objects.get(user=self.user).key
self.assertEqual(saved_token, answer_token)
class PostUITest(StaticLiveServerTestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
# ドライバーを生成(Chromeが起動)
cls.driver = webdriver.Chrome()
@classmethod
def tearDownClass(cls):
# ドライバーを終了(Chromeが終了)
cls.driver.quit()
super().tearDownClass()
def setUp(self):
"""各テストケースで毎回必要になる準備"""
# ユーザーの作成
self.user = CustomUser.objects.create_user(
username='Hanako',
password='ppaassss',
age = 20,
email='hanako@example.com'
)
# ログインページの表示
url = self.live_server_url + '/forum/login/'
self.driver.get(url)
# ユーザー名とパスワードを入力
username_field = WebDriverWait(self.driver, 10).until(
EC.presence_of_element_located((By.ID, 'id_username'))
)
username_field.send_keys('Hanako')
password_field = WebDriverWait(self.driver, 10).until(
EC.presence_of_element_located((By.ID, 'id_password'))
)
password_field.send_keys('ppaassss')
# ログインボタンをクリック
login_button = WebDriverWait(self.driver, 10).until(
EC.element_to_be_clickable((By.XPATH, '//input[@type="submit"]'))
)
login_button.click()
# リダイレクトされるまで待つ
WebDriverWait(self.driver, 10).until(
EC.text_to_be_present_in_element(
(By.TAG_NAME, 'h2'), 'Hanakoの情報'
)
)
def test_check_post_form_send_button(self):
"""投稿フォームから各種フィールドの値が送信されることを確認する"""
"""手順の実施"""
# コメント投稿ページを表示
url = self.live_server_url + '/forum/post/'
self.driver.get(url)
# textフィールドを取得(表示されるまで待機)
text_field = WebDriverWait(self.driver, 10).until(
EC.presence_of_element_located((By.NAME, 'text'))
)
# textフィールドに文字列を入力
text_field.send_keys('Yes!')
# 送信ボタンを取得(クリックできるようになるまで待機)
send_button = WebDriverWait(self.driver, 10).until(
EC.element_to_be_clickable((By.XPATH, '//input[@value="送信"]'))
)
# send_buttonをクリック
send_button.click()
# コメント一覧が表示されるまで待つ(コメント総数の要素が表示されるまで)
WebDriverWait(self.driver, 10).until(
EC.presence_of_element_located((By.ID, 'comment-count'))
)
"""結果の検証"""
# ボタンクリックによってコメントが追加されていることを検証
comment = Comment.objects.all().last()
self.assertEqual(comment.user.username, 'Hanako')
self.assertEqual(comment.text, 'Yes!')
class CommentsUITest(StaticLiveServerTestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
# ドライバーを生成(Chromeが起動)
cls.driver = webdriver.Chrome()
@classmethod
def tearDownClass(cls):
# ドライバーを終了(Chromeが終了)
cls.driver.quit()
super().tearDownClass()
def setUp(self):
"""各テストケースで毎回必要になる準備"""
# ユーザーの作成
self.user = CustomUser.objects.create_user(
username='Hanako',
password='ppaassss',
age = 20,
email='hanako@example.com'
)
# ログインページの表示
url = self.live_server_url + '/forum/login/'
self.driver.get(url)
# ユーザー名とパスワードを入力
username_field = WebDriverWait(self.driver, 10).until(
EC.presence_of_element_located((By.ID, 'id_username'))
)
username_field.send_keys('Hanako')
password_field = WebDriverWait(self.driver, 10).until(
EC.presence_of_element_located((By.ID, 'id_password'))
)
password_field.send_keys('ppaassss')
# ログインボタンをクリック
login_button = WebDriverWait(self.driver, 10).until(
EC.element_to_be_clickable((By.XPATH, '//input[@type="submit"]'))
)
login_button.click()
# リダイレクトされるまで待つ
WebDriverWait(self.driver, 10).until(
EC.text_to_be_present_in_element(
(By.TAG_NAME, 'h2'), 'Hanakoの情報'
)
)
def test_check_update_comment_count(self):
"""コメント一覧ページのコメント総数が動的更新されることを確認する"""
"""手順の実施"""
# フォームを表示するURLを設定
url = self.live_server_url + '/forum/comments/'
# ウェブブラウザでurlを開く
self.driver.get(url)
# コメント総数が表示されるまで待機
comment_count_elem = WebDriverWait(self.driver, 10).until(
EC.presence_of_element_located((By.ID, 'comment-count'))
)
# ページに表示されているコメント総数を取得
before_comment_count = int(comment_count_elem.text)
# コメントを追加
Comment.objects.create(user=self.user, text='Yes!')
time.sleep(2)
# ページに表示されているコメント総数を取得
after_comment_count = int(comment_count_elem.text)
"""結果の検証"""
# コメントの追加に伴って表示されているコメント総数が動的更新されることを検証
self.assertEqual(before_comment_count + 1, after_comment_count)
少しコードが難しいかもしれませんが、まずは StaticLiveServerTestCase
(or LiveServerTestCase
) と Selenium を組み合わせて利用することでブラウザ上での UI 操作に対するテストや JavaScript に対するテストも自動化することができることは覚えておいてください。
もちろん、こういったテストは手動や目視で実施することもできますが、自動化することで繰り返しテストを行う負担が減り、それがウェブアプリの品質向上に繋がります。
スポンサーリンク
api/tests/test_permissions.py
ここからは、Web API の開発先である api
アプリ側のテストの実装例を示していきます。Web API といっても、基本的なテストの観点や実装方法は forum
アプリ等の通常のウェブアプリと同様となるので、ここからは説明を手短にして解説を進めていきます。
まずは、test_permissions.py
の実装例を示していきます。このファイルでは、api/permissions.py
に定義した IsAuthorOrReadOnly
に対するユニットテストを実装していきます。api
アプリ側に関しては、ユニットテストの例として IsAuthorOrReadOnly
に対するテストのみを実装していきますが、本来であれば独自に定義したクラス全てに対してユニットテストを実施した方が良いです。
また、特にユニットテストはモジュール単体に対するテストなので、Web API であるか通常のウェブアプリであるかは意識せずに実装して問題ありません。
test_permissions.py
の実装例は下記のようなものになります。
from django.test import TestCase, RequestFactory
from rest_framework.permissions import BasePermission
from rest_framework.permissions import SAFE_METHODS
from api.permissions import IsAuthorOrReadOnly
from forum.models import Comment, CustomUser
class IsAuthorOrReadOnlyTest(TestCase):
@classmethod
def setUpTestData(cls):
"""各テストケースで共通で必要となるレコードの準備"""
# ユーザーの作成
cls.user1 = CustomUser.objects.create_user(
username='Hanako',
password='ppaassww',
age = 20,
)
cls.user2 = CustomUser.objects.create_user(
username='Taro',
password='ppaassww',
age = 20,
)
# コメントの作成
cls.comment = Comment.objects.create(
user=cls.user1,
text='Hello'
)
def setUp(self):
"""各テストケースで毎回必要となる準備を実施"""
self.factory = RequestFactory()
def test_has_object_permission_true(self):
"""操作対象のレコードの作成者とリクエストユーザーが一致する場合にTrueを返却することを確認する"""
"""手順の実施"""
# リクエストユーザーをuser1に設定
request = self.factory.patch('/api/comments/1')
request.user = self.user1
# has_object_permissionメソッドの実行
permission = IsAuthorOrReadOnly()
result = permission.has_object_permission(request, None, self.comment)
"""結果の検証"""
# Trueが返却されることを検証
self.assertTrue(result)
def test_has_object_permission_false(self):
"""操作対象のレコードの作成者とリクエストユーザーが一致しない場合にFalseを返却することを確認する"""
"""手順の実施"""
# リクエストユーザーをuser2に設定
request = self.factory.patch('/api/comments/1')
request.user = self.user2
# has_object_permissionメソッドの実行
permission = IsAuthorOrReadOnly()
result = permission.has_object_permission(request, None, self.comment)
"""結果の検証"""
# Falseが返却されることを検証
self.assertFalse(result)
def test_has_object_permission_read_only(self):
"""リクエストメソッドがGETの場合にTrueを返却することを確認する"""
"""手順の実施"""
# リクエストユーザーをuser2に設定
request = self.factory.get('/api/comments/1')
request.user = self.user2
# has_object_permissionメソッドの実行
permission = IsAuthorOrReadOnly()
result = permission.has_object_permission(request, None, self.comment)
"""結果の検証"""
# Trueが返却されることを検証
self.assertTrue(result)
api/tests/integration/test_auth.py
続いて api
アプリ側の test_auth.py
の実装例を示していきます。このファイルでは、Web API に対して認証に関するインテグレーションテスト、特に「各種 Web API で実施しているトークン認証が正常に機能していること」を確認するインテグレーションテストを実装していきます。この掲示板アプリでは、forum
アプリと api
アプリとで異なる認証方式を採用しているため、forum
アプリだけでなく api
アプリに対しても認証に関するインテグレーションテストを実施する必要があります。それを api/tests/integration/test_auth.py
に実装していきます。
そのインテグレーションテストの実装例は下記のようなものとなります。トークン認証に成功した場合や失敗した場合のウェブアプリの動作を確認するテストと実施しています。
from django.test import TestCase
from rest_framework.test import APIClient
from rest_framework.authtoken.models import Token
from forum.models import Comment, CustomUser
class AuthIntegrationTest(TestCase):
@classmethod
def setUpClass(cls):
"""各テストケースで共通で必要となる準備"""
super().setUpClass()
cls.login_url = '/api/token/'
cls.user_credentials = {
'username': 'Hanako',
'password': 'ppaassss'
}
@classmethod
def setUpTestData(cls):
"""各テストケースで共通で必要となるレコードの準備"""
cls.user = CustomUser.objects.create_user(
username='Hanako',
password='ppaassss',
age = 20,
email='hanako@example.com'
)
Comment.objects.create(
user=cls.user,
text='Hello'
)
Comment.objects.create(
user=cls.user,
text='ABCDEFGHIJKLMNOPQRSTUVWXYZ'
)
token_record = Token.objects.create(user=cls.user)
cls.token = token_record.key
def setUp(self):
"""各テストケースで毎回で必要となる初期化"""
# APIClientのインスタンス生成
self.client = APIClient()
def test_get_api_comments_success(self):
"""正しいトークンが送信された場合にGET /api/comments/の実行に成功することを確認する"""
"""前提条件"""
# トークンの記録
self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
"""手順を実施"""
# GET /api/comments/を実行(記録したトークンも送信される)
response = self.client.get('/api/comments/')
"""結果の検証"""
# レスポンスのステータスコードが200であることを検証
self.assertEqual(response.status_code, 200)
# 全レコードが取得できていることを検証
self.assertEqual(len(response.data), 2)
self.assertEqual(response.data[0]['text'], 'Hello')
self.assertEqual(response.data[0]['user'], 1)
self.assertEqual(response.data[1]['text'], 'ABCDEFGHIJKLMNOPQRSTUVWXYZ')
self.assertEqual(response.data[1]['user'], 1)
def test_post_api_comments_success(self):
"""正しいトークンが送信された場合にPOST /api/comments/の実行に成功することを確認する"""
"""前提条件"""
# トークンの記録
self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
"""手順を実施"""
data = {
'text': 'New Comment'
}
# POST /api/comments/を実行(記録したトークンも送信される)
response = self.client.post('/api/comments/', format='json', data=data)
"""結果の検証"""
# レスポンスのステータスコードが201であることを検証
self.assertEqual(response.status_code, 201)
# 送信したフィールドに応じたレスポンスが返却されていることを検証
self.assertEqual(response.data['text'], 'New Comment')
self.assertEqual(response.data['user'], 1)
# レコードの件数が1件増えていることを検証
self.assertEqual(len(Comment.objects.all()), 3)
# 送信したフィールドに応じたレコードがDBに保存されていることを検証
comment = Comment.objects.get(id=3)
self.assertEqual(comment.text, 'New Comment')
self.assertEqual(comment.user, self.user)
def test_get_api_comments_no_token(self):
"""トークンが送信されない場合にGET /api/comments/の実行に失敗することを確認する"""
"""手順を実施"""
# GET /api/comments/を実行(トークン無し)
response = self.client.get('/api/comments/', format='json')
"""結果の検証"""
# レスポンスのステータスコードが201であることを検証
self.assertEqual(response.status_code, 401)
self.assertEqual(response.data['detail'], '認証情報が含まれていません。')
def test_get_api_comments_invalid_token(self):
"""不正なトークンを送信した場合にGET /api/comments/の実行に失敗することを確認する"""
"""前提条件"""
# トークンの記録
self.client.credentials(HTTP_AUTHORIZATION='Token ' + 'invalidtoken')
"""手順を実施"""
# GET /api/comments/を実行(不正なトークン)
response = self.client.get('/api/comments/', format='json')
"""結果の検証"""
# レスポンスのステータスコードが201であることを検証
self.assertEqual(response.status_code, 401)
self.assertEqual(response.data['detail'], '不正なトークンです。')
Web API に対するインテグレーションテストは、基本的には APIClient
を利用して HTTP リクエストをシミュレートすることで実施していくことになります。この APIClient
の HTTP リクエストをシミュレートするメソッド(post
や put
等)では、format='json'
を指定するだけで JSON が送信されるようにデータのシリアライズやヘッダーの設定が行われることになるため、JSON のデータの送信が楽に行えます。また、返却値のデータ属性 data
は、JSON から Python のオブジェクト(辞書やリストなど)にデシリアライズされたデータとなるため、わざわざデシリアライズを行う必要がないので検証も楽に実施することができます。
ただし、この APIClient
は、Client
とは異なりインスタンスが自動的に生成されないので、テスト開始前に APIClient
のインスタンスを生成する処理を明示的に実行する必要があります。上記では、setUp
メソッドで APIClient
のインスタンスを生成し、それを self.client
に参照させるようにしています。
また、APIClient
の credentials
メソッドを実行することで、APIClient
にトークンを記録させることが可能です。トークン認証を実施する Web API を実行するためには毎回トークンを送信する必要があります(ヘッダーにセットして送信)。ですが、credentials
を実行してトークンを記録させておけば、以降で Web API を実行する時に(HTTP リクエストのシミュレートを実行する時に)、わざわざヘッダーにトークンをセットする処理が不要となり、楽に Web API が実行できるようになります。上記の例では、各テストケースで1度しか Web API を実行していないので credentials
メソッドのメリットを活かせていないのですが、何回も Web API を実行するようなテストでは効果が大きいので、credentials
メソッドについても是非覚えておいてください。
今回は、トークンの発行自体に対するテストではないため、トークンはモデルクラス Token
を直接利用して作成しています。このように、Token
を利用してトークンの作成・取得・削除が実施可能であることは覚えておきましょう。また、上記では実施していませんが、トークンを発行する API のテストも別途必要となります。
api/tests/integration/test_comments.py
次は、test_comments.py
の実装例を示していきます。このファイルでは、URL が /api/comments/
の Web API の機能に対するインテグレーションテストを実装していきます。
今回は URL が /api/comments/
の Web API に対するテストのみを紹介しますが、当然のことながら、公開している Web API すべてに対してインテグレーションテストを実施する必要があります。
test_comments.py
の実装例は下記のようなものになります。下記では正常系のテストしか実施していませんが、特にメソッドが POST
の API に関しては異常系のテストや境界値テスト等も実施することをオススメします。また、先ほど同様に、Web API に対するインテグレーションテストとなるため、APIClient
を利用して実装するという点がポイントになります。
from django.test import TestCase
from rest_framework.test import APIClient
from rest_framework.authtoken.models import Token
from forum.models import Comment, CustomUser
class ApiCommentsTest(TestCase):
@classmethod
def setUpClass(cls):
"""各テストケースで共通で必要となる準備"""
super().setUpClass()
cls.login_url = '/api/token/'
cls.user_credentials = {
'username': 'Hanako',
'password': 'ppaassss'
}
@classmethod
def setUpTestData(cls):
"""各テストケースで共通で必要となるレコードの準備"""
cls.user = CustomUser.objects.create_user(
username='Hanako',
password='ppaassss',
age = 20,
email='hanako@example.com'
)
Comment.objects.create(
user=cls.user,
text='Hello'
)
Comment.objects.create(
user=cls.user,
text='ABCDEFGHIJKLMNOPQRSTUVWXYZ'
)
token_record = Token.objects.create(user=cls.user)
cls.token = token_record.key
def setUp(self):
"""各テストケースで毎回で必要となる初期化"""
# APIClientのインスタンス生成
self.client = APIClient()
def test_get_api_comments_success(self):
"""正しいトークンが送信された場合にGET /api/comments/の実行に成功することを確認する"""
"""前提条件"""
# トークンの記録
self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
"""手順を実施"""
# GET /api/comments/を実行(記録したトークンも送信される)
response = self.client.get('/api/comments/')
"""結果の検証"""
# レスポンスのステータスコードが200であることを検証
self.assertEqual(response.status_code, 200)
# 全レコードが取得できていることを検証
self.assertEqual(len(response.data), 2)
self.assertEqual(response.data[0]['text'], 'Hello')
self.assertEqual(response.data[0]['user'], 1)
self.assertEqual(response.data[1]['text'], 'ABCDEFGHIJKLMNOPQRSTUVWXYZ')
self.assertEqual(response.data[1]['user'], 1)
def test_post_api_comments_success(self):
"""正しいトークンが送信された場合にPOST /api/comments/の実行に成功することを確認する"""
"""前提条件"""
# トークンの記録
self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
"""手順を実施"""
data = {
'text': 'New Comment'
}
# POST /api/comments/を実行(記録したトークンも送信される)
response = self.client.post('/api/comments/', format='json', data=data)
"""結果の検証"""
# レスポンスのステータスコードが201であることを検証
self.assertEqual(response.status_code, 201)
# 送信したフィールドに応じたレスポンスが返却されていることを検証
self.assertEqual(response.data['text'], 'New Comment')
self.assertEqual(response.data['user'], 1)
# レコードの件数が1件増えていることを検証
self.assertEqual(len(Comment.objects.all()), 3)
# 送信したフィールドに応じたレコードがDBに保存されていることを検証
comment = Comment.objects.get(id=3)
self.assertEqual(comment.text, 'New Comment')
self.assertEqual(comment.user, self.user)
スポンサーリンク
テストの実施
とりあえず、このページで示したかったテストケースの実装例は示すことができましたので、ここからはテストの実施を行っていきたいと思います。
事前準備
今回は Selenium を利用するテストも実施することになるため、Selenium が未インストールの方は事前に Selenium のインストールが必要となります。
Selenium は、下記のコマンドでインストールすることができます。
% python -m pip install selenium
マイグレーションの実施
続いて、コンソールアプリを起動し、testproject
フォルダ(manage.py
が存在するフォルダ)に移動してください。
テストを実施するためには manage.py
コマンドの実行が必要となります。すでに実行済みの方もおられるかもしれませんが、念の為下記のコマンドを実行しておいてください。
% python manage.py makemigrations
テストの開始
次は、下記コマンドを実行してテストを開始しましょう!--verbosity
はレポートへの出力粒度を指定するオプションで、--verbosity 2
を指定しておけば、テスト結果が OK のテストケースの情報も出力されるようになります(デフォルトの場合はテスト結果が OK のテストケースの情報は出力されない)。
% python manage.py test --verbosity 2
これにより、テストが開始され、最初にデータベースの情報が出力された後、続けて下記のようにテストケース名(テストメソッド
名)や docstring
に記述した説明内容、さらにはテスト結果等がコンソールに出力されていくはずです。
test_get_api_comments_invalid_token (api.tests.integration.test_auth.AuthIntegrationTest.test_get_api_comments_invalid_token) 不正なトークンを送信した場合にGET /api/comments/の実行に失敗することを確認する ... ok test_get_api_comments_no_token (api.tests.integration.test_auth.AuthIntegrationTest.test_get_api_comments_no_token) トークンが送信されない場合にGET /api/comments/の実行に失敗することを確認する ... ok test_get_api_comments_success (api.tests.integration.test_auth.AuthIntegrationTest.test_get_api_comments_success) 正しいトークンが送信された場合にGET /api/comments/の実行に成功することを確認する ... ok test_post_api_comments_success (api.tests.integration.test_auth.AuthIntegrationTest.test_post_api_comments_success) 正しいトークンが送信された場合にPOST /api/comments/の実行に成功することを確認する ... ok
途中でウェブブラウザが勝手に起動してビックリするかもしれませんが、これは Selenium によってウェブブラウザ操作が自動的に実施されるからになります。
しばらく放置しておけば、最後にコンソールに下記のようなテスト結果のサマリーが出力されるはずです。これでテストが全て完了したことになります!今回は、バグが見つからなかったのでテスト結果は OK と出力されていますね!
---------------------------------------------------------------------- Ran 39 tests in 17.134s OK
バグが混入している場合のテストレポート
せっかくなので、お試しでバグがある時のテストも実施してみましょう!
今回は api/permissions.py
を下記のように変更してみてください(テスト完了後に元に戻しておいてください)。
from rest_framework.permissions import BasePermission
from rest_framework.permissions import SAFE_METHODS
class IsAuthorOrReadOnly(BasePermission):
def has_object_permission(self, request, view, obj):
#if request.method in SAFE_METHODS:
# return True
return obj.user == request.user
そして、再度下記のコマンドを実行してテストを実施してみてください。
% python manage.py test --verbosity 2
今度は、最後に下記のような文字列がコンソールに出力されるはずです。先ほどのテストレポートに比べて、FAIL と判断されたテストが増えていることが分かりますし、FAIL と判断された検証箇所も特定できるはずです。この FAIL と判断された検証箇所を調べてみれば、コメントの詳細取得 API が、コメントの投稿者以外から実行できなくなるバグが発生してしまっていることも確認できるはずです(本来、コメントの投稿者以外から実行不可とする必要があるのはコメントの更新 API とコメントの削除 API のみ)。
====================================================================== FAIL: test_has_object_permission_read_only (api.tests.test_permissions.IsAuthorOrReadOnlyTest.test_has_object_permission_read_only) リクエストメソッドがGETの場合にTrueを返却することを確認する ---------------------------------------------------------------------- Traceback (most recent call last): File "/Users/daeu/Downloads/django-introduction-django-web-api/testproject/api/tests/test_permissions.py", line 84, in test_has_object_permission_read_only self.assertTrue(result) ~~~~~~~~~~~~~~~^^^^^^^^ AssertionError: False is not true ---------------------------------------------------------------------- Ran 39 tests in 12.931s FAILED (failures=1)
このように、ソースコードの変更前後でテストを実施していれば、その変更によって既存の機能やテストへの影響がテストレポートから確認可能です。そして、これによってデグレに気づくことができます。今回は意図的にバグを混入させたのですが、テストが十分に実施できていれば、当然意図せず混入されたバグの場合でも同様にテストレポートでデグレに気づくことができます。
また、実際にテストを実施してみて、手順も非常に簡単であることも実感していただけたのではないかと思います。これなら繰り返しテストを実施するのも、そこまで苦にならないと思います。テストは一度実行するだけでなく、デグレを検出していくためにも繰り返し実施することが必要となりますので、Django フレームワークを使いこなしてデグレに気付きやすい開発体制を整えていくようにしましょう!
まとめ
このページでは、Django でのテストについて解説しました!
Django にはテストフレームワークが内蔵されており、これを利用することでテストを簡単な手順で、さらに全自動で実施することが可能です。
このテストフレームワークを利用してテストを実施するためには テストクラス (TestCase
のサブクラス) を定義し、そのクラスにメソッドをテストケースとして実装していく必要があります。具体的には、このメソッドには、大きく分けて「前提条件を満たすための処理」「手順を実施する処理」「結果を検証する処理」の3つを実装していくことになります。
コーディングが必要なので少し大変ですが、それによってテストが全自動で実行できるようになり、テストの実施自体は楽になります。また、Selenium を利用することでウェブブラウザ操作も含むテストも自動で実行できるようになりますので、こういったテストも積極的に利用していきましょう!
テストは地味なイメージもあるかもしれませんが、品質向上だけでなく、ウェブアプリの高機能化にもつながる非常に重要なプロセスになります。また、テストは工夫次第で効率的に実施することも可能となり、それによってウェブアプリの開発効率も向上することになりますので、その工夫も楽しんでいただければと思います!