発端
次のような Java の enum とそれを使用するクラスがあった時、JUnit5 で単体テストを実装し、JaCoCo で分岐カバレッジ100%を達成できません。
public enum Enum1 {
VAL1, VAL2
}
public class EnumStatement {
public static String toString(final Enum1 v) {
String stringValue = null;
switch (v) {
case VAL1:
stringValue = "V1";
break;
case VAL2:
stringValue = "V2";
break;
}
return stringValue;
}
}
@TestInstance(PER_CLASS)
class EnumStatementTest {
@ParameterizedTest
@MethodSource
void test(final Enum1 value, final String expected) {
final String stringValue = EnumStatement.toString(value));
assertThat(stringValue, is(expected));
}
}
Stream<Arguments> test() {
final var mockedEnum1Value = mock(Enum1.class);
doReturn(2).when(mockedEnum1Value).ordinal();
return Stream.of(
arguments(Enum1.VAL1, "V1"),
arguments(Enum1.VAL2, "V2"));
}
}
JaCoCo はこのswitch
文のところで次のような状態(1 of 3 branches missed.
)を報告してきます。
v
の値が VAL1
でも VAL2
でもない時のケースがないということなのでしょうが、この enum には二つの値しかないため、そんなパスを通すのは普通にテストを書いても実現できません。
アイデアとして、Enum1
に不当を意味する値を定義して、default
をこの switch
文に追加するか、追加しないまでもUTで不当を意味する値でテストすることで、分岐カバレッジは100%にはなります。
しかしながら、本質的に enum にとって不必要な値を定義するのは、本来の処理の仕様として考えると無意味な選択です。それどころか後々思わぬ不具合の火種にすらなり得ます。
ですので、ここはUTでなんとか(強引に)もう一つの見えざるパスに分岐させることにします。
UTクラスの改造
Java コンパイラが、euum を使った switch~case
をどのようなコードにするかについては、 ひしだまさんのサイトの記事に詳しく書かれていますので詳細は割愛しますが、ポイントは、switch
文でenum
を扱う時に、enum
のvalues()
が返す要素数、case
文ではenum
のordinal()
の値に応じて分岐が制御されるようにJavaコンパイラがバイトコードを吐き出す点にあります。
つまり、通せていないパスは、values()
が定義済みの値の数より一つ多い要素数の配列を返し、ordinal()
が定義済みの値の順序番号よりも1つ大きい値を返すようにモック化することで通すことができます。
上のEnumStatement
を将来のバグを防止する意味で改善したコードと、改造後のUTクラスは次のようになります。
まず、テスト対象のクラスです。
public class EnumStatement {
public static String toString(final Enum1 v) {
String stringValue = null;
switch (v) {
case VAL1:
stringValue = "V1";
break;
case VAL2:
stringValue = "V2";
break;
default:
throw new IllegalStateException("Invalid Enum1 value!");
}
return stringValue;
}
}
default
を追加し、そこへ来ることがあった場合は例外をスローするようにしました。
これにより、将来Enum1
に値が追加されたときに、このswitch~case
を適切に変更しないと、例外が発生することになり、何らかのテストで問題が発見されることになります。
続いてUTクラスです。
@TestInstance(PER_CLASS)
class EnumStatementTest {
@ParameterizedTest
@MethodSource
void test(final Enum1 value, final String expected, final Class<IllegalStateException> exception) {
final int valuesLength = Enum1.values().length;
try (MockedStatic<Enum1> mockStatic = mockStatic(Enum1.class)) {
mockStatic.when(Enum1::values).thenReturn(new Enum1[valuesLength + 1]);
if (exception == null) {
final String stringValue = assertDoesNotThrow(() -> EnumStatement.toString(value));
assertThat(stringValue, is(expected));
} else {
final IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> EnumStatement.toString(value));
assertThat(thrown.getMessage(), is("Invalid Enum1 value!"));
}
}
}
Stream<Arguments> test() {
final var mockedEnum1Value = mock(Enum1.class);
final int valuesLength = Enum1.values().length;
doReturn(valuesLength).when(mockedEnum1Value).ordinal();
return Stream.of(
arguments(Enum1.VAL1, "V1", null),
arguments(Enum1.VAL2, "V2", null),
arguments(mockedEnum1Value, "Invalid Enum1 value!", IllegalStateException.class));
}
}
まずvalues()
の返す値をモック化するには、Mockito の mockStatic(Class<T>)
を使用します。
values()
の戻す値は、中身はからでも良いので、本来の値の数より一つ大きい配列を作って、thenReturn(new Enum1[<本当の要素数>+1])
のようにします。
<本当の要素数>
をenum
の値が増えても、UT クラスの修正箇所が少なくなるよう可変にするためにenum
のvalues()
を使うときは、mockStatic()
のtry
文よりも前に書く必要があります。そうしないと Mockito が正しく処理できず UT がうまく動きません。要注意です。
これでUTを実行するとめでたく、分岐カバレッジが100%(All 3 branches coverd.
)になります。
switch
式でも同様
細かいことは書きませんが、switch
式でも同様です。
public class EnumExpression {
public static String toString(final Enum1 v) {
return switch (v) {
case VAL1 -> "V1";
case VAL2 -> "V2";
default -> throw new IllegalStateException("Invalid Enum1 value!");
};
}
}
@TestInstance(Lifecycle.PER_CLASS)
class EnumExpressionTest {
@ParameterizedTest
@MethodSource
void test(final Enum1 value, final String expected, final Class<IllegalStateException> exception) {
final int valuesLength = Enum1.values().length;
try (MockedStatic<Enum1> mockStatic = mockStatic(Enum1.class)) {
mockStatic.when(Enum1::values).thenReturn(new Enum1[valuesLength + 1]);
if (exception == null) {
final String stringValue = assertDoesNotThrow(() -> EnumExpression.toString(value));
assertThat(stringValue, is(expected));
} else {
final IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> EnumExpression.toString(value));
assertThat(thrown.getMessage(), is(expected));
}
}
}
Stream<Arguments> test() {
final var mockedEnum1Value = mock(Enum1.class);
final int valuesLength = Enum1.values().length;
doReturn(valuesLength).when(mockedEnum1Value).ordinal();
return Stream.of(
arguments(Enum1.VAL1, "V1", null),
arguments(Enum1.VAL2, "V2", null),
arguments(mockedEnum1Value, "Invalid Enum1 value!", IllegalStateException.class));
}
}
enum
を使用するswitch
式の場合は、default
を書かない状態で、enum
に値を増やすと、全ての値に対応していないとしてswitch~case
文がコンパイルエラーになります。switch
文では警告は出るもののコンパイルは通って動かせる状態にはなってしまうので、うっかり実装忘れたという場合でも、switch
式の場合は早期発見することができる点が switch
文の時とは大きく異なります。
default
をちゃんと書きなさい、enum
の値は全部書きなさい、という話は今回の記事の本質とは違うので細かい議論は避けますw
例えば次のようにVAL3
を増やすしてみます。
public enum Enum1 {
VAL1, VAL2, VAL3
}
この時のdefault
を未定義の場合の、EnumStatement
とEnumExpression
は次のようになります。
EnumStatementのswitch
文のところには警告マークがついています。
そのい一方で、EnumExpressionのswitch
式のところにはエラーマークがついています。
警告とエラーの差は大きいですね!
最後に
ここで共有した内容は、Javaコンパイラが吐き出すバイトコードベースの内容であって、その内容が変わると期待通りに動作しなくなります。UTをコンパイラの実装に依存させることの善悪は一つの議論かと思いますので、ここでは割愛しますがご留意ください。
また、switch
文に至るまでのコードパスの中で、values()
やordinal()
が呼ばれるようなコードが幾度となくある場合、うまくモック化するのが難しいケースもあると思います。強引にやるとUTクラスが修正しにくい(わかりにくい)ものになりかねないので、そういった場合はおそらくテスト対象のクラス構成の見直しや、メソッド分割等のリファクタリングも視野に入れる必要があるのかもしれません。
モックのメソッドが呼び出された回数で応答する値を変更することも可能なので、実装できないということはないと思います。
doReturn(1, 2, 3).when(...)
mockStatick.when(...).thenReturn(1).thenReturn(2).thenReturn(3)
のようにすると、1回目、2回目、3回目以降、の返す値を指定したことになります。(when
の...
は書くのを端折ってるだけですw)
「なんとか分岐網羅を100%にしなければならない」という時の一助になれば幸いです。