こんにちはサーバーでジャバジャバと 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(); } }
これですべての準備は整いました。分解した個々のソリューションを一つにまとめあげてきましょう。
コンポーネントの統合
最初に示した TeamService
に TeamServiceCheckerFactory
を導入します。 その際に、先程の Checker
を畳み込んだ Checker
を TeamService
のフィールドとして定義します。
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 エンジニアも募集しています!