Status Code 303 - See Other

Java, C, C++, C#, Objective-C, Swift, bash, perl, ruby, PHP, Python, Scala, Groovy, Go, DevOps, Raspberry Pi など。情報の誤りや指摘・意見などは自由にどうぞ。

JUnitテスト前提条件制御

概要

毎回テストをするうえでお決まりの処理というのがある。
これらの処理をまとめる機構がテストフレームワークには大体存在する。
今回はJUnitの便利なテスト前提条件を設定する方法について記述する。

これらをやることによって、以下の利点がある。

  • テストがどんなものが分かりやすくなる
  • 処理が一か所にまとまる
  • テストの本質的な内容だけが目立つようになる

詳細説明

今回は、seleniumでサイトテストを作成しようとした例で説明する。
seleniumはブラウザを用いた機能ごとのテストや結合・シナリオテストで使われるライブラリだ。
なぜ、seleniumを用いたテストかというと、毎回初期化しないとあるテストが他のテストに影響しやすいからだ。

散在したテスト前提条件をまとめる

テストを実行するとき、前提条件・事後条件を定義したうえでのテストが多い。
一例としては、下記の内容だ。

  • 現在あるページにいること
  • ログインしていること

これらは、通常であれば JUnit が提供している @Before/@After を利用すれば事足りるが、
これが多数のテストクラスに必要だった場合、同じロジックが多数クラスに散在してしまう。

この問題を解決するために、@Rule というものがある。これは、@Before/@Afterをまとめる役割をする。
この@Ruleがつけられたフィールドをクラス内に配置すると、JUnitがテストの実行前後にロジックを実行してくれる。

public class LoginTest {

	/**
	 * テストルール.
	 */
	@Rule
	public WebTestRule rule = new WebTestRule();

	/**
	 * ブラウザ.
	 */
	Browser browser;

	@Before
	public void setUp() {
		browser = rule.getBrowser();
		browser.navigateTo(loginPage.getUrl());
	}

	@Test
	public void ログインテスト() throws Exception {
		User user = new User("ID", "PW");
		browser.getElement(loginPage.idText).sendKeys(user.getId());
		browser.getElement(loginPage.passwordText).sendKeys(user.getPassword());
		browser.getElement(loginPage.submitButton).click();

		assertThat(browser.getUrl(), is(new MemberPage().getUrl()));
	}

	@Test
	public void ログインが失敗すること() throws Exception {
		User user = new User("IDErr", "PWErr");
		browser.getElement(loginPage.idText).sendKeys(user.getId());
		browser.getElement(loginPage.passwordText).sendKeys(user.getPassword());
		browser.getElement(loginPage.submitButton).click();

		assertThat(browser.getUrl(), is(loginPage.getUrl()));
		assertThat(browser.getElement(loginPage.errorComment).getText(), is("認証情報が違います"));
	}
}

@Ruleがつけられたインスタンスの実装内容は以下のようになる。
ここでは、ブラウザをテスト前に開いて、テスト後に閉じている。

public class WebTestRule extends ExternalResource {

	protected Browser browser = new Browser();

	public Browser getBrowser() {
		return browser;
	}

	@Override
	protected void before() throws Throwable {
		browser.open();
	}

	@Override
	protected void after() {
		browser.close();
	}
}

最終的にこのテストでは、下記の順番でメソッドが動くことになる。

  1. @Ruleが付与されたフィールドのbeforeメソッド
  2. @Before が付与されたメソッド
  3. テスト実行
  4. @Ruleが付与されたフィールドのafter

@Ruleの内容によって、これがWebのテストであると分かるし、
その詳細なロジックはこのクラス内には記載されていないので、テスト内容だけが目立つようになる。

テスト実行前・実行後の挙動を追加

また、挙動の追加もそれほど難しくない。
例えば、selenium起動前後にさらにロジックを追加したい場合もあるだろう。
この場合、以下の2ステップで変更できる。

  1. 既存のRuleクラスを拡張したクラスを作成
  2. @Ruleを付与したフィールドを拡張したクラスインスタンスに置き換えるだけ。

例えば、seleniumでブラウザを起動した後、あるサイトにログインした状態を作ってから、テストをしたい場合があるとする。
その場合、WebTestRuleクラスを継承した以下のクラスを作成すればよい。

public class LoginRule extends WebTestRule {

	@Override
	protected void before() throws Throwable {
		super.before();
		LoginPage login = new LoginPage();
		super.browser.navigateTo(login.getUrl());
		super.browser.getElement(login.idText).sendKeys("ID");
		super.browser.getElement(login.passwordText).sendKeys("PW");
		super.browser.getElement(login.submitButton).click();
	}
}
テストクラス実行前と実行後のみ動かしたい場合

次に、beforeメソッド・afterメソッドの実行タイミング変更について記述する。
これまでのルールは、各テストごとにbeforeメソッド・afterメソッドが起動するため、
テストが多くなるとテスト時間が多くかかってしまう。(seleniumドライバの初期化処理が軽くないため)
これを軽減するためによく使われる方法として、全てのテストに共通のまたがった環境をつくる。

これは @Rule → @ClassRule に変更すれば可能であり、下記の動作になる。

  • 最初のテストの実行前 → beforeメソッドが起動
  • 最後のテストの実行後 → afterメソッドが起動
public class ログイン前状態 {

	/**
	 * テストルール.
	 */
	@ClassRule
	public WebTestRule rule = new WebTestRule();

(以下省略)

このテストは速度が向上する代わりに、あるテストが他のテストに影響してしまう危険性もある。
例えば、ログインした状態とそうでない状態で結果が全く異なる場合。

この場合、よく用いられるのは、ログイン処理→検証→ログアウト処理をテストメソッドに直接記述する方法。
なぜなら、それを共通の箇所(@Before/@Afterが付与されたメソッド)に書くと毎回起動されてしまうから。

しかし、これはテストとして間違っていると思う。本来テストには、テスト処理と検証だけがあるべきだ。
仮に末尾のログアウト処理で例外が発生した場合、それはこのテストの失敗であるといえるだろうか?

複数条件をテストクラスに記述する

この問題を解決するには、Enclosedテストランナーを使う。
これらは、別々の前提条件をテストクラスに複数含めることができる。

@RunWith(Enclosed.class)
public class LoginPageTest {

	/**
	 * テスト対象.
	 */
	static LoginPage loginPage = new LoginPage();

	public static class ログイン状態 {

		@Rule
		public LoginRule rule = new LoginRule();

		@Test
		public void メンバーページに遷移すること() throws Exception {
			rule.getBrowser().navigateTo(loginPage.getUrl());
			assertThat(browser.getUrl(), is(new MemberPage().getUrl()));

		}
	}

	public static class ログイン前状態 {

		@Rule
		public WebTestRule rule = new WebTestRule();

		Browser browser;

		@Before
		public void setUp() {
			browser = rule.getBrowser();
			browser.navigateTo(loginPage.getUrl());
		}

		@Test
		public void ログインテスト() throws Exception {
			User user = new User("ID", "PW");
			browser.getElement(loginPage.idText).sendKeys(user.getId());
			browser.getElement(loginPage.passwordText).sendKeys(user.getPassword());
			browser.getElement(loginPage.submitButton).click();

			assertThat(browser.getUrl(), is(new MemberPage().getUrl()));
		}

		@Test
		public void ログインが失敗すること() throws Exception {
			User user = new User("IDErr", "PWErr");
			browser.getElement(loginPage.idText).sendKeys(user.getId());
			browser.getElement(loginPage.passwordText).sendKeys(user.getPassword());
			browser.getElement(loginPage.submitButton).click();

			assertThat(browser.getUrl(), is(loginPage.getUrl()));
			assertThat(browser.getElement(loginPage.errorComment).getText(), is("登録されていませんよ?"));
		}
	}
}

最後に

今回は、テスト項目の制御という目的で @Ruleと@ClassRuleについて触れた。
これらがJUnitですっきりしたテストコードを書く上では大事なものであることはまちがいない。
テストがきれいであれば色んな人にも読みやすいし、これからテストを書く人にも敷居が低く感じてもらえるはずだ。