LoginSignup
0
0

More than 1 year has passed since last update.

JUnit5 + Mockito で enum を使った switch~case の分岐カバレッジを100%にするUTの書き方

Last updated at Posted at 2023-05-11

発端

次のような Java の enum とそれを使用するクラスがあった時、JUnit5 で単体テストを実装し、JaCoCo で分岐カバレッジ100%を達成できません。

Enum1.java
public enum Enum1 {
	VAL1, VAL2
}
EnumStatement.java
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;
	}
}
EnumStatementTest.java
@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.)を報告してきます。
image.png

v の値が VAL1 でも VAL2 でもない時のケースがないということなのでしょうが、この enum には二つの値しかないため、そんなパスを通すのは普通にテストを書いても実現できません。

アイデアとして、Enum1 に不当を意味する値を定義して、default をこの switch文に追加するか、追加しないまでもUTで不当を意味する値でテストすることで、分岐カバレッジは100%にはなります。

しかしながら、本質的に enum にとって不必要な値を定義するのは、本来の処理の仕様として考えると無意味な選択です。それどころか後々思わぬ不具合の火種にすらなり得ます。

ですので、ここはUTでなんとか(強引に)もう一つの見えざるパスに分岐させることにします。

UTクラスの改造

Java コンパイラが、euum を使った switch~case をどのようなコードにするかについては、 ひしだまさんのサイト記事に詳しく書かれていますので詳細は割愛しますが、ポイントは、switch文でenumを扱う時に、enumvalues()が返す要素数、case文ではenumordinal()の値に応じて分岐が制御されるようにJavaコンパイラがバイトコードを吐き出す点にあります。

つまり、通せていないパスは、values() が定義済みの値の数より一つ多い要素数の配列を返し、ordinal() が定義済みの値の順序番号よりも1つ大きい値を返すようにモック化することで通すことができます。

上のEnumStatementを将来のバグを防止する意味で改善したコードと、改造後のUTクラスは次のようになります。

まず、テスト対象のクラスです。

EnumStatement.java
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クラスです。

EnumStatementTest.java
@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 クラスの修正箇所が少なくなるよう可変にするためにenumvalues()を使うときは、mockStatic()try文よりも前に書く必要があります。そうしないと Mockito が正しく処理できず UT がうまく動きません。要注意です。

これでUTを実行するとめでたく、分岐カバレッジが100%(All 3 branches coverd.)になります。
image.png

switch式でも同様

細かいことは書きませんが、switch式でも同様です。

EnumExpression.java
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!");
		};
	}
}
EnumExpressionTest.java
@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));
	}
}

image.png

enumを使用するswitch式の場合は、defaultを書かない状態で、enumに値を増やすと、全ての値に対応していないとしてswitch~case文がコンパイルエラーになります。switch文では警告は出るもののコンパイルは通って動かせる状態にはなってしまうので、うっかり実装忘れたという場合でも、switch式の場合は早期発見することができる点が switch文の時とは大きく異なります。

defaultをちゃんと書きなさい、enumの値は全部書きなさい、という話は今回の記事の本質とは違うので細かい議論は避けますw

例えば次のようにVAL3を増やすしてみます。

Enum1.java
public enum Enum1 {
	VAL1, VAL2, VAL3
}

この時のdefaultを未定義の場合の、EnumStatementEnumExpressionは次のようになります。
image.png

EnumStatementのswitch文のところには警告マークがついています。

image.png

そのい一方で、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%にしなければならない」という時の一助になれば幸いです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0