MyBatis Generator が生成した Example を mockito したい


MyBatis Generator はデータベースにアクセスしてテーブルスキーマから自動でマッパーを生成してくれる便利なツールです。 Table というテーブルがあったとき,マッパーインターフェイスとその定義を記述した XML ファイル,エンティティークラス, Example クラス (WHERE, ORDER BY, DISTINCT を記述する),および必要に応じてキークラスというクラスファイル群を生成してくれます。

マッパーインターフェイスには,例えば user テーブルに対して以下のようなメソッドが定義されます。

List<User> selectByExample(UserExample example);
int updateByExampleSelective(User record, UserExample example);

ここで User はエンティティークラス, UserExample は Example クラスです。 Example クラスは WHERE 条件を表現するので,いたるところに出現します。

さて,話は変わり mockito です。 mockito はモックテストフレームワークで,ざっくり言うとテスト用にオブジェクトの振る舞いを変えることができます。

@InjectMock
Service service;
@Mock
Foo foo;

doNothing().when(foo).setBar(0);
doThrow(new RuntimeException()).when(foo).setBar(-1);

上記の setBar のように単純なパラメーターの場合は簡単に記述できます。上記の例では setBar が単純な型なので引数に値そのものを取ることができますが,より複雑な引数に対しては, Hamcrest のマッチャーを用いて, argThat(matcher) のような引数を与えてやることになります。

話を MyBatis Generator に戻します。 MyBatis Generator が生成するクラス群のうち,マッパークラスをモックテストしたいと思います。となると引数の対象となるのはエンティティークラスおよび Example クラスです。

エンティティークラスは単純な bean なので, org.hamcrest.beans パッケージ内のマッチャーがあれば大体事足ります。しかし Example クラスは WHERE 条件を保持するため若干データ構造が複雑な bean になっています。そこで Example クラス用のマッチャーを構築していきたいと思います。

Example 具体的には以下のようなデータを持ちます (説明のために若干改変しています)。

public class User {

   public boolean isDistinct();
   public String getOrderByClause();
   public List<Criteria> getOredCriteria();

   public static class Criteria {
      public List<Criterion> getAllCriteria();
   }

   public static class Criterion {
   }
}

Criteria クラスと Criterion クラスという内部クラスがあります。 Criteria クラスは Criterion のリストを持つ構造です。 Criterion クラスは WHERE 句で記述される個々の条件です。例えば WHERE (a = 1 AND b = 2) OR c < 3 という句では, a = 1b = 2, c < 3 がそれぞれ Criterion オブジェクトで, a = 1 AND b = 2c < 3 が Criteria オブジェクトに相当します。

ではマッチャーの作成です。マッチングの方式は, Example の内容が完全に一致していることだったり,指定した条件を含むことだったりケースによって異なるでしょう。そこでまずは基底の抽象クラスを作成し,継承するクラスが個々にマッチングアルゴリズムを実装する方式にします (テンプレートメソッドパターン)。

public abstract class AbstractExampleMatcher<T> extends TypeSafeMatcher<T> {

   private final T example;

   protected AbstractExampleMatcher(T example) {
      this.example = example;
   }

   public T getExample() {
      return example;
   }

   @Override
   public final boolean matchesSafely(T item) {
      if (item == null) {
         return false;
      }
      return allOf(
         hasProperty("distinct", getDistinctMatcher()),
         hasProperty("orderByClause", getOrderByClauseMatcher()),
         hasProperty("oredCriteria", getOredCriteriaMatcher())
      ).matches(item);
   }

   protected abstract Matcher<Boolean> getDistinctMatcher();
   protected abstract Matcher<String> getOrderByClauseMatcher();
   protected abstract Matcher<? super List<Object>> getOredCriteriaMatcher();
}

さすがにこれだけでは Example の処理が面倒で継承クラスも辛いので, Example のプロパティーを取得するための方法を提供します。

public class ExampleIntrospector<T> {

   private final Method isDistinct;
   private final Method getOrderByClause;
   private final Method getAllCriteria;
   private final Method getOredCriteria;

   public ExampleIntrospector(Class<T> exampleType) {
      try {
         Class<?>[] innerTypes = exampleType.getDeclaredClasses();
         Class<?> criteriaType = Arrays.stream(innerTypes).filter(c -> c.getSimpleName().equals("Criteria")).findFirst().get();

         isDistinct = exampleType.getDeclaredMethod("isDistinct");
         getOrderByClause = exampleType.getDeclaredMethod("getOrderByClause");
         getOredCriteria = exampleType.getDeclaredMethod("getOredCriteria");
         getAllCriteria = criteriaType.getDeclaredMethod("getAllCriteria");
      } catch (NoSuchElementException | NoSuchMethodException e) {
         throw new UndeclaredThrowableException(e);
      }
   }

   public boolean isDistinct(T example) {
      try {
         return (boolean)isDistinct.invoke(example);
      } catch (IllegalAccessException | InvocationTargetException e) {
         throw new UndeclaredThrowableException(e);
      }
   }

   public String getOrderByClause(T example) {
      try {
         return (String)getOrderByClause.invoke(example);
      } catch (IllegalAccessException | InvocationTargetException e) {
         throw new UndeclaredThrowableException(e);
      }
   }

   public List<?> getOredCriteria(T example) {
      try {
         return (List<?>)getOredCriteria.invoke(example);
      } catch (IllegalAccessException | InvocationTargetException e) {
         throw new UndeclaredThrowableException(e);
      }
   }

   public List<?> getAllCriteria(Object criteria) {
      try {
         return (List<?>)getAllCriteria.invoke(criteria);
      } catch (IllegalAccessException | InvocationTargetException e) {
         throw new UndeclaredThrowableException(e);
      }
   }

}

特に説明は不要かと思いますが,リフレクションで各プロパティーの getter を取得しています。登場するクラスはいずれも JavaBeans なので PropertyDescriptor を使っても良いのですが, getter を取得するだけなので,ナイーブなリフレクションで十分でしょう。

作成したクラスをマッチャークラスに持たせます。

private ExampleIntrospector<T> introspector;

@SuppressWarnings({ "unchecked" })
protected AbstractExampleMatcher(T example) {
   this.example = example;
   this.introspector = new ExampleIntrospector<T>(
      (Class<T>)example.getClass()
   );
}

public ExampleIntrospector<T> getIntrospector() {
   return introspector;
}

ちなみに Criteria クラスには getAllCriteriagetCriteria というメソッドがあり,通常は同じ Criterion のリストを返します。ただし TypeHandler による処理がある xxx というカラムが存在する場合は getXxxCriteria というメソッドが作成されます。その際には getAllCriteriagetCriteriagetXxxCriteria がマージされた結果を返します。そういう場合は別途もう少し厳密にマッチャーを構築しなければならないかもしれません。

閑話休題,作成した AbstractExampleMatcher を継承して実際のマッチャーを作成しましょう。例として, DISTINCT と ORDER BY は完全一致, WHERE は指定された条件を含む場合にマッチするようなマッチャーを作成してみます。例えば実際の Example が WHERE (a = 1 AND b = 2) OR c < 3 の場合, WHERE a = 1 OR c < 3WHERE a = 1 はマッチするけれど, a = 2c < 3 OR d = 4 はマッチしないといった感じです。

public class HasConditionOf<T> extends AbstractExampleMatcher<T> {

   public HasConditionOf(T example) {
      super(example);
   }

   @Override
   protected Matcher<Boolean> getDistinctMatcher() {
      return is(getIntrospector().isDistinct(getExample()));
   }

   @Override
   protected Matcher<String> getOrderByClauseMatcher() {
      return is(getIntrospector().getOrderByClause(getExample()));
   }

   @SuppressWarnings({ "unchecked" })
   @Override
   protected Matcher<? super List<Object>> getOredCriteriaMatcher() {
      return hasItems(
         (Matcher<? super Object>[])
         getIntrospector().getOredCriteria(getExample()).stream()
            .map(CriteriaMatcher::new)
            .toArray(Matcher<?>[]::new)
      );
   }

   private class CriteriaMatcher extends BaseMatcher<Object> {

      private final List<?> expectedList;

      private CriteriaMatcher(Object expectedCriteria) {
         expectedList = getIntrospector().getAllCriteria(expectedCriteria);
      }

      @Override
      public boolean matches(Object actualCriteria) {
         List<?> actualList = getIntrospector().getAllCriteria(actualCriteria);
         return expectedList.stream().allMatch(e ->
            actualList.stream().anyMatch(a ->
               samePropertyValuesAs((Object)e).matches(a)
            )
         );
      }
   }
}

getOredCreteriaMatcher は Hamcrest の hasItems を用います。実際の Criteria が,期待する Critera リストのいずれかにマッチすれば良いというものです。

期待する Criteria とのマッチングは CriteriaMatcher クラスに記述しています。 expectedList には期待する Criterion のリスト,つまり実際の Criterion のリストに含まれるべき AND 条件のリストが含まれます。 Criterion のリストの順番は不明なので, anyMatch を用いて調べています。詳細は省きますが, Criterion は単純にプロパティーの完全一致を調べれば良いため, samePropertyValuesAs を用います。

なお,上記のコードは describeTo メソッドの実装が抜けているので,適当に追加する必要があります。 describeMismatchSafely もオーバーライドすると良いでしょう。

あとは利便性を考えて static メソッドを追加します。

@Factory
public static <T> HasConditionOf<T> hasConditionOf(T example) {
   return new HasConditionOf<>(example);
}

というわけで,以下のように利用します。

// テスト対象クラス
@Service
public class RenameService {

   @Inject
   private UserMapper mapper;

   public boolean rename(Long userId, String newName) {
      UserExample example = new UserExample();
      example.createCriteria()
         .andIdEqualTo(userId)
         .andNameNotEqualTo(newName);

      User record = new User();
      record.setName(newName);

      int count = mapper.updateByExampleSelective(record, example);
      return count != 0;
   }
}
// テスト
public class RenameServiceTest {

   @Test
   public void IDがupdateの絞り込み条件に含まれる() {
      UserExample expected = new UserExample();
      expected.createCriteria()
         .andIdEqualTo(1L);
      
      when(mapper.updateByExample(
         any(User.class), any(UserExample.class)
      )).thenReturn(1);

      service.rename(1L, "alice@example.com");
      verify(mapper, times(1)).updateByExampleSelective(
         any(User.class),
         argThat(hasConditionOf(expected))
      );
   }
}

例えばテストコードの andIdEqualTo の引数を別の値に変更すると,きちんとテスト失敗になります。