普段業務でJavaを書く際、テストコードを意識したコードを書いているが、それを言語化してみた。
本稿の対象ユーザー
- テスト駆動開発を期待した人は回れ右。
- 普段からテストコードを意識してコードがかけている人は回れ右。
- Java に関して基本的な知識がある。
- Java で Spring か Jakarta EE(あるいは Java EE) のアプリの構造を知っている。
テストコードのありがたみ
私は、前職の SIer 時代はテストコードを書くのがあまり好きではなかった。
単純に動作確認するだけであれば普通にアプリを動かすか、デバッグ機能で検証した方がわざわざ実装するよりも早いケースがほとんどだと思うし、テストコードを書く場合はテストコードのメンテナンスコストも発生するのであまりコスパが良いとは思えなかった。
もちろん、テストコードを書かないと結合可能になるまでテストできないことも、細かい単位で分けた方がバグの原因特定がし易いことも知ってはいるが、そのテストコードを長期的に維持する必要があるかは些か疑問だった。
しかし、自社開発の会社へ転職してアジャイル開発がメインになったことでテストコードの重要性が変わった。
スピード感のある開発で頻繁にリリースを行い、いろいろな担当者がいろいろな案件で頻繁にコードを改修する状況では、品質管理の考え方が変わり、 CI の重要度も違った。
CI の重要度が変わると、必然的にテストコードも重要な位置付けとなった。
もちろん、完全に意見を逆転させた訳ではなく、今でも不要と感じるテストは多々ある。
というの CI でテストが効果を発揮するケースは決して多くないが、基本的にテストコードが多いと CI の時間が長くなるからだ。
ただ、テストコードを意識したコードを書くようになったことで、私のコードの書き方は変化した。
テストコードの種類
一般的にテストコードを結合単位で分類すると以下のような3つの分類で分けられる。
種類 | 実行単位 | テスト準備や実行速度 | ライブラリ・ツール例 |
---|---|---|---|
単体テスト(UT) | 最小単位。基本的にメソッドレベル | 早い | JUnit |
結合テスト(IT) | モジュールを結合した単位 | 遅い | Spring Boot Test |
E2E(End to End)テスト | アプリケーションやシステム単位 | 超遅い | JMeter, k6 |
E2Eテストについてはコードの書き方はあまり関係ないので今回は言及しない。
重いが、書き易くよく効果を感じる機会が多いのは IT だ。
というよりも、 UT の場合は、下手なプログラマが書くと、メンテナンスコストが大きいホワイトボックステストになり易いからだ。
ホワイトボックステストとブラックボックステストについては観点が違うだけなので、結果として同じようなテストパターンになることもありえるが、私が思うホワイトボックステストがブラックボックステストよりも効果を発揮すると考えるのは、
- 最初の実装時
- ログ出力のような副作用を行っている場合
- Rx や CompletableFuture などを使った非同期処理などの処理の流れが重要な意味を発揮する場合
などである。
1つ目の確認は、モジュールが結合済みならテストコードを書かずとも確認可能であるし、あるいは一時的に使い捨てのテストコードを書けば十分である。
( もちろん、そのままテストコードを残すことも可能だが、その場合はメンテナンスが必要になる )
2つ目については、後述の通り、副作用を用いたコードは現在の Java では別の書き方に変更可能だったり、副作用をわざわざテストコードで確認する必要がないケースがほとんどだと考える。
そのため、ホワイトボックステストで継続的に効果を発揮するのは3つ目のケースがほとんどである。
そんな訳で基本的にホワイトボックステストでなければ検証できないコードはやめて、ブラックボックステストをやり易いコードにした方が良い。
というよりもブラックボックステストがやり易いコードは、基本的にホワイトボックステストもやり易い。
よくあるコード
よく見かける MVC や DDD 構成の Service class は以下のような感じである。
※ Spring を使用した例
@Service
public class SampleAppService {
private final Sample1DomainService sample1DomainService;
private final Sample2DomainService sample2DomainService;
public SampleAppService(
final Sample1DomainService sample1DomainService,
final Sample2DomainService sample2DomainService) {
this.sample1DomainService = sample1DomainService;
this.sample2DomainService = sample2DomainService;
}
public SampleResponse hoge() {
var e1 = sample1DomainService.findById(1L);
var e2 = sample2DomainService.findById(2L);
return new SampleResponse(e1.id(), e1.name(), e2.name());
}
}
このメソッドの IT は以下のような感じに記載できる。
@SpringBootTest
public class SampleAppServiceIntegrationTest {
@Autowired
private SampleAppService sampleService;
@Test
public void test() {
// Arrange
// ( IT用のテーブル初期化処理が入る )
// Act
final var result = sampleService.hoge();
// assert
assertThat(result.id()).isEqualTo(1L);
assertThat(result.sample1Name()).isEqualTo("Taro");
assertThat(result.sample2Name()).isEqualTo("Apple");
}
}
@SpringBootTest
を使うと必要なインスタンスを Injection してくれるので、service 以下のモジュールが本番同様のテストができる。
( もちろん Mock もできるので、外部サービスへの連携を回避することもできる )
このようなレイヤーで区切られた中間のレイヤーを UT でテストしようとすると以下のように mock を用意する必要がある。
@ExtendWith(MockitoExtension.class)
public class SampleAppServiceUnitTest {
@InjectMocks
private SampleAppService sampleAppService;
@Mock
private Sample1DomainService sample1DomainService;
@Mock
private Sample2DomainService sample2DomainService;
@Test
public void test() {
// Arrange
when(sample1DomainService.findById(any()))
.thenReturn(new Sample1Entity(1L, "Taro", 2L));
when(sample2DomainService.findById(any()))
.thenReturn(new Sample2Entity(2L, "Apple"));
// Act
final var result = sampleAppService.hoge();
// Assert
assertThat(result.id()).isEqualTo(1L);
assertThat(result.sample1Name()).isEqualTo("Taro");
assertThat(result.sample2Name()).isEqualTo("Apple");
}
}
この例ではさほど複雑ではないが、例えば条件によって呼ばれるメソッドが変わったり、privateメソッドを呼んでいたりするとテストコードは書きづらくなる。
結合処理とロジックを別メソッドにする
UT の観点で考えると、 sample1DomainService#findById
や sample2DomainService#findById
はそれぞれで適切に動くことを検証されるべきで、モジュールで結合した場合の挙動は IT で見るべき観点である。
そのため、以下のように結合処理と Service class 固有のロジックを分けることで、レイヤーに依存しないメソッドを作ることができる。
@Service
public class SampleAppService {
private final Sample1DomainService sample1DomainService;
private final Sample2DomainService sample2DomainService;
public SampleAppService(
final Sample1DomainService sample1DomainService,
final Sample2DomainService sample2DomainService) {
this.sample1DomainService = sample1DomainService;
this.sample2DomainService = sample2DomainService;
}
public SampleResponse hoge() {
var e1 = sample1DomainService.findById(1L);
var e2 = sample2DomainService.findById(2L);
return convert(e1, e2);
}
@VisibleForTesting
static SampleResponse convert(
final Sample1Entity sample1,
final Sample2Entity sample2) {
return new SampleResponse(sample1.id(), sample1.name(), sample2.name());
}
}
private メソッドは外部から参照できないので、テストを書く場合はそれ以外のアクセスレベルにする必要がある。
テストコードは通常対象クラスと同じパッケージに作られるので、 package private にすれば十分である。
また、テスト用に package private にしているのだと分かるように @VisibleForTesting
をつけるとわかり易い。
@VisibleForTesting
はJava標準のアノテーションではないので、モジュール追加が嫌なら単純にコメントを追加するだけでも意図がわかり易いだろう。
上記のように mock の準備が不要になると以下のようにテストパターンを羅列し易くなる。
public class SampleAppServiceUnitTest {
private static Fixture[] fixtures() {
return new Fixture[]{
new Fixture(
new Sample1Entity(1L, "Taro", 2L),
new Sample2Entity(2L, "Apple"),
new SampleResponse(1L, "Taro", "Apple"))
};
}
@MethodSource("fixtures")
@ParameterizedTest
public void test(final Fixture fixture) {
final var result = SampleAppService.convert(
fixture.sample1,
fixture.sample2);
assertThat(result.id()).isEqualTo(fixture.response.id());
assertThat(result.sample1Name()).isEqualTo(fixture.response.sample1Name());
assertThat(result.sample2Name()).isEqualTo(fixture.response.sample2Name());
}
private record Fixture(
Sample1Entity sample1,
Sample2Entity sample2,
SampleResponse response
) {
}
}
コードを書き換えることで処理効率は落ちることはあるが、このような構造の方が純粋に入力と出力だけを扱えるので、テストコードは書き易い。
ちなみに前述の例の場合、mock の引数を動的に変えれば同じこともできるが、テストコードを動的に変更するのはテストコードのバグを生み易い上、メンテナンスコストも上がり易いので、避けた方が無難だろう。
また SampleAppService#hoge
のUTは書かなくなる訳だが、無理に全てを UT でテストするよりは IT と組み合わせた方がシンプルかつ将来的なメンテナンスコストを上げずに済むことが期待できる。
( 個人的には結合部分が大した処理でないならわざわざ IT を書かなくても良いと思っている )
私がメソッドを分割する上で気にする観点は以下の通り。
- 他レイヤーとの結合部分を切り分ける
- できるだけstaticメソッドにする
- メソッドをテストしやすい単位で分割する
以下、メソッドを分割する例を記載する。
メソッド分割: 条件判定の例
私の経験的に、メソッドを分け易く、テストし易いメソッドの1つが条件判定である。
@VisibleForTesting
static boolean isHoge(final Sample1Entity sample1, final Sample2Entity sample2) {
if ("Taro".equals(sample1.name())) {
return true;
}
if ("Apple".equals(sample2.name())) {
return true;
}
return false;
}
メソッド分割: データ変換の例
前述のレスポンスへの変換もデータ変換の例ではあるが、分岐が多いような変換も他のロジックに依存しなければstaticメソッドで書き易い。
@VisibleForTesting
static Fruits hoge(final Sample1Entity sample1) {
if (sample1 == null || sample1.name() == null){
return Fruits.OTHER;
}
switch (sample1.name()) {
case "Taro":
return Fruits.APPLE;
case "Hanako":
return Fruits.BANANA;
case "Jiro":
return Fruits.ORANGE;
default:
return Fruits.OTHER;
}
}
enum Fruits {
APPLE, BANANA, ORANGE, OTHER
}
クラスを分割
条件判定と判定後の分岐をテストコードで検証したい場合、分岐先の処理を別クラスのメソッドとして書くことで、検証できる。
public class Test {
@Mock
private Sample1DomainService sample1DomainService;
@Test
public void test() {
// Arrange
// ( 初期化 )
// Act
// ( メソッド実行 )
// Assert
verify(sample1DomainService, times(1))
.branch1(any());
}
}
このやり方の場合 mock を使わざるを得ないが、UT は呼び出し先のメソッドの処理の動作には依存しないので比較的単純なコードで記載できる。
また、別クラス化したコードも当然ながら、内部でstaticメソッド化することで、UTを書き易くすることができる。
尚、分岐とメソッドの組み合わせについてはそこまで複雑でないのであれば、個人的には UT を書かないというのも選択肢としてありだと思う。
( デグレのリスクが軽微である前提 )
ただ、上記のテストを行うこととは関係なく、処理が複雑であればクラスを分けた方が良い。
なぜなら メソッドが長いか、privateメソッドの呼び出しが多くなると、メソッド同士の依存関係が複雑になり、テストしづらくなるからだ。
テストコードが書き易い以前に可読性や拡張性が優れたコードか?
テストコードが書きづらい複雑なコードの場合、そもそも可読性や拡張性に優れたコードなのかも疑うべきだ。
前述の enum の変換を使って判定処理を切り分けることもできるが、以下のように GoF の Startegy パターンや Factory Method パターンを書く方法もあるだろうし、分岐が排他的でないのであればメソッドを更に分割した方が見易い可能性もある。
private interface IStrategy {
boolean isTarget(final String str);
void exec(final String str);
}
private static class Strategy1 implements IStrategy {
public boolean isTarget(final String str) {
return "Apple".equals(str);
}
public void exec(final String str) {
}
}
private static class Strategy2 implements IStrategy {
public boolean isTarget(final String str) {
return str.length() > 1;
}
public void exec(final String str) {
}
}
private List<IStrategy> strategies = List.of(new Strategy1(), new Strategy2());
public void func(String str) {
starategy(str).ifPresent(strategy -> strategy.exec(str));
}
@VisibleForTesting
Optional<IStrategy> starategy(final String str) {
return strategies
.stream()
.filter(strategy -> strategy.isTarget(str))
.findFirst();
}
このような構造であれば前述した条件判定と呼び出しメソッドの組み合わせについてもわざわざ検証する必要もないだろう。
ステートレスであること
テストコードが書き易いコードの特徴の1つがステートレスであることだ。
ステートレスなメソッドがイメージしづらければ、複数回実行した時に毎回同じ結果であるかどうかを気にするでも良い。
ステートレスと再実行可能は別の話だが、再実行可能なメソッドは基本的にステートレスだし、今回の判断基準としてはそれで十分。
当然ながら、呼び出しタイミングによって条件や値が変わったり、副作用を行っているコードは一般的にテストコードが書きづらい。
呼び出しにごとに値が変わる典型的な例は、乱数や現在時間を使ったコードである。
public LocalDateTime tomorrow() {
return LocalDateTime.now().plusDays(1L);
}
そのような値は引数として分けた方がテストし易いコードになる。
public LocalDateTime tomorrow(final Long baseTime) {
return baseTime.plusDays(1L);
}
Webサービスでは、作成日時や更新日時、DBで自動設定される値を使うことなどはよくあるので、ステートレスな処理部分を別メソッドにしたり、検証から除外するなどの対応が必要だ。
副作用のあるコードを回避
今までも何度か記載したが、副作用を扱うコードは mock を使った検証になるため、使わない方がテストコードが書き易い。
また、副作用が必要なケースでも本当に検証したいメソッドを分けるという方法がある。
ログ出力の例
AS-IS
public void print(final Long id, final String name) {
System.out.println(String.format("output: id=%d, name=%s", id, name));
}
TO-BE
public void print(final Long id, final String name) {
System.out.println(format(id, name));
}
@VisibleForTesting
static String format(final Long id, final String name) {
return String.format("output: id=%d, name=%s", id, name);
}
非同期・並列実行の例
IO入出力以外でよくある副作用が必要なケースが並列処理や非同期処理を行うようなケースである。
public void hello() {
CompletableFuture.runAsync(() -> {
final var s = "Hello";
System.out.println(s);
});
}
このような処理は非同期実行部分を別メソッドにすることで、同期メソッドとして内容のテストができる。
public void hello() {
CompletableFuture.runAsync(this::sub);
}
@VisibleForTesting
void sub() {
final var s = "Hello";
System.out.println(s);
}
また、実際のコードでは返却値は不要だろうが、void はやめて CompletableFuture を返すようにすることで、非同期処理のままテストコードを書き易くすることができる。
public CompletableFuture<String> hello() {
return CompletableFuture.supplyAsync(() -> {
final var s = "Hello";
System.out.println(s);
return s;
});
}
テストコードではこの結果を受け取り、 Blocking することで実行を待ったり、中の値を受け取ることができる。
また、今回詳しく言及しないが、RxJava を使う場合も、subscribe をメソッド側には書かず、 doOnNext()
などを使って処理だけ定義して、実行を呼び出し側に委ねることでテストコードが書き易くなる。
( 機会があれば別の機会に記載する )
まとめ
私がコードを書く際、テストのしやすさを気にして意識する観点は以下の通り。
- 他レイヤーとの結合部分を切り分ける
- staticメソッドで書けるならそうする
- メソッドをテストしやすい単位で小さくする
- メソッドが長くなったり、privateメソッドの呼び出しが多い構造であればclassを分ける
- テストコード以前に可読性や拡張性の高いコード担っているかを見直す
- メソッドがステートレスであること(あるいは再実行可能であること)
- 副作用は避ける。避けられない場合はメソッドを分けたり、実行結果をテスト側で受け取れるようにする
これで観点が完璧とは思わないし、テストコードに関する考え方もさまざまだろうが、テストし易い構造はシンプルな構造だったりするので、実際にテストを書かなくてもそれを意識する意義はあると思う。