【Django入門22】テストを実施する

Djangoのテストの解説ページアイキャッチ

このページにはプロモーションが含まれています

このページでは、Django でのテストについて解説していきます。

Django はテストフレームワークを内蔵しており、Fこれを利用することで、開発したウェブアプリのテストを簡単に実施することが可能です。そして、テストを実施することで、品質の高いウェブアプリを開発することができるだけでなく、新たな機能の導入・既存の機能の変更も行いやすくなり、より高機能なウェブアプリを安全に開発することも可能となります。なので、テストに地味なイメージを持たれている方も多いかもしれませんが、テストは品質だけでなく、機能面の向上、さらには開発効率の向上にも繋がる非常に重要なプロセスです!

ウェブアプリを公開していく上では必ずテストが必要となりますので、ウェブアプリの作り方だけでなく、Django でのテストのやり方もしっかり理解しておきましょう!

Contents

テストの重要性

最初に、皆さんのテストへのモチベーションを上げるため、テストの重要性について説明していきたいと思います。

ウェブアプリの品質が向上する

まずは、皆さんご存知の通り、テストを実施することでウェブアプリの品質が向上します。

テストはアプリ内に潜在しているバグを洗い出す作業であり、このバグをテストで発見し、そしてそれを修正することで、ウェブアプリの品質を向上させることができます。

ウェブアプリに対するテストの説明図

どれだけ機能的に優れているウェブアプリでも、品質が悪ければウェブアプリの評判が下がり、いずれはユーザーも離れていくことになります。もはや、現在では「品質は高くて当たり前」という風潮にもなっているため、多くのユーザーを獲得していくためには、ウェブアプリの品質向上が必須となります。

スポンサーリンク

ウェブアプリの機能が安全に開発・変更できる

また、テストの実施により、ウェブアプリの機能が安全に開発・変更できるようになります。

機能の新規開発や機能の変更で一番怖いのは、既存の機能・他の機能への悪影響になります。例えば、新たな機能を開発したり、新たな仕様を取り入れることで、既存の機能が正常に動作しなくなるようなことが起こりえます。つまり、今まで上手く正常に動作していた機能が動かなくなります。こういった現象はデグレ(デグレーション)と呼ばれます。

デグレの説明図

こういったデグレが発生する可能性があるため、一度リリースしたウェブアプリへの新機能追加や従来機能の変更は消極的になってしまいがちです。

デグレが怖くて機能追加に消極的になる様子

ですが、既存の機能に対してテストが十分に実施されていれば、テストを実施することで新機能の開発による既存の機能への影響がテスト結果として確認できるようになります。たとえば、元々テスト結果が 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

このようなテストケースを実装したテストファイルを作成しておけば、Django のテストフレームワークがテストファイルを読み込み、そこに実装されているテストケースに従ってテストの実行やテストレポートの作成を行なってくれるようになります。

テストフレームワークがテストケースに従ってテストを実施する様子

コーディングを伴うため手間に感じるかもしれませんが、プログラムとしてテストを開発することで、コマンド1つで Django テストフレームワークから全テストケースが自動的に実行されるようになり、これによって手作業でのテストが不要となりますし、テストの実施時間も短縮されます。

MEMO

テストケースをコーディングするためには、まずはウェブアプリの品質を担保するのに必要なテストケースの列挙が必要となります

このテストケースの列挙の仕方も重要ではあるのですが、このページでは、テストケースの列挙の仕方についての詳細な解説は省略し、特に Django テストフレームワークでのテストケースのコーディングの仕方に焦点を当てた解説を行います

コマンド実行でのテスト実施

Django のテストフレームワークでのテストの実行手順は非常に簡単で、以下のコマンドを実行するだけで、テストファイルに実装された全テストケースが実行されます。

% python manage.py test

このコマンド一つで全テストケースが自動で実行されるため、テストを簡単・自動・短時間で実施できます。これによって反復的なテストが可能となり、テストと必要に応じた修正を繰り返すことで、高品質・高機能なウェブアプリを開発できるようになります。

テストと修正を繰り返し実施する様子

実際には、下記のようなタイミングで反復的にテストを実施することが多いと思います。

  • ソースコードを変更するたびにテストを実施する
  • デイリーでテストを実施する
  • リリース前にテストを実施する

また、詳細は省略しますが、テストがコマンド一つで実施可能であるため、CI(継続的インテグレーション)ツールにも組み込みやすいです。これにより、コードがリポジトリにマージされるタイミングで自動的にテストを実施することも可能となります。

CIによる自動テスト

ここまでの説明からも分かるように、テストは1度だけではなく、繰り返し実施するものです。そのテストを、毎回複雑な手順が必要&手作業が必要となるとテストに要する時間が長くなり、開発の長期化やテスト不足による品質低下が発生する可能性もあります。できるだけ簡単にテストが実施できることは、ソフトウェアの開発にとっては非常に重要となります。

unittest ベースのテストフレームワーク

Django のテストフレームワークは unittest という Python の標準モジュールをベースに開発されています。ここまで解説してきた特徴に関しては、unittest と共通のものになります。

また、unittest をベースとしているため、テストケースの作り方も unittest とほとんど同じです。そのため、unittest でのテストの実施経験がある方は、Django でのテストケースもスムーズに実装することができると思います。

テストケースの作り方がunittestでのテストケースの作り方とほぼ同じであることを示す図

また、unittest に用意された mock の仕組みを利用し、特定の関数やメソッド・データ等をモック化することも可能です。

unittest や mock に関しては下記ページで詳細を解説していますので、詳しく知りたい方は下記ページを参照してください。

Pythonで単体テストを行う方法の解説ページアイキャッチ 【Python】unittestで単体テストを行う(カバレッジの計測についても紹介) 【Python/unittest】mockの基本的な使い方

スポンサーリンク

テスト用データベースの自動管理

先ほど説明した通り、Django のテストフレームワークは unittest をベースに開発されているのですが、Django のテストフレームワークにしか存在しない、Django で開発したウェブアプリをテストするのに特化した機能も多く存在します。

その1つが、この節の題名となっている「テスト用データベースの自動管理」となります。

Django のテストフレームワークでは、テストを実行する際に「専用のテスト用データベース」を自動的に作成し、このデータベースにレコードの作成等を行いながらテストが実施されるようになっています。そして、このテスト用データベースはテスト終了後に自動的に削除されるようになっています。そのため、テストの開始前に手動でデータベースの中身を空にしたり、大事なレコードが保存されているデータベースをテスト前に退避したりするようなデータベースの管理が不要となります。

Djangoのテストフレームワークではテスト時にテスト用データベースが作成されることを示す図

また、テストケース毎にデータベースが自動的にロールバック(リセット)されるようになっています。したがって、各テストケースでデータベースの状態は独立することになり、あるテストケースでデータベースへのレコードの作成・更新を行った場合でも、次のテストケースは、それらのレコードの作成・更新がロールバック(リセット)された状態で開始されることになります。

テストケースごとにデータベースがリセットされることを示す図

そのため、各テストケースは同じデータベースの状態から開始されることになり、意図しないレコードが存在する状態でのテストの開始を防止することができます。

意図しないレコードが残っている状態でテストを開始すると、テストの結果も意図しないものになる可能性があります。また、毎回テスト時にデーターベースのテーブルの中身が異なるとテスト結果も毎回異なってしまう可能性もあり、テスト結果に一貫性が無くなってしまいます。そういったことが起きないように、テストフレームワーク側でデータベースの管理が自動的に行われるようになっています。

テスト用クライアントが利用可能

また、Django のテストフレームワークにはテスト用クライアントのクラス Client が定義されています。この Client を利用することで、より高速&より詳細なテストを実現することができます。

Client は HTTP リクエストをシミュレートするためのクラスとなります。簡単に言ってしまえば、HTTP リクエストを送信するのではなく、HTTP リクエストを模倣するデータを作成し、それを引数としてビュー等の関数・メソッドを呼び出すことで、ウェブアプリを動作させます。

シミュレートするだけで、実際には HTTP リクエストの送信のための通信は行われないため、通信にかかる時間を短縮し、より高速なテストを実現することができます。また、HTTP リクエストを受信するための HTTP サーバーを起動する必要もないため、TestCase のサブクラスとして定義された テストクラス ではテスト実行時に HTTP サーバーを起動しないようになっており、これによって HTTP サーバー起動に必要となる時間の分、テスト時間が短縮できるようになっています。

HTTPリクエストのシミュレートの説明図

また、Client で HTTP リクエストをシミュレートしてウェブアプリを動作させた場合も、ウェブアプリから結果をレスポンスとして受け取ることが可能です。そして、このレスポンスには、通常の HTTP レスポンスには含まれないデータが含まれています。そのため、通常の HTTP レスポンスからは得られない結果に対する検証を実施することが可能です。

例えば、Client が HTTP リクエストをシミュレートすることで得られるレスポンスには、ビューが使用したテンプレートファイルのファイルパスや、ビューが生成したコンテキスト等が含まれます。これらは、通常の HTTP レスポンスには含まれないデータです。このような「通常の HTTP レスポンスには含まれないデータ」に対する検証も Client を利用することで実施することができるようになり、より詳細なテストが実現できるようになります。

Clientからメソッドを実行して得られるレスポンスのみに含まれるデータを示す図

例えば Python であれば、他のテスト手段として requests ライブラリを利用するという手もあります。下記ページでも解説したように、requests ライブラリからは HTTP リクエストを送信することができ、この HTTP リクエストの送信によってウェブアプリを動作させることができます。また、HTTP リクエストの応答として受信する HTTP レスポンスから、そのウェブアプリの動作の結果を得ることもできます。

ウェブブラウザ以外からのウェブアプリの操作方法の解説ページアイキャッチ 【Django入門18】ウェブアプリのHTTPリクエストでの操作

そのため、requests ライブラリを利用して HTTP リクエストを送信することでもウェブアプリのテストを実施することは可能です。そして、そのテスト結果は HTTP レスポンスを検証することで確認可能です。

ただし、このような HTTP リクエストの送信によるテストは、実際に HTTP リクエスト・HTTP レスポンスの送受信が必要になるため負荷が高く、テストに要する時間が長くなります。また、結果が HTTP レスポンスからしか検証できないため、詳細なテスト(例えば使用したテンプレートファイルのパスやコンテキストの検証など)を実施することができません。

こういった、実際に HTTP リクエストを送信するテストは、Django 以外で開発したウェブアプリのテストも実施可能で汎用性が高いというメリットもあるのですが、その反面、上記のような課題もあります。それに対し、Client を利用したテストは Django で開発したウェブアプリに特化したものになりますが、特化している分、高速かつ詳細なテストを行うことができ、Django で開発したウェブアプリに対するテストの効率化、およびウェブアプリの品質向上が図りやすいです。

豊富なテスト結果の検証手段

また、テストを実施する際には、テストを実施するだけでなく、そのテスト結果を検証することも重要です。例えば、関数やメソッドを実行して得られた返却値がテストケースに応じた「期待結果」を満たしていること、また HTTP リクエストのシミュレートによって得られたレスポンスのステータスコード、ボディのデータ等が、テストケースに応じた「期待結果」を満たしていることを検証する必要があります。

このような検証は、Django のテストフレームワークでは assert 系メソッドを利用して実施することになります。例えば下記のように assertEqual メソッドを実行すれば、第1引数で指定した 実行結果 が第2引数で指定した 期待結果 と一致しているかどうかを検証することができます。この assert 系メソッドは、1つのテストケースで複数実行することが可能です。

assertEqualでの結果の検証
self.assertEqual(実行結果, 期待結果)

さらに、この assert 系メソッドでの検証結果に基づいて、次の節で説明するテストレポートが作成されることになります。具体的には、assert 系メソッドでの検証で、実行結果が全ての期待結果を満たしていると判断された場合には「テスト結果:OK (or Pass)」、1つでも満たしていなければ「テスト結果:FAIL (or ERROR)」としてテストレポートに記載されることになります。

テストレポートがassert系メソッドによる結果の検証結果に応じた作成されることを示す図

また、この assert 系メソッドには様々な種類のものが定義されており、その種類ごとに異なる検証を実施することが可能です。Django テストフレームワークのベースとなっている unittest でも assert 系メソッドが定義されているのですが、Django テストフレームワークでは、Django 向けの assert 系メソッドが用意されており、それらのメソッドを利用することで効率的にテストの検証を実施することができるようになっています。

例えば、ウェブアプリからはリダイレクトレスポンスが返却されることがありますが、Django テストフレームワークには assertRedirects が定義されており、これを利用することで、ビューから返却されたレスポンスがリダイレクトレスポンスであり、さらに、そのリダイレクト先の URL が期待結果と一致しているかどうかを確認することが可能です。

assertRedirectsでの結果の検証
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 を導入することで、下の図のような見栄えの良いレポートをウェブブラウザから閲覧することもできるようになります。

HTML形式のテストレポート

このレポートをサーバーから公開するようにすれば、テスト実施者だけでなくチームのメンバー全員がテスト結果を確認し、ウェブアプリの品質レベルをメンバー全員で共有しながら開発することができます。そして、これによって円滑にプロジェクトを進めることもできるようになります。

サーバーで公開されるレポートをチームメンバーが確認する様子

Django で実施するテスト

次に、Django のテストフレームワークを利用して実施するテストの種類について解説していきます。

ユニットテスト(単体テスト)

まず、実施すべきテストの1つはユニットテストとなります。このユニットテストでは、モジュール単位(もしくは関数単位・メソッド単位)でのテストを実施します。

ユニットテストの説明図

基本的には、このユニットテストでは、クラスのメソッドや関数を直接実行する形式でテストを行うことになります。HTTP リクエストを送信(シミュレート)してウェブアプリを動作させる形式のテストは、次に説明するインテグレーションテストとなると考えて良いと思います。

そして、ユニットテストでは、各種メソッドや関数が意図した通りに動作することや、各クラスが定義した通りに利用できることを確認します。例えば、モデルに対するテストであれば、必須フィールドが無しの状態のレコードの新規登録を行うと例外が発生すること等を確認します。

このユニットテストを実施することで、各モジュールの品質が向上するだけでなく、次に説明する結合テストもスムーズに行うことが可能となります。さらに、モジュールの品質が向上することで、モジュール単位での再利用(他のウェブアプリへの流用)しやすくなり、それによってウェブアプリの開発効率を向上させることもできます。

各モジュールに対するユニットテストで確認する項目としては下記のようなものが挙げられます。

モジュール 確認項目の例
models.py
  • CRUD 操作:CRUD 操作が正しく行われること
  • フィールドの制約:null=Falseunique=True 等の制約が反映されていること
  • 定義したメソッド:入力に応じて正しい結果が得られること
forms.py
  • 妥当性の検証:is_valid メソッドから入力に応じて正しい結果が得られること
  • 定義したメソッド:入力に応じて正しい結果が得られること
views.py
  • 定義したメソッド:入力に応じて正しい結果が得られること
  • (下記はユニットテスト or インテグレーションテストで確認)
    • ステータスコード:入力に応じて正しいステータスコードが得られること
    • 例外の発生:必要に応じて例外が発生すること
    • テンプレートファイル:入力に応じて正しいステータスコードが得られること
urls.py
  • マッピング:URL にマッピングされているビューのクラス(関数)が正しいこと
  • 逆引き:名前から逆引きされる URL が正しいこと
その他
  • 定義したメソッド:入力に応じて正しい結果が得られること

テンプレートファイルに関しては単体で動作するものではなく、ビューや Django フレームワークから利用されるものとなるため、基本的にはユニットテストは実施せず、インテグレーションテストでレスポンスのボディを検証することで動作を確認することになります。

スポンサーリンク

インテグレーションテスト(統合テスト)

ユニットテストがモジュール単体に対するテストであるのに対し、インテグレーションテストは複数のモジュールを結合した状態で実施するテストになります。主に、複数のモジュールが正しく連係動作して、機能が正常に実現できていることを確認することを目的に実施するテストとなります。そのため、機能単位でテストを行うことが多いです。

インテグレーションテストの説明図

また、このインテグレーションテストは、基本的には各モジュールが単体で正しく動作することが確認できている状態で実施します。つまり、ユニットテストよりも後のフェーズで実施するテストになります。

Django で開発したウェブアプリにおいては、基本的にはインテグレーションテストは HTTP リクエストを送信(or シミュレート)して実施することになります。ウェブアプリは、HTTP リクエストを受け取ればビューが動作し、さらにビューとモデル・テンプレート・フォーム等が連係動作することで機能が実現されるようになっています。後は、HTTP リクエストの中身に応じた結果(HTTP レスポンスや機能の実行結果など)が得られることを検証してやれば、複数のモジュールが正しく連係動作していることを確認するテスト、すなわちインテグレーションテストが実施できることになります。

インテグレーションテストの実施方法の説明図

テスト用クライアントが利用可能 でも説明したように、Django のテストフレームワークでは Client を利用して HTTP リクエストをシミュレートすることが可能で、これによりインテグレーションテストを効率的・詳細に実施することが可能です。

例えば、インテグレーションテストで確認する項目としては下記のようなものが挙げられます。

機能 確認項目の例
レコードの新規登録
  • フィールドの値に応じて正しいレスポンスが得られること
  • 操作後、データベースに新規登録したレコードが存在すること
レコードの更新
  • フィールドの値に応じて正しいレスポンスが得られること
  • 操作後、データベースのレコードが更新されていること
レコードの削除
  • フィールドの値に応じて正しいレスポンスが得られること
  • 操作後、データベースに削除したレコードが存在しないこと
レコードの取得
  • クエリパラメーターに応じて正しいレスポンスが得られること
  • 得られたボディに取得したレコードの情報が含まれていること
ログイン
  • 正しいユーザー情報の場合にログインに成功すること
  • 不正なユーザー情報の場合にログインに失敗すること
アクセス制限
  • ログインユーザーからのアクセスが許可されること
  • 非ログインユーザーからのアクセスが拒否されること(リダイレクトされるなど)

正常系テスト・異常系テスト

また、テストでは、正常系テストだけでなく、異常系テストも実施する必要があります。

正常系テスト

正常系テストとは、有効な入力(手順・操作)に対するウェブアプリの動作を確認するためのテストになります。

この正常系テストは、ウェブアプリが開発者の想定する動作を実現できていることを確認することを目的に実施します。

例えば「ユーザー登録フォーム」で、各種フィールドに有効な値を入力してフォームからデータを送信することで、それらのフィールドに応じたレコードがデータベースのユーザーを管理するテーブルに新規登録されることを確認するようなテストは正常系テストとなります。

また、正常系テストを繰り返し実施することで、新機能の追加等によってデグレが発生していないことを確認することもできます。

異常系テスト

異常系テストとは、無効な入力(手順・操作)・想定外の入力(手順・操作)に対するウェブアプリの動作を確認するためのテストになります。

この異常系テストは、無効な入力・想定外の入力が与えられた時に、ウェブアプリが想定外の動作をしないことを確認することを目的に実施します。例えば、無効な入力・想定外の入力が与えられた時にウェブアプリが暴走したりしないかどうか、また本来であればエラーとするべき入力の場合に意図せず成功したりしないかどうかを確認します。

例えば「ユーザー登録フォーム」の例であれば、入力が必須のフィールドが空の状態でデータが送信された場合や、各種フィールドに無効な値が入力された状態でデータが送信された場合等に、意図通りエラーのレスポンスが返却されることや、レコードが新規登録されていないことを確認します。

ウェブアプリは有効な入力が与えられることを想定して開発することが多いため、正常系テストよりも異常系テストの方がバグが見つかりやすいです。ユーザーが間違って無効な入力を行ったり、無効な手順でウェブアプリを操作するような場合もありますし、悪意あるユーザーから意図的に想定外の手順でウェブアプリの操作が行われる場合もあります。そういった入力・操作が行われた時にも、ウェブアプリが異常な動作をしないことを確認するために異常系テストを十分に実施し、バグがあればそれらを修正しておく必要があります。

また、元々無効な入力が与えられた時にエラーが発生するようになっていたのに、コードの変更によってエラーが発生せずに正常終了してしまうようになってしまうこともあります。そして、これが開発者の意図と反した正常終了なのであれば、これもデグレの1つであると考えられます。異常系のテストも十分に実施しておけば、この異常系に対するデグレも検出することができるようになりますので、そういった意味でも異常系のテストが重要となります。

Django でのテスト実施手順

次は、Django でのテストの実施手順について解説していきます。

ここでは、テストを実施するために最低限必要となる手順のみを解説していきますので、この解説を読んで、まずはテストを実施する手順を理解していただければと思います。

スポンサーリンク

テストファイルの用意

では、Django でのテストの実施手順について解説していきます。

まず、Django でテストを実施するためには、テストケースの実装先となるテストファイルが必要となります。

自動生成される tests.py を利用する

このテストファイルは、startapp コマンドでのアプリの作成時に、自動的にアプリフォルダ内に tests.py という名称で作成されることになります。したがって、アプリに対するテストケースを tests.py の1つのファイルに全て実装する場合は、テストファイルの用意の手順は不要となります。

tests.pyの説明図

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 形式であれば、テストファイルの置き場所はアプリ内であればどこでも良いことになるのですが、慣習的に上記のような構成になるようにテストファイルを設置することが一般的となります。

MEMO

テストファイルを配置したフォルダを 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'
  • 手順:id1 のレコードを下記のように更新する
    • author'Hanako'
    • text'Good'
  • 期待結果:テーブルに保存されている id1 のレコードの各種フィールドが下記であること
    • author:'Hanako'
    • text:'Good'

TestCase のサブクラスを定義する

テストケースは、基本的には django.test で定義される TestCase のサブクラスの「メソッド」ととして実装していくことになります。

MEMO

テストケースは、実施したいテストの内容によっては TestCase 以外のサブクラスのメソッドとして実装する場合もあります

それについては後述で補足していきますので、まずは TestCase のサブクラスのメソッドとしてテストケースを実装することを前提に解説を進めさせていただきます

そのため、テストケースを実装していくためには、まずは TestCase のサブクラスを定義する必要があります。以降では、この TestCase のサブクラスのことを テストクラス と呼ばせていただきます。

この テストクラスは、ユニットテストであればテスト対象のクラス毎に、インテグレーションテストであればテスト対象の機能毎に用意することが多いです。ただし、これは目安であって、テストの目的毎にクラスを定義するなど、もっと自由な考え方で テストクラスを定義して問題ありません。

テストクラス を複数定義する理由は、細かな単位でテストを実施するためになります。Django のテストフレームワークではテストクラス 単位でテストを実施することが可能です。なので、1つの テストクラスに全てのテストケースを実装するのではなく、複数の テストクラスにテストケースを実装するようにすることで、必要なテストのみを選んで実施するようなことができます。そして、それによってテストに要する時間を短縮することもできます。

テストをクラス単位で実施することが可能であることを示す図

また、この テストクラス は、Test で始まるクラス名や Test で終わるクラス名を付けてやると、テスト用のクラスであることが分かりやすくなって可読性の高いコードにすることができます。

今回は、Comment というモデルクラスに対するテストケースを実装していこうとしているので、まずは下記のように CommentTest という テストクラス を定義してやれば良いことになります。

テストクラスの定義
from django.test import TestCase

class CommentTest(TestCase):
    pass

テストクラス のメソッドを定義する

そして、先ほども少し説明した通り、テストケースは テストクラス のメソッドとして実装していくことになります。

テストケースの作り方の説明図

前述の通り、テストケースとは下記のような内容をまとめたものであり、これらをコードとして テストクラス のメソッドに実装していくことになります。以降では、テストケースとして実装されたメソッドのことを テストメソッド と呼ばせていただきます。

  • テストの目的(内容)
  • テストの前提条件
  • テストの手順
  • テストの期待結果

テストケースとなるメソッドを定義する上でポイントになるのがメソッド名です。Django のテストフレームワークにテストケースとして認識されるためには、メソッド名は test から始まる必要があります。これさえ守れば、そのメソッドはテストケースとして認識され、テスト実施用のコマンド実行時にテストフレームワークから自動的に実行されるようになります。逆に、テストケースとして認識されたくないようなメソッド、例えば、各種テストケースのメソッドから共通に呼び出されるようなユーティリティ的なメソッドに関しては、test から始まらないメソッド名を付けてやれば良いです。

メソッド名に対する説明図

また、これは必須ではないのですが、テストケースにおける「テストの目的」をメソッド名に反映するようにすれば、メソッド名からテストの目的が理解できるようになり、可読性の高いコードを実現することができます。さらに、メソッド定義の先頭に docstring として記述した内容はテストレポートにも出力されるようになるので、メソッド名のみで表現できない目的を docstring で補うことで、テストの目的や確認する内容がテストレポートから読み取りやすくなります。

docstringの内容がテストレポートに出力される様子

今回の例であれば、テストの目的が「レコードの更新に成功することを確認する」なので、下記のような テストメソッド を定義すれば、テストケースの目的が理解しやすくなると思います。

テストメソッドの定義
from django.test import TestCase

class CommentTest(TestCase):
    def test_update_record_success(self):
        """各種フィールドが正常な場合にレコードの更新に成功することを確認する"""
        pass

「前提条件」を満たすための処理を実装する

続いて、この定義した テストメソッド に、「前提条件」を満たすための処理を実装していきます。

テストでは、テストを実施するための「前提条件」が設定されていることが多いです。前提条件と聞くとイメージが湧かないかもしれませんが、単純にテストを実施するための準備と考えてもらっても良いと思います。

例えば、今回のテストケースの例のように、レコードの更新を行うためには、事前にレコードをテーブルに登録しておく必要があります。また、インテグレーションテストでは、ウェブアプリにログイン機能が搭載されている場合、特定の機能のテストを実行するためには事前にログインを実施しておく必要があります。

これらの例のように、テストの手順を実施するためには、事前に前提条件を満たしておく必要があることも多いです。Django のテストにおいては、この前提条件を満たすための処理もコードとして テストメソッド に実装する必要があります。

テストメソッドに「手順」を満たすための処理も実装が必要であることを示す図

前述の通り、この前提条件として設定されていることが多いのが、テーブルのレコードに関するものになります。レコードを N 個登録しておく、テーブルを空にしておく、などの前提条件が設定されていることが多いです。こういったレコードに関する前提条件を満たすための処理は、基本的にはモデルクラスを直接利用して実施することが多いです。

例えば今回のテストケースの例であれば、下記のように テストメソッド に処理を実装してやれば良いことになります。Commentapp というアプリの 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引数に指定したデータと一致するかどうかの検証を実施することができます。今回のテストケースの例では、id1 のレコードの各種フィールドの値が「期待結果」に記載されたものと一致するかどうかを検証すればよいため、この 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 系のメソッドが定義されており、様々な検証を実施することが可能です。これに関しても、後述の テストケースの実装の詳細 で解説を行いたいと思います。

また、検証対象となるデータはテストの内容・テストの目的によって変化するという点には注意してください。例えば、上記の例においてはデータベースに保存されているレコードの各種フィールドが検証対象となります。そのため、レコードを取得し、そのレコードのフィールドに対して検証を実施する必要があります。

検証対象の説明図1

ですが、例えばメソッドや関数のユニットテストを行う場合は、それらの返却値が検証対象となることが多いです。要は、条件や手順に対して適切な値が返却されているかどうかを検証することになります。

検証対象の説明図2

また、メソッドや関数の作りによっては、期待結果が「例外が発生すること」となることもあり、この場合は例外の発生の有無や発生した例外の種類を検証することになります。さらには、統合テストの場合は、結果として得られたレスポンスのステータスコードやボディを検証するようなことも必要となります。

このように、テストケースによって検証対象も異なりますし、検証の仕方(利用する 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 に定義された CommentTesttest_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の利用
# 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 クラスを利用した テストメソッド の実装例は下記のようになります。ビューやモデル・フォームの実装例に関しては省略させていただきますが、下記のコードだけでも大体何をやっているかは理解していただけるのではないかと思います。

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 メソッドについては覚えておきましょう!

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 リクエストをシミュレートするメソッドが用意されています(getpost など)。

ただし、APIClient には下記の特徴があるため、APIClient を利用する方が Web API のテストケースを楽に実装することができます。(下記では送信という言葉を使っていますが、実際にはシミュレートされるだけになります)。

  • JSON フォーマットのデータの送信が容易:
    • HTTP リクエスト送信メソッドに format='json' を指定するだけで、データが JSON フォーマットにシリアライズされ、さらにヘッダーに Content-Type: application/json が自動でセットされるようになる
  • レスポンスのボディのデシリアライズが不要:
    • HTTP リクエスト送信メソッドの返却値(レスポンス)のデータ属性 data には、JSON をデシリアライズした結果がセットされている
  • トークン記録機能あり:
    • credentials メソッドを実行することでトークンを記録することができ、以降の HTTP リクエスト送信メソッドを実行した際に、自動的にトークンが送信されるようになる

もちろん、Client でも Web API のテストは可能なのですが、効率的にテストを実施していくために APIClient を利用することをオススメします。

ただし、Client の場合とは異なり、APIClient のインスタンスは テストメソッド 毎に自動的に生成されるようにはなっていないので注意してください。つまり、APIClient を利用する場合は、APIClient のインスタンスを生成する処理を開発者自身が実装しておく必要があります。テストの独立性を保つため、APIClient のインスタンスを生成する処理は setUp メソッドに実装しておくことをオススメします。

例えば下記は、トークン認証を実施する GET /api/comments/ の 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 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が各テストケースの実行前後に実行される様子

したがって、setUp メソッドに各テストケースでテスト前に毎回必要となる初期化処理を、tearDown メソッドに各テストケースでテスト後に毎回必要となる終了処理をそれぞれ実装しておけば、わざわざ テストメソッド に同じ処理を実装する必要がなくなり、各メソッドの実装量を減らすことができます。

例えば「各 テストメソッド の実行前と実行後のログの出力」は各 テストメソッド の先頭と末尾で print を実行することでも実現できるのですが、これだと全ての テストメソッド に同じような処理を記述する必要があって非効率です。それに対し、setUptearDown を定義し、これらのメソッド内で print を実行するようにすれば、2つのメソッドの変更のみで同じことが実現でき、効率的にテストの実装を行うことができます。

例えば、下記のように setUp メソッドと tearDown メソッドを定義しておけば、テストの実行前後に テストメソッド の名称が出力されるようになります。

setUpの利用例
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がテストクラスの開始時と終了時に実行される様子

例えば、各テストケースに共通となる初期化処理・終了処理が必要である場合、その初期化処理を setUpClass メソッドに、終了処理を tearDownClass メソッドに実装しておけば、各テストケースに同じような処理を実装する必要が無くなり、効率的にテストケースの実装を行うことができるようになります。

例えば、LiveServerTestCase でも解説するように、テストをウェブブラウザを利用して実施するような場合があり、各テストケースが共通にウェブブラウザを利用するものであるのであれば、ウェブブラウザを起動する処理を setUpClass メソッドに、ウェブブラウザを終了する処理を tearDownClass メソッドに実装しておけば良いことになります。

setUpClassとtearDownClassの利用例

もちろん、同じようなことは setUp メソッドと tearDown メソッドでも実現することが可能です。ですが、これらのメソッドはテストケースごとに実行されることになるため、テストケースごとにウェブブラウザを開く / 閉じる処理が実行されることになり、テストの実行時間が長くなってしまいます。setUpClass メソッドと tearDownClass メソッドでは、テストクラス の開始 / 終了のタイミングでのみ実行されることになるため、ウェブブラウザを開く / 閉じる処理も1度ずつのみ行われることになり、テストの実行時間を短くすることができます。

setUpTestData メソッド

また、TestCase のサブクラスにおいては setUpTestData メソッドを定義することが可能で、このメソッドは TestCase の setUpClass から呼び出されるメソッドとなります。この  setUpTestData メソッドに関してもクラスメソッドとして定義する必要があります。

この setUpTestData は、主にデータベースのセットアップ(レコードの新規登録など)を行うことを目的に定義するメソッドとなります。setUpClass から呼び出されるため、setUpClass 同様に テストクラス 全体の テストメソッド を実行する前に1度だけ実行されるメソッドとなります。そのため、各テストケース間で共通のレコードの新規登録を行う必要があるのであれば、この setUpTestData でレコードの新規登録を行うようにしておけば、各 テストメソッド に同様の処理を実装する必要が無くなり、効率的にテストケースを実装することができます。また、1度だけ実行されるメソッドであり、毎回データベースのセットアップが行われるわけではないので、テストの実行時間的な効率化を図ることもできます。

さらに、この setUpTestData でセットアップされたデータベースは、各テストケース実行後に 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 実行後のデータベースの状態にロールバックされることになり、これらのテストケースは同じ状態から開始されることになります。

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機能の説明図1

各種フィールドの値が大きく異なる大量のレコードを新規登録するような処理を実装するのは非現実的なので、このような場合は Fixture 機能が活躍することになります。

ただし、この Fixture 機能によるレコードのインポートは setUp 同様にテストケースの開始前に毎回実施されることになります。なので、setUpTestData に比べるとテストに時間がかかることになります。この辺りのバランスを考えて、Fixture 機能の利用の可否を決めれば良いと思います。

Fixture 機能の利用方法

この Fixture 機能は、テストクラスクラス変数 fixtures を定義することで利用することができ、fixtures にはファイルパスの文字列を要素とするリストを指定します。これにより、テスト前にリスト内のファイルパスのファイルが読み込まれ、それらがデータベースにレコードとしてインポートされることになります。

この Fixture 機能で読み込み可能なファイルのフォーマットとしては JSON や XML 等が挙げられ、例えば JSON の場合は下記のような構造のファイルである必要があります。

Fixture機能用のJSONの構造
[
    {
        "model": "アプリ名.モデルクラス名",
        "pk": プライマリーキー1,
        "fields": {
            "フィールド名1": 値1,
            "フィールド名2": 値2,
            // 略
        }
    },
    {
        "model": "アプリ名.モデルクラス名",
        "pk": プライマリーキー1,
        "fields": {
            "フィールド名1": 値1,
            "フィールド名2": 値2,
            // 略
        }
    },
    // 略
]

例えば下記は、Fixture 機能を利用し、test_data.json に記録されたレコードをテストケース開始前にデータベースにインポートする例となります。

Fixture機能の利用例
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つのレコードが登録されることになります。

test_data.json
[
    {
        "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 以外のメソッドの情報も含まれるので、その点は注意してください。

TestCaseのヘルプの表示
from django.test import TestCase

help(TestCase)

また、下記を実行することで、assert 系メソッドのみのメソッド名をリストアップして表示することも可能です。

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) ab が値として一致すること
assertNotEqual(a, b) ab が値として一致しないこと
assertIn(member, container) containermember 要素が含まれていること
assertNoIn(member, container) containermember 要素が含まれていないこと
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) formfield フィールドで err のエラーが発生したこと
assertRaises(exception) exception の例外が発生すること

RequestFactory

RequestFactory は、ビューに入力する「リクエスト」を生成するためのクラスになります。django.test で定義されるクラスで、特にビューのユニットテストを実施する時に利用します。

通常のウェブアプリ利用時には、ビューには適切なリクエストのデータが引数で渡されるように Django フレームワークが動作するようになっています。なので、ビューの引数に指定するリクエストを開発者が用意するような必要はありません。

ですが、ビューに対するユニットテストを実施する際には、そのテストの目的や内容に応じたリクエストを用意し、それをビューに引数で渡すようにする必要があります。このリクエストを生成するときに便利なのが RequestFactory になります。

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/・メソッドが POSTtext フィールドの値が 'Yes' であるリクエストを RequestFactory を利用して生成し、このような HTTP リクエストを送信したときに実行されるビューのクラス Post のユニットテストを実施する例となります。下記のように、生成したリクエストの user にユーザーをセットすることで、リクエストの送信者を設定することも可能です。

RequestFactoryの利用例
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を利用することで実際にHTTPリクエストを送信するテストも実施できることを示す図

LiveServerTestCase を利用する機会が多いのが、ウェブアプリの UI に対するテストを自動的に実行したい場合になります。例えば「ウェブアプリのフォームのボタンをクリックすることで、フォームの各種フィールドに入力されたデータが HTTP リクエストのボディとしてウェブアプリに送信されること」を確認したい場合は、HTTP サーバーを起動して HTTP リクエストを受信できるようにしておく必要があります。そのため、このようなテストは LiveServerTestCase を利用して実現する必要があります。

UIに対するテストにHTTPサーバーが必要であることを示す図

Selenium と組み合わせた UI テストの自動化

また、LiveServerTestCase は、Selenium というライブラリと組み合わせて利用されることが多いです。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 では、次の節の テストの実行メカニズム で解説するように、テストケース毎に「テストケース開始前の状態」へデータベースがロールバックされるようになっています。なので、テストケース開始前にデータベースをセットアップした場合、より具体的には、setUpClasssetUpTestData でデータベースをセットアップした場合、テストケース開始時には、そのセットアップ後の状態にロールバックされることになります。

LiverServerTestCaseでのデータベースの自動管理1

それに対し、LiveServerTestCase では、テストケース毎にデータベースがリセット(トランケート)されて空になるようになっています。したがって、例えば LiveServerTestCase のサブクラス内のテストケースで共通に必要となるデータベースのセットアップであっても、そのデータベースのセットアップはテストケースごとに毎回実施する必要があることになります。例えば setUpClass でデータベースのセットアップを行ったとしても、そのデータベースは最初のテストケース終了時にリセットされて空になってしまうことになります(また、LiveServerTestCase では setUpTestData は利用不可となります)。

LiverServerTestCaseでのデータベースの自動管理2

そのため、基本的には、LiveServerTestCase においては各テストケースで共通に必要となるデータベースのセットアップは setUp メソッドで実施することになります。LiveServerTestCase においても各テストケース開始時(すなわち、データベースがリセットされた後)に setUp メソッドが実行されるようになっています。もしくは、Fixture 機能を利用するのでも良いです。

LiveServerTestCaseにおけるデータベースのセットアップはsetUpメソッドで実施する必要があることを説明する図

ただ、これらの方法ではデータベースのセットアップが各テストケース開始時に毎回実施されることになるので、テスト時間が長くなってしまいます。これに関しては許容するしかないと思います…。したがって、HTTP リクエストの送信が必要となるテストのみ LiveServerTestCase を利用し、それ以外は TestCase を利用してテストを実施するようにした方が良いと思います。

ここまでの説明の通り、TestCase と LiveServerTestCase のいずれにおいてもテストケース間でデータベースの状態が独立するようデータベースの自動管理が行われるのですが、その管理の仕方が異なる点には注意してください。

静的ファイルの配信と StaticLiveServerTestCase

また、LiveServerTestCase によって起動する HTTP サーバーは開発用ウェブサーバーとは異なるという点にも注意してください。特に注意が必要となるのが「静的ファイルの配信」になります。下記ページで解説したように、開発用ウェブサーバーでは アプリ名/static/ フォルダ以下(+STATICFILES_DIRS に指定されたフォルダ以下)に設置されたファイルが配信されるようになっています。

Djangoでの静的ファイルの扱い方の解説ページアイキャッチ 【Django入門17】静的ファイルの扱い方(画像・JS・CSS)

ですが、LiveServerTestCase によって起動する HTTP サーバーは開発用ウェブサーバーではないため、アプリ名/static/ 以下にファイルを設置してもファイルの配信が行われません。LiveServerTestCase によって起動する HTTP サーバーでは、settings.pySTATIC_ROOT に指定したフォルダ以下のファイルのみが配信されるようになっています。そのため、LiveServerTestCase を利用したテスト時に静的ファイルの配信も行われるようにしたいのであれば、あらかじめ collectstatic コマンドを実行して静的ファイルを STATIC_ROOT に指定したフォルダ以下に集約しておく必要があります。

LiveServerTestCase利用時に起動されるHTTPサーバーでの静的ファイルの配信の説明図

つまり、LiveServerTestCase を利用したテストでの静的ファイルの配信を実施するためには、まず settings.pySTATIC_ROOT の定義を追記し、

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利用時に起動されるHTTPサーバーでの静的ファイルの配信の説明図

また、StaticLiveServerTestCaseLiveServerTestCase に対して静的ファイルの扱い方のみを拡張したクラスとなりますので、LiveServerTestCase の時と同様の手順でテストケースが実装可能です。

ただし、TestCaseLiveServerTestCase とは import 先のモジュールが異なるので注意してください。これらは django.test で定義されたクラスになりますが、StataicLiveServerTestCase に関しては django.contrib.staticfiles.testing で定義されたクラスとなります。そのため、下記のように django.contrib.staticfiles.testing から import を行う必要があります。

StaticLiveServerTestCaseのインポート
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 実行時に、テストケース単位でのロールバック用のものとは異なるトランザクションが開始され、TestCasetearDownClass 実行時に、そのトランザクション内で実施されたデータベース操作がロールバックされるようになっています。そのため、各 テストクラス 間でもデータベースが独立した状態でテストが実施されるようになっています。

テストクラス単位でのロールバック

で、この テストクラス 単位でのロールバックを上手く動作させるためには、まず TestCase の setUpClass メソッドと TestCase の tearDownClass メソッドの実行が必要となります。そのため、テストクラス (すなわち TestCase のサブクラス) で setUpClasstearDownClass を定義する場合、これらのメソッド内で必ずスーパークラス (すなわち TestCase) の setUpClasstearDownClass を呼び出すようにする必要があります。あくまでもロールバックの仕組みが導入されているのは 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 実行後」に実施する必要があります。あくまでも、TestCasetearDownClass で実施されるロールバックは、TestCasesetUpClass でトランザクションを開始した時点のデータベースの状態へのロールバックとなります。したがって、TestCasesetUpClass を実行する前に実施されたデータベース操作はロールバックされません。そのため、次に他の テストクラス でテストが実施される際には、前に実行された テストクラス でのデータベース操作が残った状態のデータベースでテストが実施されることになります。そうなると、テストケースの設計と話が合わなくなる可能性があり、意図したテストが実施できなくなる可能性があります。

setUpClass実行前にデータベース操作を行うとロールバックが上手く実施されない例

このように、テストクラス 内で共通となる初期化処理でデータベース操作が必要である場合は、TestCasesetUpClass を実行した後に初期化処理を実施する必要があるという点に注意してください。

例えば、下記のように テストクラスsetUpClass を実装してしまうと、TestCasesetUpClass を実行する前に作成した 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 メソッドは、必ず TestCasesetUpClass でトランザクションが開始された後に実行されるようになっています。したがって、setUpTestData メソッドで実施したデータベース操作は、TestCasetearDownClass 実行時に必ずロールバックされることになります。つまり、上記のような背景を知らなくても、setUpTestData メソッドでデータベース操作を行うようにする限り、そのデータベース操作が他の テストクラス のテストに影響を及ぼすことはありません。

ということで、TestCase のサブクラスでテストケースを実装する場合、クラス内で共通となるデータベースのセットアップに関しては setUpTestData で行うようにした方が良いです。

TestCase 以外でのロールバック

ここまでの解説は、TestCase でのロールバックに関する説明になります。

LiveServerTestCase の節でも解説したように、LiveServerTestCase でもテストケース毎にデータベースが独立する仕組みが存在しますが、これはロールバックによって実現されているのではなく、単にデータベースをリセットすることで実現されています。つまり、単純にデータベースのレコードがテストケース毎に削除されて空っぽになります。

他にも、Django のテストフレームワークには TransactionTestCase が定義されており、このクラスにおいても、LiveServerTestCase 同様にテストケース毎にデータベースがリセットされるようになっています。

したがって、LiveServerTestCaseTransactionTestCase の場合は、クラス単位でのデータベースのセットアップに関しては考える必要がなく、単純に必要に応じてデータベースのセットアップをテストケース毎に実施するようにすれば良いだけです。テストケース毎にセットアップが行われることになってテストの時間はかかりますが、考え方に関しては LiveServerTestCase の方がシンプルではないかと思います。

Django テスト向け外部ライブラリ

続いて、Django でのテスト時に利用する機会の多い外部ライブラリについて解説しておきます。

Selenium

1つ目が、LiveServerTestCase の節で紹介した Selenium になります。この Selenium はウェブブラウザ操作を Python スクリプトから実行するためのライブラリであり、これを利用することで、ウェブブラウザ操作を伴うテストケースが実現できることになります。つまり、ウェブブラウザからの下記のような操作が必要となるテストも自動で実行することができるようになります。

  • フォームのフィールドへの値の入力
  • フォームのボタンのクリック

また、Selenium ではページが実際にウェブブラウザに表示され、さらに JavaScript も実行されることになるため、JavaScript の動作確認を含むテストも自動で実施することができるようになります。

Selenium は pip を利用して下記のコマンドによりインストール可能です。

% python -m pip install selenium

Selenium に関しては下記ページで詳細を解説していますので、詳しく知りたい方は下記ページを参照していただければと思います。

Seleniumについての解説ページアイキャッチ 【Python】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.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 形式のテストレポートになります。ウェブブラウザで作成されたファイルを開けば、下の図のようなレポートが表示されることになります。

HTML形式のテストレポート

ただ、Windows PC を使用している場合、このテストレポートをウェブブラウザから開くとセキュリティ上の問題でエラーが発生することがあります。この場合、下の図のようにテスト結果のリストが表示されません。

レポートにテストケースのリストが表示されない様子

このような現象が発生した場合は、ローカルの HTTP サーバーを立ててテストレポートを表示するようにする必要があります。

具体的には、まずコンソールアプリでテストレポート(上記の場合は report.html)の出力先のフォルダに移動し、下記のコマンドを実行します。8080 は起動する HTTP サーバーで使用するポート番号になります。コマンドの実行でエラーが発生する場合は適当なポート番号に変更してください。このコマンドによって HTTP サーバーが PC 上で起動することになります。

% python -m http.server 8080

続いてウェブブラウザで下記の URL を開いてみてください。前述の通り 8080 は HTTP サーバーが使用しているポート番号になります。さらに、report.htmlpytest コマンドの -html オプションに指定したファイル名になります。これらは、ここまで実行してきたコマンドに応じて適切に変更してください。

http://localhosst:8080/report.html

この手順を踏んでテストレポートを表示すれば、先ほど表示されなかったテスト結果のリストが表示されるようになっているはずです。

HTTP サーバーを起動している限り、先ほど示した pytest コマンドからテストを実行するたびに HTML が更新され、ページ表示の更新で新たなテスト結果をウェブブラウザから確認することが可能です。また、HTTP サーバーを終了させたい場合は、HTTP サーバーを起動するときに実行したコマンドに対して ctrl + c を入力して強制終了させてください。

掲示板アプリのテストを実施する

最後に、ここまで解説してきた内容を踏まえ、掲示板アプリのテストを実施していきたいと思います。

この Django 入門 の連載の中では簡単な掲示板アプリを開発してきており、前回の連載(下記ページ)の 掲示板アプリで JavaScript を扱う では、掲示板アプリを JavaScript が扱えるように変更し、動的なページを実現しました。

Djangoで開発するウェブアプリでの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=Falseunique=True 等の制約が反映されていること
  • 定義したメソッド:入力に応じて正しい結果が得られること

これを踏まえた test_models.py の実装例は下記のようなものになります。下記では、CRUD 操作における Create と Read (一覧取得) に対するテストしか行っていませんが、当然 Update と Delete に対するテストも実施した方がよいです。また、下記ではモデルクラスの Comment に対してテストを実施していますが、掲示板アプリでは CustomUser も定義していますので、本来であれば CustomUser に対するテストも必要となります。こんな感じで、あくまでも、ここで示すのは テストケース(テストメソッド)の実装例であり、今後紹介する実装例でもテストケースに漏れがあるので注意してください。

forum/tests/test_models.py
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 に対するテストのみを実施しています。

forum/tests/test_forms.py
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 と定義しています(Commenttext から引き継がれる)。これに対する境界値テストととして、下記の2つの テストメソッド を実装しています。

  • test_is_valid_text_equal_max_lentext フィールドの文字列長が 256 の場合のテスト
  • test_is_valid_text_greater_than_max_lentext フィールドの文字列長が 257 の場合のテスト

このような境界値テストを実施することで、仕様として定義された「閾値」が機能しているかどうかを確認することが可能です。また、何らかのタイミングで閾値が変更された場合に、それをテストで検知することが可能となります。

同様に、文字列を扱うフィールドの最小長はデフォルトで 1 に設定されるため、これらの境界に対するテストも上記の test_forms.py では実施するようにしています。

この境界値テストはバグを洗い出すために利用されることの多いテスト手法となりますので、境界値テストについては理解しておき、積極的にテストを実施するようにしましょう。

スポンサーリンク

forum/tests/test_views.py

3番目に、forum/tests/ に設置した test_views.py の実装例を示していきます。このファイルは views.py に対するユニットテストの実装先となっており、このファイルにアプリ内で定義したビューのテストのテストケースを実装していきます。Django で実施するテスト でも示したように、ここでは下記のような観点を確認するテストを実施するのがよいと思います。

  • 定義したメソッド:入力に応じて正しい結果が得られること

ビューに対するユニットテストは結構ややこしくて、その理由の1つとしてインテグレーションテストとの切り分けが難しいという点が挙げられます。例えば、ステータスコードやビューから利用したテンプレートファイル等を検証するテストも必要となるのですが、ビューはモデルクラスやフォームクラス、さらにはテンプレート等も利用して動作することになりますので、本当の意味での「ユニットテスト」を実施するのが困難で、どちらかというと「インテグレーションテスト」でテストを行うことが多いと思います。

もちろん、モックの仕組みを利用してモデルクラスやフォームクラス等からビューを分離させてユニットテストを行うことも可能なので、できればユニットテストもしっかり実施した方が良いのですが、どこまでやるかはプロジェクトの方針に応じて変化することになると思います。

今回に関しては、ビューのユニットテストでは、各ビューで定義したメソッドに対するテストのみを実施するようにしたいと思います。

test_views.py の実装例は下記のようなものになります。メソッドを定義しているビューが PostLoginUserDetail であるため、これらのメソッドに対するテストを実施しています。

forum/tests/test_views.py
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 で定義されている UserDetailget_context_data メソッドの定義は下記のようになっています。ご覧の通り、get_context_data 内では Comment.objects.filter が実行されているため、このメソッドはモデルに依存していることになります。それに対し、本来ユニットテストはモジュール単体(or 関数 / メソッド単体)に対して実施するテストなので、このメソッドをそのまま実行するテストは、本来のユニットテストの定義には当てはまらないことになります。

UserDetailのget_context_data
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 の実装例は下記のようなものになります。

forum/tests/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.pyforms.pyviews.pyurls.py から構成されることになるのですが、それ以外のモジュールを追加で作成することも多いです。その場合は、追加したモジュールに対してもテストを実施する必要があります。

その一例として、今回は mixins.py に対するユニットテストの実装例を示したいと思います。Django のモジュールではクラスを定義することが多いので、基本的には下記の観点でのテストを実施することになることになると思いますが、必要に応じて他のテストも適切に追加するようにしてください。

  • 定義したメソッド:入力に応じて正しい結果が得られること

test_mixins.py の実装例は下記のようなものになります。

forum/tests/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_objectAuthorRequiredMixin には定義されていないため、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 他のテストファイルにインテグレーションテストに実装が必要となります。 

forum/tests/integration/test_auth.py
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 の実装例は下記のようなものになります。この例は、あくまでもコメント投稿機能に関するテストとなりますが、他にもユーザー登録機能であったり、コメントの一覧表示・ユーザーの詳細表示など、アプリが備えている全機能に対して同様にテストを実施する必要があります。

forum/tests/integration/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 でも解説したように、LiveServerTestCaseStaticLiveServerTestCase のサブクラスとして テストクラス を定義することで、テスト実施時に HTTP サーバーが起動するようになり、シミュレートではなく実際の HTTP リクエストの送信によってテストを実施することができるようになります。さらに、Selenium を利用することで、Python スクリプトからのウェブブラウザの自動操作が可能となります。

そのため、手順にウェブブラウザ操作が必要となるようなテストケース、例えば「フォームのボタンのクリック」や「フォームのフィールドへの値の入力」が必要となるようなテストケースも テストメソッド として実装可能となり、こういったテストも Django のテストフレームワークから自動で実施することが可能となります。

また、Selenium を利用することで実際にウェブブラウザが起動するようになるため、ウェブブラウザで実行される JavaScript の動作確認を含めたテストも実施可能となります。

こういった、UI や JavaScript に対するテストを実施する test_ui.py の実装例は下記のようなものになります。LoginUITest では、ログインフォームに対する UI 操作、PostUITest では投稿フォームに対する UI 操作、CommentsUITest では JavaScript による動的更新に対するテストケースを実装するようにしています。

forum/tests/integration/test_ui.py
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 の実装例は下記のようなものになります。

api/tests/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 に実装していきます。

そのインテグレーションテストの実装例は下記のようなものとなります。トークン認証に成功した場合や失敗した場合のウェブアプリの動作を確認するテストと実施しています。

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 を利用して実装するという点がポイントになります。

api/tests/integration/test_comments.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 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 を下記のように変更してみてください(テスト完了後に元に戻しておいてください)。

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 を利用することでウェブブラウザ操作も含むテストも自動で実行できるようになりますので、こういったテストも積極的に利用していきましょう!

テストは地味なイメージもあるかもしれませんが、品質向上だけでなく、ウェブアプリの高機能化にもつながる非常に重要なプロセスになります。また、テストは工夫次第で効率的に実施することも可能となり、それによってウェブアプリの開発効率も向上することになりますので、その工夫も楽しんでいただければと思います!

同じカテゴリのページ一覧を表示