Stream を使ってテストしやすい業務チェック処理を実装する

こんにちはサーバーでジャバジャバと Java を開発している持田です。
今回は Stream を使ったテストしやすいチェック処理の実装方法を紹介します。


よくあるチェックロジック

業務ロジックのエラーチェックについて、皆さんはどのような感じで書いているでしょうか?

class TeamService {
  final UserRepository userRepository;
  final UserRoleRepository userRoleRepository;
  final TeamTaskRepository teamTaskRepository;
  // コンストラクター省略

  TaskArtifact getTaskArtifact(TeamId teamId, UserId userId, TeamTaskId teamTaskId) {
    if (!userRepository.findById(userId).isPresent()) {
      throw new UserNotFoundException(userId);
    }

    Set<UserRole> roles = userRoleRepository.findByUserIdAndTeamId(userId, teamId);
    boolean hasReadRight = roles
        .stream()
        .map(UserRole::getRight)
        .anyMatch(role -> role.equals(Role.READ_ARCHIVE));
    if (!hasReadRight) {
      throw new ProhibitedActionException(Role.READ_ARCHIVE, userId, teamId);
    }

    Optional<TeamTask> teamTask = teamTaskRepository.findByTeamIdAndTeamTaskId(temaId, teamTaskId);
    boolean hasArtifact = teamTask.flatMap(TeamTask::getArtifact).isPresent();
    if (!hasArtifact) {
      throw new ArchiveNotFoundException(teamId, teamTaskId);
    }
    // 以下略
  }
}

だいたいのプロジェクトでチェックロジックを記述するとこのようになるのではないかと思います。 ところが、このようなメソッドのテストを書こうとすると、ちょっとつらいところが出てきます。

class TeamServiceTest {

  final User user = User.of(/* 省略 */);

  UserRepository userRepository;
  UserRoleRepository userRoleRepository;
  TeamTaskRepository teamTaskRepository;

  TeamService teamService;

  @BeforeEach
  void setup() {
    userRepository = mock(UserRepository);
    userRoleRepository = mock(UserRoleRepository);
    teamTaskRepository = mock(TeamTaskRepository);
    teamService = new TeamService(userRepository, userRoleRepository, teamTaskRepository);
  }

  @Test
  void userにREAD_ARCHIVEロールが存在しない場合にエラー() {
    when(userRepository.findById(any())).thenReturn(Optional.of(user));
    when(userRoleRepository.findByUserIdAndTeamId(any(), any()))
        .thenReturn(Sets.immutable.of(
            UserRole.of(/* 省略 */), // READ_ARCHIVE でない UserRole
            UserRole.of(/* 省略 */), // READ_ARCHIVE でない UserRole
            UserRole.of(/* 省略 */)  // READ_ARCHIVE でない UserRole
        ).toSet());

    assertThatCode(() -> teamService.getTaskArtifact(teamId, userId, teamTaskId))
        .isInstanceOf(ProhibitedActionException.class);
  }
}

このようにメソッドが依存している Repository に対するモックの設定だけで、お腹が一杯になりそうなテストコードになりました。 そして、このようなモックを大量に使っているテストに関して、テストを作るときはまだ良いのですが、 メンテナンスなどで実装が少しでも変わるとテストが壊れてしまいます。 例えば、上記のメソッドではチェックの順番を変更して、 userRole のチェックを最後にするとテストが落ちてしまいます。 これは「ユーザーが適切なロールを持っていない」場合のテストをしたいのにもかかわらず、 「ユーザーが存在する場合」のモック設定も必要になるなど、関心外の状態を把握している必要があるなど テストが複雑なことの現れといえるでしょう。


テストを簡単にするために

先述の通り TeamService のテストは複数の Repository が関連して複雑になっています。 複雑な問題は、より小さな問題に分割して単純にするのがセオリーです。

一つ一つのチェックを観察すると、特定の条件の場合にエラーをだして、そうでない場合は何も発生しないことに気が付きます。 そこで、この観察結果から一つの型を考えてみます。 その型はある一つの入力値に対して検証をおこなって、エラーの有無を返すように定義します。

interface Checker<INPUT, OUTPUT extends Throwable> {
  Optional<OUTPUT> check(INPUT input);
}

例えば、 check メソッドの結果として返されるオブジェクトの Optional#isPresent メソッドが true を返す場合は チェック失敗してエラーを返したことになります。もちろん Optional#isPresent メソッドの結果が false の場合は チェックをパスしたことになります。それでは、この型をつかって先程の一つ一つの処理を書き直してみます。 先ほどの TeamService とは異なる Checker を作るだけのクラス(ここでは TeamServiceCheckerFactory という名前にします)を作り、 TeamService の各チェックを別々のメソッドにコピーして、 戻り値が適切な型になるように調整します。 (ここでは楽にするために、いろいろな Repository を持つクラスが、ラムダの形の Checker を返すメソッドを持つ形にしてありますが、 それぞれを個別の Checker 実装クラスとして作成しても良いです)

class TeamServiceCheckerFactory {
  final UserRepository userRepository;
  final UserRoleRepository userRoleRepository;
  final TeamTaskRepository teamTaskRepository;
  // コンストラクター省略

  Checker<TaskArtifactArgument, ApplicationException> userExists() {
    return arg -> {
      if (!userRepository.findById(arg.userId()).isPresent()) {
        return Optoinal.of(new UserNotFoundException(arg.userId()));
      }
      return Optional.empty();
    };
  }

  Checker<TaskArtifactArgument, ApplicationException> userHasReadRight() {
    return arg -> {
      Set<UserRole> roles = userRoleRepository.findByUserIdAndTeamId(arg.userId(), arg.teamId());
      boolean hasReadRight = roles
          .stream()
          .map(UserRole::getRight)
          .anyMatch(role -> role.equals(Role.READ_ARCHIVE));
      if (!hasReadRight) {
        return Optional.of(new ProhibitedActionException(Role.READ_ARCHIVE, arg.userId(), arg.teamId()));
      }
      return Optional.empty();
    };
  }

  Checker<TaskArtifactArgument, ApplicationException> taskHasArtifact() {
    // 省略
  }
}

これらに対するテストは複数の Repository が絡むことはないので単純に行なえます。 例えば、 userExists のテストは次のようになります。

class TeamServiceCheckerFactoryTest {

  final TalkArtifactArgument argument = ...

  UserRepository userRepository;
  // 他の repository は省略

  TeamServiceCheckerFactory factory;

  @BeforeEach
  void setup() {
    userRepository = mock(UserRepository.class);
    // 他の repository は省略
    factory = new TeamServiceCheckerFactory(userRepository /* 他省略 */);
  }

  @Test
  void ユーザーが見つからない場合はエラー() {
    when(userReposiotry.findById(any())).thenReturn(Optional.empty());
    var checker = factory.userExists();

    var result = checker.check(argument);

    assertThat(result).isPresent();
  }
}

テストする対象の Repository だけモックを作って他の Repository については何も営業しないので、 実行順でテストが壊れるようなこともなくなります。

Checker の合成

Java8 にて連続する要素を処理するための仕組みとして java.util.stream.Stream が導入されました。 java.util.Stream を用いることで 個々の要素に対して何らかの変換(map)、もしくは制御(filter)を行う中間処理を施した後、 畳み込み(reduce)あるいは蓄積(collect)操作によって単一の結果にまとめられます。

Stream.of("foo","bar","baz").reduce("", (acc, str) -> acc + "/" + str)
// -> /foo/bar/baz

先程のセクションで業務チェックをオブジェクト(Checker)として表現したので、 これを連続する要素とみなして、畳み込んで一つの Checker にまとめていきます。

@SafeVarargs
static <INPUT, OUTPUT extends Throwable> Checker<INPUT,OUTPUT> all(
    Checker<? super INPUT, ? extends OUTPUT>... checkerList) {
  return Stream.of(checkerList).reduce(i -> Optional.empty(), (acc, ch) -> input -> {
      var optional = acc.check(input);
      if (optional.isPresent()) return optional;
      else return ch.check(input);
    });
  }
}

念の為、この畳込みが正しいかテストを書くとこのようになるでしょう。(例なので適切なテストではないです)

class CheckerTest {
  final Checker<String, Exception> success = string -> Optional.empty();
  final Checker<String, Exception> failure = string -> Optional.of(new Exception());

  @Test
  void すべてのチェックが成功の場合に合成されたチェックは成功する() {
    var checker = Checker.all(success, success, success);

    var result = checker.check("test");

    assertThat(result).isEmpty();
  }

  @Test
  void チェックエラーになるチェックがある場合は合成されたチェックもエラーを返す() {
    var checker = checker.all(success, success, failure, success);

    var result = checker.check("test");

    assertThat(result).isPresent();
  }
}

これですべての準備は整いました。分解した個々のソリューションを一つにまとめあげてきましょう。


コンポーネントの統合

最初に示した TeamServiceTeamServiceCheckerFactory を導入します。 その際に、先程の Checker を畳み込んだ CheckerTeamService のフィールドとして定義します。

class TeamService {
   // 他の Repository などは省略
  final Checker<TaskArtifactArgument, ApplicationException> checker;

  TeamService(TeamServiceCheckerFactory factory) {
    // ブログの書面の都合上こちらで `Checker` を畳み込む処理を書きましたが、 TeamServiceCheckerFactory にある方が自然です
    this(Checker.all(factory.userHasReadRight(), factory.userExists(), factory.taskHasArtifact()));
  }

  TeamService(Checker<TaskArtifactArgument, ApplicationException> checker) {
    this.checker = checker;
  }

  TaskArtifact getTaskArtifact(TeamId teamId, UserId userId, TeamTaskId teamTaskId) {
    var arg = new TaskArtifactArgument(teamId, userId, teamTaskId);
    var checkResult = checker.check(arg);
    if (checkResult.isPresent()) {
      throw checkResult.get();
    }
    // 以下省略
  }  

以前に比べて見通しが良くなりました。チェックの内容はそれぞれのメソッド名(userHasReadRight, userExists, taskHasArtifact) で表現されていますし、エラーの有無という状態も明確です。

ではほとんど必要ないと思いますが、テストを書いてみましょう。

class TeamServiceTest {
  final TaskArtifactArgument arg = /* 省略 */;
  @Test
  void エラーがある場合は例外が発生する() {
    var service = new TeamService(input -> Optional.of(new ApplicationException("test")));
    assertThatCode(() -> service(arg))
      .isInstanceOf(ApplicationException.class);
  }
}

以前はモックまみれだったテストも、たった一つの単純なラムダ式で簡略化できるようになり、テストコードの方も見通しが良くなりました。


以上、ここ最近 direct サーバー開発で使っている Stream のパターンを紹介してみました。 この記事に書いたのは僕が適当にでっち上げたソースコードですが、実際のプロダクションコードでどのように 使われているかを見てみたくなりましたね?

そんなコードが読みたい放題の direct 開発部では Java を書きたいエンジニアを募集しています。 気になった方は こちら からご応募ください。 あ、もちろん Java 以外の Android エンジニアや、 iOS エンジニア、 フロントエンドエンジニア、 SRE エンジニアも募集しています!

タイトルとURLをコピーしました