LoginSignup
23
21

More than 5 years have passed since last update.

Elixir+Phoenixプロジェクトで単体テストを行う

Posted at

やること

これまで作ったデモアプリのテストを確認する.
プロジェクトのソースはこちらにあります.

Elixirの単体テストフレームワークExUnit超入門

プロジェクト作成からテスト実行まで

まず, デフォルトのElixirプロジェクトを作る.

$ mix new hoge_project

すると以下のような構成のディレクトリが作られると思う.

$ tree -L 1
.
├── README.md
├── _build
├── config
├── lib
├── mix.exs
└── test

このlibディレクトリ以下に, 新たに作成するモジュールを格納し, testディレクトリ以下には
それらのモジュールに対するテストモジュールを格納する.

例えば、すごくつまらない例になるが, 足し算機能を提供するモジュールとそれに対するテストを用意したい場合, libディレクトリにCalcモジュールを作成し, textディレクトリにそれに対するCalcTestモジュールを作ることになる.

lib/calc.ex
defmodule Calc do

  def add(x, y) do
    x + y
  end

end
test/calc_test.ex
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はこんな感じ

各種テスト前に共通処理を差し込みたい場合は, 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なので、まずこれをを編集する.

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にて行われるので, 事前に用意する必要はない.

test/test_helper.exs
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モデルを作成した時に, 実際に生成されたテストを見てみると, 以下の様な内容だった.

test/models/post_test.exs
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/models/post_test.exs
  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

当然, まだ関数は未定義なのでエラー.
記事モデルに関数を追加して

web/models/post.ex

  @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/models/post.ex
  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関数を追加してやる.

test/models/post_model.ex
  # 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を追加し

mix.exs
  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/以下に配置するようなので, そこに以下の様なファイルを作成

test/fixtures/posts.exs
posts model: PhoenixSample.Post, repo: PhoenixSample.Repo do
  long do
    title "長い本文"
    content "この記事は10文字以上の長さがあるよ"
  end
  short do
    title "短い本文"
    content "この記事は8文字"
  end
end

次にテストで, ライブラリをロードし

test/models/post_test.ex
 use EctoFixtures

テストデータを使いたいテストの頭にタグを追加

test/models/post_test.ex
  @tag fixtures: :posts
  test "get truncated content", %{data: data} do

この部分は多分マクロで何らかのsetupに展開されているのだと思います.
DBからデータ取得する際に指定するIDを, fixtureから取得するように変更する

test/models/post_test.ex
#    post = Repo.get!(Post, 1)
    post = Repo.get!(Post, data.posts.long.id)

テスト全体としては, 以下のようになる.

test/models/post_test.ex
  @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に差し替える仕組みやらなんやらを用意してください.

実際生成されるテストはこんな感じ.

test/controllers/api_post_controller.ex
  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

23
21
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
23
21