はじめに
JavaとSpringBootフレームワークを使用したWebアプリケーション開発の勉強をしています。
JUnit5で単体テストのテストメソッドを実装する上で、例外のスローを検証したいと考えた際に戸惑った部分がありました。
package org.junit.jupiter.api;
public class Assertions {
/**省略*/
public static <T extends Throwable> T assertThrows(Class<T> expectedType, Executable executable) {
return (T)AssertThrows.assertThrows(expectedType, executable);
}
}
例外が投げられているかの検証としてassertThrows
を使用したかったのですが、「引数のClass<T> expectedType
はわかるとしてExecutable executable
は何…?」
と困惑してしまったので、それについて調べたことを初学者の視点から備忘録として残したいと思います。
伝わりやすさに重点を置いて書いているので、しばしば表現が厳密でない部分もあるかとおもいますが、ご容赦ください。
成功したテストメソッドの実装
先んじて、どのように書いてコンパイルが通ったかを記しておきたいと思います。
長いので周辺の詳細は適宜ご参照ください
例として用いる処理
id
から製品情報(Product
)を検索します。
データベースにid
と一致するレコードがない場合に、サービス層でカスタム例外クラスInvalidIdException
をスローします。下記のシーケンス図の色付きの部分をテスト対象とします。
public class ProductService {
private ProductRepository repository;
@Autowired
public StudentService(ProductRepository repository) {
this.repository = repository;
}
// ↓テスト対象メソッド
public Product searchProduct(int id) {
Product product = repository.selectProduct(id);
if (product == null) {
throw new InvalidIdException(id);
} else {
return product;
}
}
}
public class ProductRepository {
public Product selectProduct(int id){
// DBでidに一致するレコードを取得し、Productインスタンスとして呼び出し元にリストで返す
// 一致するレコードがない場合は呼び出し元にnullを返す
}
}
public class InvalidIdException extends RuntimeException {
public InvalidIdException(int id) {
super("存在しないIDです:" + id);
}
}
テスト対象メソッド
public class ProductService {
private ProductRepository repository;
@Autowired
public ProductService(ProductRepository repository) {
this.repository = repository;
}
// ↓テスト対象メソッド
public Product searchProduct(int id) {
Product product = repository.selectProduct(id);
if (product == null) {
throw new InvalidIdException(id);
} else {
return product;
}
}
}
テストメソッド
@ExtendWith(MockitoExtension.class)
class ProductServiceTest {
@Mock
private ProductRepository repository;
private ProductService sut;
@BeforeEach
private void before() {
sut = new ProductService(repository);
}
@Test
void 製品検索失敗_リポジトリの処理を呼び出し戻り値がnullの場合は例外を投げていること()
throws Exception {
// Arrange
int id = 99;
Product nullProduct = null;
Mockito.when(repository.selectProduct(id)).thenReturn(nullProduct);;
// Act & Assert
Assertions.assertThrows(
InvalidIdException.class, () -> {
sut.searchProduct(id);
});
Mockito.verify(repository, times(1)).selectProduct(id);
}
}
第2引数Executable executable
として、() -> { sut.searchProduct(id);}
を渡しています。
Perplexityに手伝ってもらいながら書きました。
Perplexityが言うには、
『Executable executableには、「例外が発生することを期待する処理」をラムダ式またはメソッド参照で渡します。
このExecutableは関数型インターフェースで、引数なし・戻り値なしのvoidメソッド(例:void run() throws Throwable)を実装するものです』
とのことです。
とりあえず、例外が発生することを検証したいテスト対象メソッドが、引数が1つだけなのであれば、上記のコードブロック内のsut.searchProduct(id);
を丸々任意のテスト対象メソッドに書き換えればコンパイルは通りそうです。
Executableについてもう少し詳しく
Executableをはじめとした関数型インタフェースは、共通して「ラムダ式またはメソッド参照で実装できる」そうなので、関数型インタフェースを知っていればすぐ答えが出たのだと思うのですが、私は知らなかったのでPerplexityが何を言っているのか全く理解できませんでした。
そこで、もう少し掘り下げていきたいと思います。
関数型インタフェースとラムダ式
ざっくり説明すると、関数型インタフェースは 「関数(処理)そのものを引数として受け取ったり、返り値として返したりすることをサポートするもの」 のことです。
また、成立条件として「抽象メソッドを一つだけ持っているインタフェースである」必要があります。
たとえばExcutableインタフェースの中身はこれだけです↓
package org.junit.jupiter.api.function;
/**省略 */
@FunctionalInterface
@API( /**省略 */)
public interface Executable {
void execute() throws Throwable;
}
Javaにおけるメソッドは、基本的に受け取ったり返したりするのはオブジェクト(プリミティブ型の値含む)なので、ピンと来ないと思います。
関数型インタフェースでは、関数(処理)をラムダ式としてオブジェクト(インスタンス)化することで、関数を受け取ったり返したりということを可能にしています。
ラムダ式
私はラムダ式のことを「コードを簡略化できるもの」という程度にしか認識していなかったので苦戦していました。そもそもラムダ式は、「関数型インタフェースの実装を簡略化するために用意されたもの」で、ラムダ式を書くところは常に関数型インタフェースを実装しているんだそうです。
関数(処理)をインスタンス化するとは
Assertions.assertThrows(
InvalidIdException.class, () -> {
sut.searchProduct(id);
});
前述したassertThrows()メソッドは、ラムダ式を第2引数として渡しています。
「ラムダ式がインスタンスになるってどういうこと?」
と混乱してしまうのですが、ラムダ式を使わずに同等の動きをするコードを書こうとすると以下のようになります。
Assertions.assertThrows(
InvalidIdException.class,
// ラムダ式を無名クラスに置き換えて記述
new Executable() {
@Override
public void execute() throws Throwable {
sut.searchProduct(id);
}
}
);
(無名クラスについては、ここで暗黙的に無名のクラスを宣言しているとだけ認識していてください)
上記のコードでは、第2引数でExecutableのインスタンスを生成しています。
そして、Executableはインタフェースで抽象メソッドしか持たないため、インスタンスを生成したときに必ずオーバーライドして処理の中身を実装する必要があります。
つまり、Executable型のインスタンス(実体はExecutableをimplementした無名クラスのインスタンス)に、プログラマが定義した処理が組み込まれています。
ラムダ式は、無名クラスを使ったこのようなコードを簡略化し、プログラマが実際に意味のある処理以外をほぼ書かなくていいようにしています。
このような仕組みによって、見かけ上「処理(関数)をインスタンスとして渡す」ことが実現可能となるのです。
Assertions.assertThrows()
では、引数で受け取ったこの処理を自身のメソッド内のExecutable.execute()
で実行し、try/catchで例外の発生を検証しています。
おわりに
・Executableは、関数型インタフェースである
・Executable executable
には、Executableインタフェースを実装した無名クラスのインスタンスを渡す。
・関数型インタフェースを実装した無名クラスのインスタンスは、ラムダ式で表現できる。
上記がまとめとなります。
Executableって何だろう、関数型インタフェースって何だろうという疑問を端に発した調べものでしたが、ラムダ式に対する勘違いを正すことができたり、とても有用でした。
参考
https://qiita.com/KenyaSaitoh/items/98be8b9140aa195409d7
https://zenn.dev/jk447/articles/0a1a5d5f053f02
https://docs.oracle.com/javase/jp/17/docs/api/java.base/java/util/function/package-summary.html