Tbpgr Blog

Ruby プログラマ tbpgr(てぃーびー) のブログ

JUnit | JUnit4のDataPointsによるテストとパラメータ化の基準

パンくず

Java
JUnit
JUnit4のDataPointsによるテストとパラメータ化の基準

概要

JUnit4のDataPointsによるテストとパラメータ化の基準

内容

JUnit4のDataPointsによって、テストをパラメータ化することができます。
詳細はTheoryとDataPointsとFixitureによるパターン化テスト参照。

ここで、問題になるのはテストの「変数」としてFixtureのフィールドに設定すべき内容の判断基準について。
実際に、このパターンのテストを導入してみて熟練した開発者は説明せずとも適切なテストを書いてくれますが、
不慣れな開発者は抽出の基準が分からずにFixtureに含めるべきではないパラメータを含めてしまったりと混乱してしまうようです。

また、Fixtureにはデータクラスなどは極力持たせずデータクラス内で利用する個別のフィールドを持たせるほうが
すっきりします。データクラスのsetterやコンストラクタへの設定はtestコード内で行います。
Fixtureのパラメータが複雑すぎる場合は、そもそも実装コードの処理が分厚すぎることへの警鐘になるような気がします。

パラメータ化すべき項目

項目名 内容
分岐に影響する変数 if文など処理の分岐に影響を与える変数
処理結果に影響する変数 最後にassertで比較する結果に影響を与える変数
入力値によって変化のある処理結果 入力値によって変化のある処理結果の期待値。結果が分岐によって変わらない場合はパラメータ化せずにtestメソッド内に直接記述する
特定の分岐のみモックさせたい場合の制御フラグ djUnitなどによるモックを利用する際に特定の分岐の場合のみモックする場合にbooleanの制御変数を追加
モック内容 処理結果や分岐に影響を与えるモック内容を追加する
例外の制御フラグ 例外のテストケースはexpectedで記述する、という方法もありますがエラーの詳細を検証出来ないため結局は同じケース内で処理するのが楽です

サンプルコード

テスト対象コード

仕様は以下
・DBからユーザー名を取得して、以下の変換を実施した上で返却する
・名前がaで始まっているユーザーは無変換
・名前がa以外で始まっているユーザーは大文字に変換
・引数のhasEncloseがtrueなら名前を隅付き括弧で囲う
例:【obama】
・名前に数字を含んでいた場合はExceptionを投げる

package parameterized;

import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class SampleParameterized {
  public String getUpperUserName(boolean hasEnclose) throws Exception {
    // DB空のデータ取得と想定
    String baseUserName = new DbAccess().getUserName();
    checkValidUserName(baseUserName);
    String userName = toUpperUserName(baseUserName);
    userName = appendEnclose(hasEnclose, userName);
    return userName;
  }

  private String toUpperUserName(String baseUserName) {
    String userName;
    // aで始まる場合は変換なし
    if (baseUserName.substring(0, 1).equals("a")) {
      userName = baseUserName;
      // a以外の場合は大文字変換
    } else {
      userName = baseUserName.toUpperCase();
    }
    return userName;
  }

  private String appendEnclose(boolean hasEnclose, String userName) {
    if (hasEnclose) {
      userName = "【" + userName + "】";
    }
    return userName;
  }

  /**
   * 数字を含む無効な名前かどうか判断。
   *
   * @param userName ユーザー名
   * @throws Exception 例外
   */
  private void checkValidUserName(String userName) throws Exception {
    String regex = "\\d";
    Pattern pattern = Pattern.compile(regex);
    Matcher matcher = pattern.matcher(userName);
    if (matcher.find()) {
      throw new Exception("取り敢えず例外");
    }
  }
}

DB処理を想定したクラス

package parameterized;

public class DbAccess {
  public String getUserName() {
    return "hoge";
  }
}

テストクラス

package parameterized;

import static jp.co.dgic.testing.framework.DJUnitTestCase.setReturnValueAtAllTimes;
import static org.hamcrest.CoreMatchers.is;
import static org.junit.Assert.assertThat;
import gr.java_conf.tb.tbpg_util.common.CommonUtil;

import org.junit.experimental.runners.Enclosed;
import org.junit.experimental.theories.DataPoints;
import org.junit.experimental.theories.Theories;
import org.junit.experimental.theories.Theory;
import org.junit.runner.RunWith;

@RunWith(Enclosed.class)
public class SampleParameterizedTest {

  @RunWith(Theories.class)
  public static class GetUpperUserName {

    @DataPoints
    public static Fixture[] getFixture() {
      /*
       * ケース番号、ユーザー名、囲い文字有無、例外有無、例外メッセージ、ユーザー名期待値
       */
      Fixture[] fixture = {
          // 大文字変換なし、囲い文字有りのケース
          new Fixture(1, "andy", true, false, null, "【andy】"),
          // 大文字変換なし、囲い文字なしのケース
          new Fixture(2, "andy", false, false, null, "andy"),
          // 大文字変換あり、囲い文字有りのケース
          new Fixture(3, "boby", true, false, null, "【BOBY】"),
          // 大文字変換あり、囲い文字なしのケース
          new Fixture(4, "boby", false, false, null, "BOBY"),
          // 例外になるケース
          new Fixture(5, "boby12", false, true, "取り敢えず例外", "boby12"),
          // わざとエラーになるケース
          new Fixture(6, "boby", false, false, null, "boby"),

      };
      return fixture;
    };

    @Theory
    public void testGetUpperUserName(Fixture fixture) {
      // ****テスト前処理*****
      // DBから取得するユーザー名をモック化
      setReturnValueAtAllTimes(DbAccess.class, "getUserName", fixture.userName);
      SampleParameterized parameterized = new SampleParameterized();
      String actualUserName;
      // ****テスト実行****
      try {
        actualUserName = parameterized.getUpperUserName(fixture.hasEnclose);
        if (fixture.hasException) {
          // 例外のケースにも関わらず正常終了していたら強制的にテスト失敗
          assertThat(fixture.toString(), true, is(false));
        }
        // ****テスト結果の検証****
        assertThat(fixture.toString(), actualUserName, is(fixture.expectedUserName));
      } catch (Exception e) {
        if (!fixture.hasException) {
          // 正常系のケースにも関わらず例外が発生していたら強制的にテスト失敗
          e.printStackTrace();
          assertThat(fixture.toString(), true, is(false));
        }
        // 例外のメッセージ検証
        assertThat(fixture.toString(), e.getMessage(), is(fixture.errorMessage));
      }


    }

    static class Fixture {
      // Fixtureのケース番号
      int caseNo;
      // モックに設定するユーザー名。また分岐に影響する
      String userName;
      // 分岐に影響する入力値。
      boolean hasEnclose;
      // 例外の有無
      boolean hasException;
      // 例外のメッセージ内容
      String errorMessage;
      // ユーザー名の期待値。分岐に影響する
      String expectedUserName;

      public Fixture(int caseNo, String userName, boolean hasEnclose, boolean hasException, String errorMessage,
          String expectedUserName) {
        this.caseNo = caseNo;
        this.userName = userName;
        this.hasEnclose = hasEnclose;
        this.hasException = hasException;
        this.errorMessage = errorMessage;
        this.expectedUserName = expectedUserName;
      }

      public String toString() {
        // Fixtureの内容を一括出力
        return CommonUtil.getAllFiledInfo(Fixture.class, this);
      }
    }
  }

}

出力

※1-5のケースは正常終了。6のケースのエラー表示は以下

int|caseNo|6

の部分がfixtureのtoStringによって出力されているテストケースの内容

org.junit.experimental.theories.internal.ParameterizedAssertionError: testGetUpperUserName(getFixture[5])
	at org.junit.experimental.theories.Theories$TheoryAnchor.reportParameterizedError(Theories.java:183)
	at org.junit.experimental.theories.Theories$TheoryAnchor$1$1.evaluate(Theories.java:138)
	at org.junit.experimental.theories.Theories$TheoryAnchor.runWithCompleteAssignment(Theories.java:119)
	at org.junit.experimental.theories.Theories$TheoryAnchor.runWithAssignment(Theories.java:103)
	at org.junit.experimental.theories.Theories$TheoryAnchor.runWithIncompleteAssignment(Theories.java:112)
	at org.junit.experimental.theories.Theories$TheoryAnchor.runWithAssignment(Theories.java:101)
	at org.junit.experimental.theories.Theories$TheoryAnchor.evaluate(Theories.java:89)
	at org.junit.runners.BlockJUnit4ClassRunner.runNotIgnored(BlockJUnit4ClassRunner.java:79)
	at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:71)
	at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:49)
	at org.junit.runners.ParentRunner$3.run(ParentRunner.java:193)
	at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:52)
	at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:191)
	at org.junit.runners.ParentRunner.access$000(ParentRunner.java:42)
	at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:184)
	at org.junit.runners.ParentRunner.run(ParentRunner.java:236)
	at org.junit.runners.Suite.runChild(Suite.java:128)
	at org.junit.runners.Suite.runChild(Suite.java:24)
	at org.junit.runners.ParentRunner$3.run(ParentRunner.java:193)
	at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:52)
	at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:191)
	at org.junit.runners.ParentRunner.access$000(ParentRunner.java:42)
	at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:184)
	at org.junit.runners.ParentRunner.run(ParentRunner.java:236)
	at org.eclipse.jdt.internal.junit4.runner.JUnit4TestReference.run(JUnit4TestReference.java:50)
	at org.eclipse.jdt.internal.junit.runner.TestExecution.run(TestExecution.java:38)
	at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.runTests(RemoteTestRunner.java:467)
	at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.runTests(RemoteTestRunner.java:683)
	at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.run(RemoteTestRunner.java:390)
	at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.main(RemoteTestRunner.java:197)
	at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at sun.reflect.NativeMethodAccessorImpl.invoke(Unknown Source)
	at sun.reflect.DelegatingMethodAccessorImpl.invoke(Unknown Source)
	at java.lang.reflect.Method.invoke(Unknown Source)
	at jp.co.dgic.eclipse.jdt.internal.junit.runner.DJUnitRunner.main(DJUnitRunner.java:49)
Caused by: java.lang.AssertionError: 
int|caseNo|6
class java.lang.String|userName|boby
boolean|hasEnclose|false
boolean|hasException|false
class java.lang.String|errorMessage|null
class java.lang.String|expectedUserName|boby

Expected: is "boby"
     got: "BOBY"

	at org.junit.Assert.assertThat(Assert.java:778)
	at parameterized.SampleParameterizedTest$GetUpperUserName.testGetUpperUserName(SampleParameterizedTest.java:58)
	at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at sun.reflect.NativeMethodAccessorImpl.invoke(Unknown Source)
	at sun.reflect.DelegatingMethodAccessorImpl.invoke(Unknown Source)
	at java.lang.reflect.Method.invoke(Unknown Source)
	at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:44)
	at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:15)
	at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:41)
	at org.junit.experimental.theories.Theories$TheoryAnchor$2.evaluate(Theories.java:167)
	at org.junit.experimental.theories.Theories$TheoryAnchor$1$1.evaluate(Theories.java:133)
	... 33 more