Play Frameowrk2.x Javaのテスト入門 ~準備からモデルまで〜

この記事はPlay framework 2.x Java Advent Calendar 2013の12/4分です。

はじめに

最近Play Frameworkの1系と2系を触る機会があり、どうやらこのWAFにはテストサポート機能があるらしいということで、せっかくなのでテスト・ファーストでアプリを書いてみようと思い立ちました。

公式サイトを訪れたところ、テストについて書いてあるページは2ページしかなく、チュートリアルのzentasksは説明の中でテストを一部書いているものの、Githubのリポジトリをみてみたらテストがありません。

これではplay初心者には、テストを書くのが大変辛い。
Play Java2系な人にテストを書いてもらって、ブログとかにどんどんノウハウをためていって欲しいということで、JUnitでのテスト入門です。

偉そうに書いていますが、PlayもJUnitも未熟なので、こうした方が良いとか、クソコードだとか突っ込みもらえるとうれしいです。

1. テストの実行

playのテストはコマンドライン、もしくはIDEで実行することができます。
IDEでJUnitを動かすには、Run As => JUnit testで実行します。

コマンドラインは以下の2通りで実行できます。

$ play test

もしくは

$ play
[projectName] $ test

テストが増えてきて、開発している特定のテストだけを実行したい場合は、playのコンソールモードでtestOnlyで指定して実行できます。一度全テストを実行した後であれば、testOnlyの後でtabを押すと引数で指定できるクラスの一覧が表示されます。
地味に便利。

$ testOnly targetTestClassName

2.AssertThatを使う

いまどきのテストコードはassertEqualsとかではなく、自然言語の表現に近いassertThatを使います。

playではHamcrestをわざわざ追加する必要はありません。
Festがもともと入っているので、以下のように宣言するだけでassertThatが使えます。

import static org.fest.assertions.Assertions.assertThat;

ところで、日付のテストをしようとした場合、オレオレ拡張をしたライブラリを作ること、あると思います。実はFestの2系を入れるとこんな感じでisToday()とかisBefore()とか書けてしまいますので、車輪の再発明しなくてすみます。

折角なのでsbtを使って入れてしまいましょう。

3.build.sbtを使う

ということで、早速Festの2系をインストールします。

name := "playTest"

version := "1.0-SNAPSHOT"

libraryDependencies ++= Seq(
  javaJdbc,
  javaEbean,
  cache,
  "org.easytesting" % "fest-assert-core" % "2.0M10" % "test"
)

play.Project.playJavaSettings

これでplay runとかすれば、2系のライブラリを使えるようになります。どうしてもHamcrestが使いたい人は同様にbuild.sbtに追加してください。

  "org.hamcrest" % "hamcrest-core" % "1.3" % "test",
  "org.hamcrest" % "hamcrest-library" % "1.3" % "test"

Eclipseとかを使っている方は、sbtで追加したライブラリは勝手には読み込んでくれませんでしたので、playがインストールされているディレクトリのlibexec/repository/cacheあたりにあるjarを追加してください。

4.モデルのテスト

さて、準備はととのったということで、早速モデルのテスト書いてみます。 以下のようなUserモデルがあるとして、findByEmailメソッドのテストを書いてみます。

package models;

import java.util.Date;

import javax.persistence.*;
import play.db.ebean.*;

@Entity
public class User extends Model {

    @Id
    public Long id;
    public String name;

    @Column(unique=true)
    public String email;
    public String password;

    public Date createdAt;
    public Date updatedAt;

    public User(String name, String email, String password) {
        this.name = name;
        this.email = email;
        this.password = password;

        this.createdAt = new Date();
        this.updatedAt = new Date();
    }

    public static Finder<Long, User> find
        = new Finder<Long, User>(Long.class, User.class);

    public static User findByEmail(String email) {
        return User.find.where().eq("email", email).findUnique();
    }
}

テストコードは、プロジェクト直下のtestディレクトリ以下に作っていきます。
デフォルトでは、 ApplicationTest.javaとIntegrationTest.javaがありますが、ここに直接テストファイルをどんどん置いていくと管理が煩雑で、意味/機能分けがやりにくいのでpackegeを切っておくと良いと思います。
私はプロダクション・コードとの対応関係がわかりやすいようにappsディレクトリと同じ構造でパッケージを切っています。

test
└── models
    └── UserTest.java

モデルのテストをするにはDBが必要です。テスト用のRDBのテーブルを準備するのもありですが、単体テストを書くには管理を考えると少々過剰です。 簡単便利なインメモリDBを使いましょう。

start(fakeApplication(inMemoryDatabase()));

正常系のテストを書くとするとこんな感じでしょうか。

package models;

import models.User;
import org.junit.*;

import static org.fest.assertions.Assertions.assertThat;
import static play.test.Helpers.*;

public class UserTest {
    @Test
    public void findByEmail() {
        String email = "alice@email.com";
        start(fakeApplication(inMemoryDatabase()));
        User user = new User("alice", email, "password");
        user.save();

        User actual = User.findByEmail(email);

        assertThat(actual.email).isEqualTo(email);
    }
}

このテストをコンソールで実行すると無事にテストが通ります。 ただし、多くの人がIDEでJUnitを実行すると思いますが、残念ながらそのままだとEbeanでエラーが出てしまいます。
この問題はSampoさんが解決方法を書いていらっしゃいます。 が、自分は普段Eclipseを使わないため、やり方がわからず苦労しました。。。

動くように変更するには、以下の手順で設定します。

  1. メニューから"Run"を選択
  2. "Run Configurations..."を選択
  3. 対象のUnitTestのArgumentsのVM argumentsに以下を設定します。
    /path/toのところは、各自のpathを指定してください。
-javaagent:/path/to/avaje-ebeanorm-agent.jar

無事動いたところで、異常系のテストも追加します。
事前処理で共通するものは@Beforeでまとめてしまいます。

public class UserTest {
    static final String EMAIL = "alice@email.com";

    @Before
    public void setUp() {
        start(fakeApplication(inMemoryDatabase()));
        User user = new User("alice", EMAIL, "password");
        user.save();
    }

    @Test
    public void findByEmail() {
        User actual = User.findByEmail(EMAIL);

        assertThat(actual.email).isEqualTo(EMAIL);
    }

    @Test
    public void dontFindByInvalidEmail() {
        String typoEmail = EMAIL + ".";
        User actual = User.findByEmail(typoEmail);

        assertThat(actual).isNull();
    }
}

モデルのテストが増えていったら@RunWith(Enclosed.class)で、階層化して分けていくと良いでしょう。

今回のテストは1つのユーザーデータだけですんだので良かったですが、データのバリエーションテストをしたい場合に、毎回new User()とかやっていられないです。 そんなときはFixtureです。YAMLで定義してあげると可読性も高くて管理しやすいです。

5.Fixtureでデータ管理

play2系のfixture用yamlファイルはconfディレクトリに作ります。 play1系は、yamlファイルをtestディレクトリに配置していたため、動かなくてはまりました・・・。

yamlをconf以下に作るのはいいですが、そのうちテストごとにyamlファイルを作りたくなると思います。そうするとconfディレクトリがカオスになってしまうので、私はconfディレクトリ内にtestDataディレクトリを作って管理しています。

conf
└── testData
    └── users.yml

Userモデルのyamlサンプルを以下に記載します。 なお、1つのyamlファイルに複数のデータを書くこともできます。

- &alice !!models.User
    name:      Alice
    email:     alice@email.com
    password:  password
    createdAt: 2013-01-01 00:00:00
    updatedAt: 2013-01-01 00:00:00

- &bob !!models.User
    name:      Bob
    email:     bob@email.com
    password:  password
    createdAt: 2013-01-01 00:00:00
    updatedAt: 2013-01-01 00:00:00

このYAMLファイルをテストで使うには以下のように書きます。

Ebean.save((List) Yaml.load("testData/users.yml"));

今回の例では独立したモデルですが、当たり前のようにOneToManyとかのリレーションを持たせたくなると思います。この場合もYAMLでデータを表現することができます。

例えば、このUserモデルがCompanyモデルと結合している場合は、以下のように書くことができます。

- &bobCorp !!models.Company
    corpId: 000001
    name:   bobCorporation
    phone:  00-0000-0000

- &alice !!models.User
    name:      Alice
    email:     alice@email.com
    password:  password
    company:   *bobCorp
    createdAt: 2013-01-01 00:00:00
    updatedAt: 2013-01-01 00:00:00

もしくは、一意になる情報を使って引っ張ってくることも可能です。その場合は、Userモデルのcompanyの部分を以下のように書いてあげます。

    company:  !!models.Company
                     corpId: 000001

ただし、読みやすさを考えると、前者の書き方をすることをお勧めします。

Fixtureの準備ができたので、テストコードをリファクタします。

package models;

import models.User;
import org.junit.*;
import java.util.List;
import play.libs.Yaml;
import com.avaje.ebean.Ebean;

import static org.fest.assertions.Assertions.*;
import static play.test.Helpers.*;

public class UserTest {
    static final String EMAIL = "alice@email.com";

    @Before
    public void setUp() {
        start(fakeApplication(inMemoryDatabase()));
        Ebean.save((List) Yaml.load("testData/users.yml"));
    }

    @Test
    public void findByEmail() {
        User actual = User.findByEmail(EMAIL);

        assertThat(actual.email).isEqualTo(EMAIL);
    }

    @Test
    public void dontFindByInvalidEmail() {
        String typoEmail = EMAIL + ".";
        User actual = User.findByEmail(typoEmail);

        assertThat(actual).isNull();
    }
}

これで、テストコードとしてノイズとなるデータ準備について外部化して、可読性をあげることができました。また、YAMLは他のテストコードでも再利用可能です。
ただし、外部化したYAMLのデータはテストコードとの依存関係が切れている訳ではないので、管理する対象が2つに増えたり、テストコードを読んだだけだとテスト実行前の状態がわからないといった課題はありますので、トレードオフを考えて活用すれば良いと思います。

コントローラのテスト入門は次回

今回の記事はここまでです。本当はコントローラーまで書きたかったですが、間に合わなかったので次に持ち越したいと思います。

なぜJUnit

今回の記事の最後に1つだけ。 BDDっぽく書けるspecs2ではなく、なぜ今更JUnitの記事なのかについて私の考えを書いておきたいと思います。

つまるところ、speces2を書くにはScalaも書けるようにならないといけないからです。私はもともとRubyを書いているのでSpecで書けるほうがうれしいです。が、テストでScalaが書けるならプロダクション・コードもScalaで書けば良いです。

Scalaの知識が不十分な状態でテストを書いても、正しい振る舞いであることを自信を持って書けない訳で、それってテストとしてどうなの?と思います。

そういうわけで、テスト対象をJavaで書くならテストもJava派です。 もちろん、ScalaとかGroovyとか、よりよい手段を活用して書けるのが理想的ですけど、Scala書けて当たり前というようなスキルの高いチームはなかなかなさそうというのが現状ではないかなと。

明日のPlay framework 2.x Java Advent Calendar 2013

Play Framework 2徹底入門を執筆されたkazuhiro haraさんです。
本が発売されるのが楽しみですね。