Chapter 20

テストの導入

koga1020
koga1020
2021.11.27に更新

テストの導入

前提: このガイドでは、入門ガイドの内容を理解し、Phoenixアプリケーションを起動していることを前提としています

テストは、ソフトウェア開発プロセスに不可欠なものとなっており、意味のあるテストを簡単に書く能力は、現代のWebフレームワークにとって不可欠な機能です。Phoenixはこれに真剣に取り組んでおり、フレームワークのすべての主要なコンポーネントを簡単にテストできるようにするためのサポートファイルを提供しています。また、生成されたモジュールと一緒に、実世界の例を含むテストモジュールを生成して、私たちの作業を支援してくれます。

ElixirにはExUnitと呼ばれるテストフレームワークが組み込まれています。ExUnitは簡潔で明快なテストを心がけており、魔法を最小限に抑えています。PhoenixはすべてのテストにExUnitを使用しています。ここでも使っていきます。

テストの実行

PhoenixがWebアプリケーションを生成する際には、テストも含まれています。テストを実行するには、mix test と入力するだけです。

$ mix test
....

Finished in 0.09 seconds
3 tests, 0 failures

Randomized with seed 652656

すでに3つのテストを実施しています!

実際には、テスト用のヘルパーやサポートファイルを含めて、すでにテスト用のディレクトリ構造が完全に設定されています。

test
├── hello_web
│   ├── channels
│   ├── controllers
│   │   └── page_controller_test.exs
│   └── views
│       ├── error_view_test.exs
│       ├── layout_view_test.exs
│       └── page_view_test.exs
├── support
│   ├── channel_case.ex
│   ├── conn_case.ex
│   └── data_case.ex
└── test_helper.exs

無料で入手できるテストケースは test/hello_web/controllers/page_controller_test.exs, test/hello_web/views/error_view_test.exs, test/hello_web/views/page_view_test.exs です。これらはコントローラーとビューをテストしています。まだコントローラーとビューのガイドを読んでいないのであれば、今がチャンスです。

テストモジュールを理解する

次のセクションでは、Phoenixのテスト構造に慣れるために使用します。まずは、Phoenixで生成された3つのテストファイルから始めます。

最初に見るテストファイルは test/hello_web/controllers/page_controller_test.exs です。

defmodule HelloWeb.PageControllerTest do
  use HelloWeb.ConnCase

  test "GET /", %{conn: conn} do
    conn = get(conn, "/")
    assert html_response(conn, 200) =~ "Welcome to Phoenix!"
  end
end

ここではおもしろいことがいくつか起きています。

テストファイルは単純にモジュールを定義しています。各モジュールの先頭には、次のような行があります。

use HelloWeb.ConnCase

もし、Phoenix以外のElixirライブラリを書くとしたら、use HelloWeb.ConnCase の代わりに use ExUnit.Case と書きます。しかし、Phoenixにはコントローラーをテストするための多くの機能が搭載されており、HelloWeb.ConnCaseExUnit.Case の上に構築され、これらの機能を取り入れています。HelloWeb.ConnCaseExUnit.Case の上に構築され、これらの機能を取り込むことができます。

次に、test/3 マクロを使って各テストを定義します。test/3 マクロは3つの引数を受け取ります: テスト名、パターンマッチングを行うテストコンテキスト、テストの内容です。このテストでは、get/2 マクロを使ってパス "/" への "GET" HTTPリクエストでアプリケーションのルートページにアクセスします。そして、レンダリングされたページに "Welcome to Phoenix!" という文字列が含まれていることをアサートします。

Elixirでテストを書くときには、アサーションを使って何かが真であることを確認します。この例では、assert html_response(conn, 200) =~ "Welcome to Phoenix! はいくつかのことをしています。

  • これは、conn がレスポンスをレンダリングしたことをアサートします
  • レスポンスのステータスコードが200であることをアサートします(HTTPの用語でOKを意味します)
  • レスポンスのタイプがHTMLであることをアサートします
  • HTMLレスポンスである html_response(conn, 200) の結果に "Welcome to Phoenix!" という文字列が含まれていることをアサートします

しかし、gethtml_response で使う conn はどこから来ているのでしょうか?この疑問に答えるために、HelloWeb.ConnCase を見てみましょう。

ConnCase

test/support/conn_case.ex を開くと、次のようなものがあります(コメントは削除されています)

defmodule HelloWeb.ConnCase do
  use ExUnit.CaseTemplate

  using do
    quote do
      # Import conveniences for testing with connections
      import Plug.Conn
      import Phoenix.ConnTest
      alias HelloWeb.Router.Helpers, as: Routes

      # The default endpoint for testing
      @endpoint HelloWeb.Endpoint
    end
  end

  setup tags do
    pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Demo.Repo, shared: not tags[:async])
    on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)

    %{conn: Phoenix.ConnTest.build_conn()}
  end
end

説明が必要なものがたくさんあります。

2行目には、これはケーステンプレートだと書かれています。これはExUnitの機能で、開発者は組み込みの use ExUnit.Case を自分のケースに置き換えることができます。この行のおかげで、コントローラーのテストの先頭に use HelloWeb.ConnCase を書くことができるようになりました。

さて、このモジュールをケーステンプレートにしたので、特定の場面で呼び出されるコールバックを定義してみましょう。using コールバックは、use HelloWeb.ConnCase を呼び出すすべてのモジュールに注入されるコードを定義します。この場合、コントローラーで利用できるコネクションヘルパーはすべてテストでも利用できるように、Plug.Connをインポートしてから、Phoenix.ConnTestをインポートします。これらのモジュールを参照して、利用可能なすべての機能を学ぶことができます。

そして、すべてのパスヘルパーでモジュールをエイリアス化するので、テストで簡単にURLを生成できます。最後に、モジュールの @endpoint 属性にエンドポイントの名前を設定します。

そして、ケーステンプレートでは setup ブロックを定義します。この setup ブロックはテストの前に呼び出されます。セットアップブロックの大部分はSQLサンドボックスの設定に関するもので、これについては後ほど説明します。setup ブロックの最後の行には、次のような記述があります。

%{conn: Phoenix.ConnTest.build_conn()}

setup の最後の行は、各テストで利用可能なテストのメタデータを返すことができます。ここで渡すメタデータは新しくビルドされた Plug.Conn です。このテストでは、テストの最初にこのメタデータからconnを抽出します。

test "GET /", %{conn: conn} do

これがconnの由来です!最初のうちは、テスト構造には多少の間接性がありますが、この間接性はテストスイートの成長に伴って効果を発揮します。

Viewのテスト

アプリケーション内の他のテストファイルは、ビューのテストを担当しています。

エラービューのテストケース test/hello_web/views/error_view_test.exs は、それ自体がいくつかの興味深いことを示しています。

defmodule HelloWeb.ErrorViewTest do
  use HelloWeb.ConnCase, async: true

  # Bring render/3 and render_to_string/3 for testing custom views
  import Phoenix.View

  test "renders 404.html" do
    assert render_to_string(HelloWeb.ErrorView, "404.html", []) ==
           "Not Found"
  end

  test "renders 500.html" do
    assert render_to_string(HelloWeb.ErrorView, "500.html", []) ==
           "Internal Server Error"
  end
end

HelloWeb.ErrorViewTestasync: true を設定し、このテストケースが他のテストケースと並行して実行されることを意味します。ケース内の個々のテストは依然として連続的に実行されますが、これは全体のテスト速度を大幅に向上させることができます。

また、render_to_string/3 関数を使うために Phoenix.View をインポートしています。これで、すべてのアサーションは単純な文字列の等価テストになります。

ページビューケース test/hello_web/views/page_view_test.exs にはデフォルトではテストは含まれていませんが、HelloWeb.PageView モジュールに関数を追加する必要がある場合には、これを利用できます。

defmodule HelloWeb.PageViewTest do
  use HelloWeb.ConnCase, async: true
end

ディレクトリ/ファイルごとにテストを実行する

テストが何をしているかわかったので、それらを実行するためのさまざまな方法を見てみましょう。

このガイドの最初の方で見たように、mix test で一連のテスト全体を実行できます。

$ mix test
....

Finished in 0.2 seconds
3 tests, 0 failures

Randomized with seed 540755

たとえば test/hello_web/controllers のように、指定したディレクトリですべてのテストを実行したい場合は、そのディレクトリへのパスを mix test に渡すことができます。

$ mix test test/hello_web/controllers/
.

Finished in 0.2 seconds
1 tests, 0 failures

Randomized with seed 652376

特定のファイル内のすべてのテストを実行するためには、そのファイルのパスを mix test に渡すことができます。

$ mix test test/hello_web/views/error_view_test.exs
...

Finished in 0.2 seconds
2 tests, 0 failures

Randomized with seed 220535

そして、ファイル名にコロンと行番号を追加することで、ファイル内の単一のテストを実行できます。

たとえば、HelloWeb.ErrorView500.html をどのようにレンダリングするかだけのテストを実行したいとしましょう。テストはファイルの11行目から始まっているので、次のようにします。

$ mix test test/hello_web/views/error_view_test.exs:11
Including tags: [line: "11"]
Excluding tags: [:test]

.

Finished in 0.1 seconds
2 tests, 0 failures, 1 excluded

Randomized with seed 288117

ここではテストの最初の行を指定して実行することにしましたが、実際にはテストのどの行でも実行できます。これらの行番号はすべて動作します - :11, :12, :13 です。

タグを利用したテストの実行

ExUnitでは、テストに個別に、あるいはモジュール全体にタグをつけることができます。特定のタグをつけたテストだけを実行することもできますし、 そのタグをつけたテストを除外してそれ以外のテストを実行することもできます。

これがどう動くのか実験してみましょう。

まず、test/hello_web/views/error_view_test.exs@moduletag を追加します。

defmodule HelloWeb.ErrorViewTest do
  use HelloWeb.ConnCase, async: true

  @moduletag :error_view_case
  ...
end

モジュールタグにアトムだけを指定した場合は、ExUnitはその値が true であるとみなします。別の値を指定することもできます。

defmodule HelloWeb.ErrorViewTest do
  use HelloWeb.ConnCase, async: true

  @moduletag error_view_case: "some_interesting_value"
  ...
end

ここでは、単純なアトム @moduletag :error_view_case として残しておきましょう。

mix test--only error_view_case を渡すことで、エラービューケースからのテストのみを実行できます。

$ mix test --only error_view_case
Including tags: [:error_view_case]
Excluding tags: [:test]

...

Finished in 0.1 seconds
3 tests, 0 failures, 1 excluded

Randomized with seed 125659

注意: ExUnitは、テストの実行ごとにどのタグを含めたり除外したりしているのかを正確に教えてくれます。先ほどのテストの実行の節を見てみると、 個々のテストで指定した行番号が実際にはタグとして扱われていることがわかります。

$ mix test test/hello_web/views/error_view_test.exs:11
Including tags: [line: "11"]
Excluding tags: [:test]

.

Finished in 0.2 seconds
2 tests, 0 failures, 1 excluded

Randomized with seed 364723

error_view_casetrue を指定しても同じ結果が得られます。

$ mix test --only error_view_case:true
Including tags: [error_view_case: "true"]
Excluding tags: [:test]

...

Finished in 0.1 seconds
3 tests, 0 failures, 1 excluded

Randomized with seed 833356

しかし、error_view_casefalse を指定しても、システム内に error_view_case: false にマッチするタグがないため、テストは実行されません。

$ mix test --only error_view_case:false
Including tags: [error_view_case: "false"]
Excluding tags: [:test]



Finished in 0.1 seconds
3 tests, 0 failures, 3 excluded

Randomized with seed 622422
The --only option was given to "mix test" but no test executed

同様の方法で --exclude フラグを使うことができます。これはエラービューの場合を除いてすべてのテストを実行します。

$ mix test --exclude error_view_case
Excluding tags: [:error_view_case]

.

Finished in 0.2 seconds
3 tests, 0 failures, 2 excluded

Randomized with seed 682868

タグに値を指定する方法は --exclude でも --only と同じです。

完全なテストケースだけでなく、個々のテストにもタグを付けることができます。これがどのように動作するのか、エラービューのケースにいくつかのテストをタグ付けしてみましょう。

defmodule HelloWeb.ErrorViewTest do
  use HelloWeb.ConnCase, async: true

  @moduletag :error_view_case

  # Bring render/3 and render_to_string/3 for testing custom views
  import Phoenix.View

  @tag individual_test: "yup"
  test "renders 404.html" do
    assert render_to_string(HelloWeb.ErrorView, "404.html", []) ==
           "Not Found"
  end

  @tag individual_test: "nope"
  test "renders 500.html" do
    assert render_to_string(HelloWeb.ErrorView, "500.html", []) ==
           "Internal Server Error"
  end
end

値に関係なく individual_test としてタグ付けされたテストのみを実行したい場合は、これが有効です。

$ mix test --only individual_test
Including tags: [:individual_test]
Excluding tags: [:test]

..

Finished in 0.1 seconds
3 tests, 0 failures, 1 excluded

Randomized with seed 813729

また、値を指定して、その値でのみテストを実行することもできます。

$ mix test --only individual_test:yup
Including tags: [individual_test: "yup"]
Excluding tags: [:test]

.

Finished in 0.1 seconds
3 tests, 0 failures, 2 excluded

Randomized with seed 770938

同様に、与えられた値でタグ付けされたもの以外のすべてのテストを実行できます。

$ mix test --exclude individual_test:nope
Excluding tags: [individual_test: "nope"]

...

Finished in 0.2 seconds
3 tests, 0 failures, 1 excluded

Randomized with seed 539324

より具体的には、individual_test でタグ付けされた値が "yup" であるテストを除いて、すべてのテストをエラービューのケースから除外できます。

$ mix test --exclude error_view_case --include individual_test:yup
Including tags: [individual_test: "yup"]
Excluding tags: [:error_view_case]

..

Finished in 0.2 seconds
3 tests, 0 failures, 1 excluded

Randomized with seed 61472

最後に、デフォルトでタグを除外するようにExUnitを設定します。デフォルトのExUnitの設定は test/test_helper.exs ファイルで行います。

ExUnit.start(exclude: [error_view_case: true])

Ecto.Adapters.SQL.Sandbox.mode(Hello.Repo, :manual)

これで、mix test を実行すると、page_controller_test.exs から1つのspecだけが実行されるようになりました。

$ mix test
Excluding tags: [error_view_case: true]

.

Finished in 0.2 seconds
3 tests, 0 failures, 2 excluded

Randomized with seed 186055

この動作を --include フラグでオーバーライドし、mix testerror_view_case でタグ付けされたテストを含めるように指示します。

$ mix test --include error_view_case
Including tags: [:error_view_case]
Excluding tags: [error_view_case: true]

....

Finished in 0.2 seconds
3 tests, 0 failures

Randomized with seed 748424

このテクニックは、CIや特定のシナリオでしか実行したくないような、非常に長い時間実行されるテストを制御するのに非常に便利です。

ランダム化

テストをランダムな順序で実行することは、テストが本当に分離されていることを保証する良い方法です。あるテストで散発的に失敗することに気がついた場合、それは前のテストでシステムの状態を変更したために、その後のテストに影響を与えてしまったからかもしれません。これらの失敗は、テストが特定の順序で実行された場合にのみ現れるかもしれません。

ExUnitはデフォルトで整数のシードを利用してテストの実行順をランダム化します。特定のランダムなシードが断続的な失敗の引き金になっていることに気づいた場合は、 同じシードでテストを再実行することで、そのテストの順番を確実に再現して問題の原因を突き止めることができます。

$ mix test --seed 401472
....

Finished in 0.2 seconds
3 tests, 0 failures

Randomized with seed 401472

並列処理とパーティショニング

これまで見てきたように、ExUnitは開発者がテストを同時に実行できるようにします。これにより、開発者はマシンのパワーをすべて使ってテストスイートを可能な限り高速に実行できるようになります。これにPhoenixのパフォーマンスを組み合わせると、ほとんどのテストスイートは他のフレームワークと比べてほんのわずかな時間でコンパイルして実行できます。

通常、開発者は開発中に強力なマシンを使用できるようにしていますが、Continuous Integrationサーバーでは必ずしもそうとは限りません。そのため、ExUnitはテスト環境のテストパーティショニングもサポートしています。config/test.exs を開くと、データベース名の設定があります。

database: "hello_test#{System.get_env("MIX_TEST_PARTITION")}",

デフォルトでは、環境変数 MIX_TEST_PARTITION は何も値を持ちません。しかし、CIサーバーでは、たとえば、4つの異なるコマンドを使うことで、テストスイートを複数のマシンに分割できます。

$ MIX_TEST_PARTITION=1 mix test --partitions 4
$ MIX_TEST_PARTITION=2 mix test --partitions 4
$ MIX_TEST_PARTITION=3 mix test --partitions 4
$ MIX_TEST_PARTITION=4 mix test --partitions 4

異なった名前のパーティションごとにデータベースを設定することを含めて、あとはExUnitとPhoenixが面倒を見てくれます。

さらに深掘る

ExUnitはシンプルなテストフレームワークですが、mix test コマンドを使うことで非常に柔軟で堅牢なテストランナーを提供します。mix help test を実行したり、オンラインのドキュメントを読むことをオススメします。

新しく生成されたアプリを使って、Phoenixが何を提供してくれるかを見てきました。さらに、新しいリソースを生成するたびに、Phoenixはそのリソースに適したすべてのテストを生成します。たとえば、アプリケーションのルートで以下のコマンドを実行することで、スキーマ、コンテキスト、コントローラー、ビューを含む完全なスキャフォールドを作成できます。

$ mix phx.gen.html Blog Post posts title body:text
* creating lib/demo_web/controllers/post_controller.ex
* creating lib/demo_web/templates/post/edit.html.heex
* creating lib/demo_web/templates/post/form.html.heex
* creating lib/demo_web/templates/post/index.html.heex
* creating lib/demo_web/templates/post/new.html.heex
* creating lib/demo_web/templates/post/show.html.heex
* creating lib/demo_web/views/post_view.ex
* creating test/demo_web/controllers/post_controller_test.exs
* creating lib/demo/blog/post.ex
* creating priv/repo/migrations/20200215122336_create_posts.exs
* creating lib/demo/blog.ex
* injecting lib/demo/blog.ex
* creating test/demo/blog_test.exs
* injecting test/demo/blog_test.exs

Add the resource to your browser scope in lib/demo_web/router.ex:

    resources "/posts", PostController


Remember to update your repository by running migrations:

    $ mix ecto.migrate

それでは、指示にしたがって lib/hello_web/router.ex ファイルに新しいリソースルートを追加し、マイグレーションを実行してみましょう。

再び mix test を実行すると、19個のテストがあることがわかります!

$ mix test
................

Finished in 0.1 seconds
19 tests, 0 failures

Randomized with seed 537537

この時点で、我々はテストガイドの残りの部分に進むには絶好の場所にあり、その中で我々はこれらのテストをはるかに詳細に検討し、いくつかのテストを追加しましょう。