やること
これまで作ったデモアプリのテストを確認する.
プロジェクトのソースはこちらにあります.
Elixirの単体テストフレームワークExUnit超入門
プロジェクト作成からテスト実行まで
まず, デフォルトのElixirプロジェクトを作る.
$ mix new hoge_project
すると以下のような構成のディレクトリが作られると思う.
$ tree -L 1
.
├── README.md
├── _build
├── config
├── lib
├── mix.exs
└── test
このlibディレクトリ以下に, 新たに作成するモジュールを格納し, testディレクトリ以下には
それらのモジュールに対するテストモジュールを格納する.
例えば、すごくつまらない例になるが, 足し算機能を提供するモジュールとそれに対するテストを用意したい場合, libディレクトリにCalcモジュールを作成し, textディレクトリにそれに対するCalcTestモジュールを作ることになる.
defmodule Calc do
def add(x, y) do
x + y
end
end
defmodule CalcTest do
use ExUnit.Case
setup_all do
IO.puts "now setup_all"
end
setup do
IO.puts "now setup"
{:ok, three: 3, four: 4}
end
test "Calc 1 + 2" do
assert Calc.add(1, 2) == 3
end
test "try failed" do
assert Calc.add(1, 2) == 4
end
test "use setuped context", context do
assert Calc.add(context[:three], context[:four]) == 7
end
end
テスト実行はmixコマンドのtestタスクで行う.
実際にテストをしてみると...
$ mix test
.now setup_all
now setup
now setup
.now setup
1) test try failed (CalcTests)
test/calc_test.exs:17
Assertion with == failed
code: Calc.add(1, 2) == 4
lhs: 3
rhs: 4
stacktrace:
test/calc_test.exs:18
.
Finished in 0.1 seconds (0.1s on load, 0.01s on tests)
4 tests, 1 failure
Randomized with seed 905035
$
このようにテストが実行されて, 1つのテストに失敗している結果が表示される.
ちなみに, mix test <path>
で指定したファイルのみのテストもできる.
テストモジュールをもう少し詳しく見てみる
まず, ExUnit.Caseをロードする.
use ExUnit.Case
これでテスト用の各種マクロが使えるようになる.
テストの定義はtest message do ~
の部分
test "Calc 1 + 2" do
assert Calc.add(1, 2) == 3
end
この構文はRubyの単体テストフレームワークにもあったと思うので, そちらを使ったことがある人には
違和感なく使えるんじゃないでしょうか.
使えるassertionは[こんな感じ] (http://elixir-lang.org/docs/stable/ex_unit/ExUnit.Assertions.html)
各種テスト前に共通処理を差し込みたい場合は, setupで定義する
setup_all do
IO.puts "now setup_all"
end
setup do
IO.puts "now setup"
{:ok, three: 3, four: 4}
end
setupで定義された処理は各テストごとに, setup_allで定義された処理は最初に一回だけ実行される.
また
setup do
IO.puts "now setup"
{:ok, three: 3, four: 4}
end
setupでこのようなdictionaryを返すと,
test "use setuped context", context do
assert Calc.add(context[:three], context[:four]) == 7
end
このように, テストのほうではcontext経由で値を取得することができる.
なお, teardownは存在しないが, かわりにon_exitという関数があるようだ。(挙動がよくわからなかったが)
これらについての詳細も, こちらを読んでください.
Phoenixプロジェクトでの単体テスト
テストの準備
Phoenixプロジェクトでは, 単体テストは単体テスト用の環境でテストが行われる.
使用される設定ファイルは, test/config/test.exs
なので、まずこれをを編集する.
use Mix.Config
# We don't run a server during test. If one is required,
# you can enable the server option below.
config :phoenix_sample, PhoenixSample.Endpoint,
http: [port: 4001],
server: false
# Print only warnings and errors during test
config :logger, level: :warn
# Configure your database
config :phoenix_sample, PhoenixSample.Repo,
adapter: Ecto.Adapters.MySQL,
username: "phoenix",
password: "phoenix",
database: "phoenix_sample_test",
hostname: "localhost",
pool: Ecto.Adapters.SQL.Sandbox
とりあえず, 使用するDBとユーザのみを変えておく.
なお, ここで指定したDBの作成とマイグレーションはテスト実行前に呼び出される
test/test_hepler.ex
にて行われるので, 事前に用意する必要はない.
ExUnit.start
Mix.Task.run "ecto.create", ~w(-r PhoenixSample.Repo --quiet)
Mix.Task.run "ecto.migrate", ~w(-r PhoenixSample.Repo --quiet)
Ecto.Adapters.SQL.begin_test_transaction(PhoenixSample.Repo)
あとは, mix test
としてやればテストは実行されるでしょう.
モデルのテスト
mixのジェネレータでモデルを作成すると、それに対応するテストモジュールもtest/modes/
以下に生成される.
Postモデルを作成した時に, 実際に生成されたテストを見てみると, 以下の様な内容だった.
defmodule PhoenixSample.PostTest do
use PhoenixSample.ModelCase
alias PhoenixSample.Post
@valid_attrs %{content: "some content", title: "some content"}
@invalid_attrs %{}
test "changeset with valid attributes" do
changeset = Post.changeset(%Post{}, @valid_attrs)
assert changeset.valid?
end
test "changeset with invalid attributes" do
changeset = Post.changeset(%Post{}, @invalid_attrs)
refute changeset.valid?
end
end
デフォルトでは, valids関数を対象としたテストが作られています.
せっかくなので, 記事モデルに本文内容を先頭10文字に切り詰めた文字列を返す関数を追加してみることにします.
以下のテストを追加して
test "get truncated content" do
post = %Post{id: 1, title: "長い本文", content: "この記事は10文字以上の長さがあるよ"}
assert Post.truncated(post.content) == "この記事は10文字以..."
end
test "get not truncated content" do
post = %Post{id: 2, title: "短い本文", content: "この記事は8文字"}
assert Post.truncated(post.content) == "この記事は8文字"
end
再度テストを実行すると...
$ mix test test/models/post_test.exs
1) test get truncated content (PhoenixSample.PostTest)
test/models/post_test.exs:19
** (KeyError) key :truncated not found in: %PhoenixSample.Post{__meta__: #Ecto.Schema.Metadata<:built>, comments: #Ecto.Association.NotLoaded<association :comments is not loaded>,\
content: "この記事は10文字以上の長さがあるよ", id: 1, inserted_at: nil, title: "長い本文", updated_at: nil}
stacktrace:
test/models/post_test.exs:21
.
2) test get not truncated content (PhoenixSample.PostTest)
test/models/post_test.exs:24
** (KeyError) key :truncated not found in: %PhoenixSample.Post{__meta__: #Ecto.Schema.Metadata<:built>, comments: #Ecto.Association.NotLoaded<association :comments is not loaded>,\
content: "この記事は8文字", id: 2, inserted_at: nil, title: "短い本文", updated_at: nil}
stacktrace:
test/models/post_test.exs:26
.
Finished in 0.3 seconds (0.3s on load, 0.01s on tests)
4 tests, 2 failures
Randomized with seed 591922
当然, まだ関数は未定義なのでエラー.
記事モデルに関数を追加して
@doc """
引数の文字列が10文字を超えていた場合、それを切り詰める
"""
def truncate(content) do
if String.length(content) > 9 do
String.slice(content, 0..9) <> "..."
else
content
end
end
再度テスト実行
$ mix test test/models/post_test.exs
Compiled web/controllers/comment_controller.ex
....
Finished in 0.2 seconds (0.2s on load, 0.00s on tests)
4 tests, 0 failures
Randomized with seed 864661
今度は:ok
DBを扱う
ここまでで, 関数とテストの追加はできたのですが, モデルのインスタンスを直接生成してしまっていていて面白くない.
なので, インスタンスの取得をDBから行うようにしたいと思う.
まずテストで, テスト対象をDBからデータを取得するように変える
test "get truncated content" do
# post = %Post{id: 1, title: "長い本文", content: "この記事は10文字以上の長さがあるよ"}
post = Repo.get!(Post, 1)
assert Post.truncate(post.content) == "この記事は10文字以..."
end
test "get not truncated content" do
# post = %Post{id: 2, title: "短い本文", content: "この記事は8文字"}
post = Repo.get!(Post, 2)
assert Post.truncate(post.content) == "この記事は8文字"
end
リポジトリは, テスト用に用意されているModelCaseモジュールの中でロードされているらしく, 特に追加の宣言は必要なく使える.
そして, テスト実行
$ mix test test/models/post_test.exs
..
1) test get truncated content (PhoenixSample.PostTest)
test/models/post_test.exs:19
** (Ecto.NoResultsError) expected at least one result but got none in query:
from p in PhoenixSample.Post,
where: p.id == ^1
stacktrace:
(ecto) lib/ecto/repo/queryable.ex:57: Ecto.Repo.Queryable.one!/4
...
データは未投入なので, 当然対象のレコードが見つからないというエラー.
データ投入用のsetup関数を追加してやる.
# initilize test data
setup do
Repo.insert! %Post{id: 1, title: "長い本文", content: "この記事は10文字以上の長さがあるよ"}
Repo.insert! %Post{id: 2, title: "短い本文", content: "この記事は8文字"}
:ok
end
そして, テスト実行
$ mix test test/models/post_test.exs
....
Finished in 0.3 seconds (0.3s on load, 0.07s on tests)
4 tests, 0 failures
Randomized with seed 857481
こちらも:ok
EctoFixturesを使う
DBと連携したモデルのテストができるようになったのだが, データ投入にRepoの関数を直接呼び出しているので非常に記述がくどい.
できたらテストデータは他のファイルにまとめておきたい.
さいわい, EctoFixturesというライブラリの開発が行われているようなので試しに使ってみることにする.
まずmix.exsの依存ライブラリにecto_fituresを追加し
defp deps do
[{:phoenix, "~> 1.1.4"},
{:mariaex, ">= 0.0.0"},
{:phoenix_ecto, "~> 2.0"},
{:phoenix_html, "~> 2.4"},
{:phoenix_live_reload, "~> 1.0", only: :dev},
{:gettext, "~> 0.9"},
{:cowboy, "~> 1.0"},
{:ecto_fixtures, github: "DockYard/ecto_fixtures"}] # <- 追加
end
ecto_fixturesはhexでも公開されているが, バージョンが古いままなので, githubのリポジトリから直接取得することにする.
そして, mix get.deps
実行
$ mix deps.get
* Getting ecto_fixtures (https://github.com/DockYard/ecto_fixtures.git)
Cloning into '/home/vagrant/phoenix_sample/deps/ecto_fixtures'...
remote: Counting objects: 587, done.
remote: Total 587 (delta 0), reused 0 (delta 0), pack-reused 586
Receiving objects: 100% (587/587), 84.08 KiB | 52.00 KiB/s, done.
Resolving deltas: 100% (312/312), done.
Checking connectivity... done.
Running dependency resolution
Dependency resolution completed
uuid: 1.1.3
テストデータはtest/fixtures/
以下に配置するようなので, そこに以下の様なファイルを作成
posts model: PhoenixSample.Post, repo: PhoenixSample.Repo do
long do
title "長い本文"
content "この記事は10文字以上の長さがあるよ"
end
short do
title "短い本文"
content "この記事は8文字"
end
end
次にテストで, ライブラリをロードし
use EctoFixtures
テストデータを使いたいテストの頭にタグを追加
@tag fixtures: :posts
test "get truncated content", %{data: data} do
この部分は多分マクロで何らかのsetupに展開されているのだと思います.
DBからデータ取得する際に指定するIDを, fixtureから取得するように変更する
# post = Repo.get!(Post, 1)
post = Repo.get!(Post, data.posts.long.id)
テスト全体としては, 以下のようになる.
@tag fixtures: :posts
test "get truncated content", %{data: data} do
post = Repo.get!(Post, data.posts.long.id)
assert Post.truncate(post.content) == "この記事は10文字以..."
end
最後に, 今までデータ投入を行っていたsetup関数を削除して完成.
テストを実行してみると
$ mix test test/models/post_test.exs
....
Finished in 0.4 seconds (0.3s on load, 0.1s on tests)
4 tests, 0 failures
Randomized with seed 723406
:ok
最終的に出来上がったテストは全体はこちらを見てください.
コントローラのテスト
コントローラも, モデルと同様にgeneratorで生成した時点で対応するテストが生成される.
なお, ここで生成されるテストはいわゆる機能テストと呼ばれるものになる(と思う).
本当にコントローラ単体のテストが欲しい場合は, 依存先モデルをMockに差し替える仕組みやらなんやらを用意してください.
実際生成されるテストはこんな感じ.
setup %{conn: conn} do
{:ok, conn: put_req_header(conn, "accept", "application/json")}
end
test "lists all entries on index", %{conn: conn} do
conn = get conn, api_post_path(conn, :index)
assert json_response(conn, 200)["data"] == []
end
見ての通り, RouterHelperのapi_post_path関数を使って得たURIに対してGETリクエストを行い, 結果をjsonデコードして結果を比較しているテストです.
もっとも, 結果の比較対象が空のリストになっている点からもわかるように, あくまで雛形ですが。
他のCRUDについてもテストの雛形は作られるので, 真似して書いていけばテストを増やしていくのは難しいことではないと思われます.
結果の正しさの定義が容易なAPIのテストを行いたい場合は, こちらを使っていくのもいいのではないでしょうか.
(HTMLを返すインタフェイスのテストは正しさの定義が面倒過ぎていやになります...)
最後に
今回は、ExUnitという、いわゆるxUnitの系譜に連なるテストフレームワークを使いましたが、
これとは別に、ESpecというRubyのRSpecの影響を受けたテストフレームワークもあるようなので
機会があったそちらも試してみたいと思います
参考サイト
ExUnit : http://elixir-lang.org/docs/stable/ex_unit/ExUnit.html
EctoFixtures : https://github.com/DockYard/ecto_fixtures