Spring Boot 2.0.x の Web アプリを 2.1.x へバージョンアップする ( その12 )( @Rule を使用しているテストを JUnit 5 のテストに書き直す2 )
概要
記事一覧はこちらです。
Spring Boot 2.0.x の Web アプリを 2.1.x へバージョンアップする ( その11 )( @Rule を使用しているテストを JUnit 5 のテストに書き直す ) の続きです。
参照したサイト・書籍
目次
手順
TestDataResource クラスを JUnit 5 の Extension としても使用できるようにする
方針
- TestWatcher クラスは継承したまま BeforeEachCallback, AfterEachCallback インターフェースを実装します。
- JUnit 5.4.0 から TestWatcher インターフェースが実装されましたが JUnit 4 とは機能が異なるため(testSuccessful, testFailed 等テスト結果に応じて呼び出されるメソッドを実装するためのものでした)、今回の実装では使いません。
- TestDataResource クラスが ExternalResource クラスではなく TestWatcher クラスを継承しているのはテスト開始・終了時に Description クラスのインスタンスを受け取ってテストクラスに定義されたアノテーションに応じた処理を実行したいためです。JUnit 5 の BeforeEachCallback, AfterEachCallback インターフェースの beforeEach, afterEach メソッドを override すると引数に ExtensionContext context が渡されますが、これは JUnit 4 の starting, finished メソッドに渡される Description description と型が異なります。
- Description description は以下の用途で使用していました。
- 調べたところ ExtensionContext context でも上の3点は実現可能でしたので、共通インターフェースとそれを実施する JUnit4 の Description、JUnit 5 の ExtensionContext の Wrapper クラスを作成して違いを吸収することにします。
- 実際の処理は private メソッドに移動して、starting, finished メソッド及び beforeEach, afterEach メソッドから private メソッドを呼び出すようにします。
以下のような形に変更します。
@Component public class TestDataExtension extends TestWatcher implements BeforeEachCallback, AfterEachCallback { .......... @Override protected void starting(Description description) { before(new DescriptionWrapper(description)); } @Override protected void finished(Description description) { after(new DescriptionWrapper(description)); } @Override public void beforeEach(ExtensionContext context) { before(new ExtensionContextWrapper(context)); } @Override public void afterEach(ExtensionContext context) { after(new ExtensionContextWrapper(context)); } private void before(TestContextWrapper testContextWrapper) { .......... } private void after(TestContextWrapper testContextWrapper) { .......... } .......... }
TestContextWrapper インターフェースと JUnit 4 用の DescriptionWrapper クラス、JUnit 5 用の ExtensionContextWrapper クラスを実装する
最初に共通インターフェースを定義します。src/test/java/ksbysample/common/test/helper の下に TestContextWrapper.java を新規作成し、以下の内容を記述します。
package ksbysample.common.test.helper; import java.lang.annotation.Annotation; import java.util.Collection; public interface TestContextWrapper { Collection<Annotation> getAnnotations(); <T extends Annotation> T getAnnotation(Class<T> annotationType); Class<?> getTestClass(); }
JUnit 4 用の DescriptionWrapper クラスを実装します。src/test/java/ksbysample/common/test/helper の下に DescriptionWrapper.java を新規作成し、以下の内容を記述します。
package ksbysample.common.test.helper; import org.junit.runner.Description; import java.lang.annotation.Annotation; import java.util.Collection; public class DescriptionWrapper implements TestContextWrapper { private Description description; public DescriptionWrapper(Description description) { this.description = description; } @Override public Collection<Annotation> getAnnotations() { return description.getAnnotations(); } @Override public <T extends Annotation> T getAnnotation(Class<T> annotationType) { return description.getAnnotation(annotationType); } @Override public Class<?> getTestClass() { return description.getTestClass(); } }
JUnit 5 用の ExtensionContextWrapper クラスを実装します。src/test/java/ksbysample/common/test/helper の下に ExtensionContextWrapper.java を新規作成し、以下の内容を記述します。
package ksbysample.common.test.helper; import org.junit.jupiter.api.extension.ExtensionContext; import java.lang.annotation.Annotation; import java.util.Arrays; import java.util.Collection; import java.util.Optional; public class ExtensionContextWrapper implements TestContextWrapper { private ExtensionContext context; public ExtensionContextWrapper(ExtensionContext context) { this.context = context; } @Override public Collection<Annotation> getAnnotations() { return context.getTestMethod() .flatMap(m -> Optional.ofNullable(m.getAnnotations())) .flatMap(a -> Optional.ofNullable(Arrays.asList(a))) .orElse(null); } @Override public <T extends Annotation> T getAnnotation(Class<T> annotationType) { return context.getTestMethod() .flatMap(m -> Optional.ofNullable(m.getAnnotation(annotationType))) .orElse(null); } @Override public Class<?> getTestClass() { return context.getTestClass().orElse(null); } }
TestDataResource クラス → TestDataExtension クラスに変更する+関連クラスも変更する
src/test/java/ksbysample/common/test/rule/db ディレクトリを src/test/java/ksbysample/common/test/extension/db へ移動した後、TestDataResource.java を以下のように変更します。
package ksbysample.common.test.extension.db; import ksbysample.common.test.helper.DescriptionWrapper; import ksbysample.common.test.helper.ExtensionContextWrapper; import ksbysample.common.test.helper.TestContextWrapper; import org.dbunit.DatabaseUnitException; import org.dbunit.database.IDatabaseConnection; import org.dbunit.database.QueryDataSet; import org.dbunit.dataset.DataSetException; import org.dbunit.dataset.IDataSet; import org.dbunit.dataset.ReplacementDataSet; import org.dbunit.dataset.xml.FlatXmlDataSet; import org.dbunit.dataset.xml.FlatXmlDataSetBuilder; import org.dbunit.operation.DatabaseOperation; import org.junit.jupiter.api.extension.AfterEachCallback; import org.junit.jupiter.api.extension.BeforeEachCallback; import org.junit.jupiter.api.extension.ExtensionContext; import org.junit.rules.TestWatcher; import org.junit.runner.Description; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import javax.sql.DataSource; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.lang.annotation.Annotation; import java.lang.reflect.Field; import java.net.MalformedURLException; import java.nio.file.Files; import java.nio.file.Paths; import java.sql.Connection; import java.sql.SQLException; import java.util.Arrays; import java.util.Collection; import java.util.List; import static java.util.Comparator.comparing; @Component public class TestDataExtension extends TestWatcher implements BeforeEachCallback, AfterEachCallback { private static final String BASETESTDATA_ROOT_DIR = "src/test/resources/"; private static final String TESTDATA_ROOT_DIR = "src/test/resources/ksbysample/webapp/lending/"; private static final String BASETESTDATA_DIR = BASETESTDATA_ROOT_DIR + "testdata/base"; private static final String BACKUP_FILE_NAME = "ksbylending_backup"; @Autowired private DataSource dataSource; @Autowired private TestDataLoader testDataLoader; private File backupFile; @Override protected void starting(Description description) { before(new DescriptionWrapper(description)); } @SuppressWarnings("Finally") @Override protected void finished(Description description) { after(new DescriptionWrapper(description)); } @Override public void beforeEach(ExtensionContext context) { before(new ExtensionContextWrapper(context)); } @Override public void afterEach(ExtensionContext context) { after(new ExtensionContextWrapper(context)); } private void before(TestContextWrapper testContextWrapper) { // @NouseTestDataResource アノテーションがテストメソッドに付加されていない場合には処理を実行する if (!hasNoUseTestDataResourceAnnotation(testContextWrapper)) { IDatabaseConnection conn = null; try (Connection connection = dataSource.getConnection()) { conn = DbUnitUtils.createDatabaseConnection(connection); // バックアップ&ロード&リストア対象のテストデータのパスを取得する String testDataBaseDir = getBaseTestDir(testContextWrapper); // バックアップを取得する backupDb(conn, testDataBaseDir); // テストデータをロードする testDataLoader.load(conn, testDataBaseDir); // @BaseTestSql アノテーションで指定された SQL を実行する TestSqlExecutor<BaseTestSqlList, BaseTestSql> baseTestSqlExecutor = new TestSqlExecutor<>(BaseTestSqlList.class, BaseTestSql.class); baseTestSqlExecutor.execute(connection, testContextWrapper); // テストメソッドに @TestData アノテーションが付加されている場合には、 // アノテーションで指定されたテストデータをロードする loadTestData(conn, testContextWrapper); // @TestSql アノテーションで指定された SQL を実行する TestSqlExecutor<TestSqlList, TestSql> testSqlExecutor = new TestSqlExecutor<>(TestSqlList.class, TestSql.class); testSqlExecutor.execute(connection, testContextWrapper); } catch (Exception e) { throw new RuntimeException(e); } finally { try { if (conn != null) conn.close(); } catch (Exception ignored) { } } } } private void after(TestContextWrapper testContextWrapper) { IDatabaseConnection conn = null; try (Connection connection = dataSource.getConnection()) { // @NouseTestDataResource アノテーションがテストメソッドに付加されていない場合には処理を実行する if (!hasNoUseTestDataResourceAnnotation(testContextWrapper)) { conn = DbUnitUtils.createDatabaseConnection(connection); // バックアップからリストアする restoreDb(conn); } } catch (Exception e) { throw new RuntimeException(e); } finally { try { if (conn != null) conn.close(); } catch (Exception ignored) { } if (backupFile != null) { try { Files.delete(backupFile.toPath()); } catch (Exception e) { throw new RuntimeException(e); } backupFile = null; } } } private boolean hasNoUseTestDataResourceAnnotation(TestContextWrapper testContextWrapper) { Collection<Annotation> annotationList = testContextWrapper.getAnnotations(); boolean result = annotationList.stream() .anyMatch(annotation -> annotation instanceof NoUseTestDataResource); return result; } private String getBaseTestDir(TestContextWrapper testContextWrapper) { // @BaseTestData アノテーションで指定されている場合にはそれを使用し、指定されていない場合には // BASETESTDATA_DIR 定数で指定されているものと使用する // テストメソッドに @BaseTestData アノテーションが付加されているかチェックする BaseTestData baseTestData = testContextWrapper.getAnnotation(BaseTestData.class); if (baseTestData != null) { return BASETESTDATA_ROOT_DIR + baseTestData.value(); } // TestDataResource クラスのフィールドに @BaseTestData アノテーションが付加されているかチェックする Field[] fields = testContextWrapper.getTestClass().getDeclaredFields(); for (Field field : fields) { if (field.getType().equals(TestDataExtension.class)) { baseTestData = field.getAnnotation(BaseTestData.class); if (baseTestData != null) { return BASETESTDATA_ROOT_DIR + baseTestData.value(); } } } // テストクラスに @BaseTestData アノテーションが付加されているかチェックする Class<?> testClass = testContextWrapper.getTestClass(); baseTestData = testClass.getAnnotation(BaseTestData.class); if (baseTestData != null) { return BASETESTDATA_ROOT_DIR + baseTestData.value(); } return BASETESTDATA_DIR; } private void backupDb(IDatabaseConnection conn, String testDataBaseDir) throws DataSetException, IOException { QueryDataSet partialDataSet = new QueryDataSet(conn); // BASETESTDATA_DIR で指定されたディレクトリ内の table-ordering.txt に記述されたテーブル名一覧を取得し、 // バックアップテーブルとしてセットする List<String> backupTableList = Files.readAllLines(Paths.get(testDataBaseDir, "table-ordering.txt")); for (String backupTable : backupTableList) { partialDataSet.addTable(backupTable); } ReplacementDataSet replacementDatasetBackup = new ReplacementDataSet(partialDataSet); replacementDatasetBackup.addReplacementObject(null, DbUnitUtils.NULL_STRING); this.backupFile = File.createTempFile(BACKUP_FILE_NAME, "xml"); try (FileOutputStream fos = new FileOutputStream(this.backupFile)) { FlatXmlDataSet.write(replacementDatasetBackup, fos); } } private void restoreDb(IDatabaseConnection conn) throws MalformedURLException, DatabaseUnitException, SQLException { if (this.backupFile != null) { IDataSet dataSet = new FlatXmlDataSetBuilder().build(this.backupFile); ReplacementDataSet replacementDatasetRestore = new ReplacementDataSet(dataSet); replacementDatasetRestore.addReplacementObject(DbUnitUtils.NULL_STRING, null); DatabaseOperation.CLEAN_INSERT.execute(conn, replacementDatasetRestore); } } private void loadTestData(IDatabaseConnection conn, TestContextWrapper testContextWrapper) { testContextWrapper.getAnnotations().stream() .filter(annotation -> annotation instanceof TestDataList || annotation instanceof TestData) .forEach(annotation -> { if (annotation instanceof TestDataList) { TestDataList testDataList = (TestDataList) annotation; Arrays.asList(testDataList.value()).stream() .sorted(comparing(testData -> testData.order())) .forEach(testData -> testDataLoader.load(conn, TESTDATA_ROOT_DIR + testData.value())); } else { TestData testData = (TestData) annotation; testDataLoader.load(conn, TESTDATA_ROOT_DIR + testData.value()); } }); } }
- クラス名を
TestDataResource
→TestDataExtension
に変更します。 implements BeforeEachCallback, AfterEachCallback
を追加します。private void before(TestContextWrapper testContextWrapper) { ... }
を追加し、starting メソッド内の処理を移動してからdescription
→testContextWrapper
に変更します。private void after(TestContextWrapper testContextWrapper) { ... }
を追加し、finished メソッド内の処理を移動してからdescription
→testContextWrapper
に変更します。- hasNoUseTestDataResourceAnnotation, getBaseTestDir, loadTestData の引数を
Description description
→TestContextWrapper testContextWrapper
に変更します。 - starting メソッド内の処理を
before(new DescriptionWrapper(description));
に変更します。 - finished メソッド内の処理を
after(new DescriptionWrapper(description));
に変更します。 - beforeEach メソッドを追加し、メソッド内に
before(new ExtensionContextWrapper(context));
を記述します。 - afterEach メソッドを追加し、メソッド内に
after(new ExtensionContextWrapper(context));
を記述します。
src/test/java/ksbysample/common/test/extension/db/TestSqlExecutor.java の以下の点を変更します。
public class TestSqlExecutor<L extends Annotation, I extends Annotation> { .......... public void execute(Connection connection, TestContextWrapper testContextWrapper) throws SQLException { .......... } ..........
- execute メソッドの引数を
Description description
→TestContextWrapper testContextWrapper
に変更します。
動作確認
JUnit 4 のテストで正常に動作することを確認します。src/test/java/ksbysample/webapp/lending/webapi/library/LibraryControllerTest.java は JUnit 4 のテストとして以下のように実装されていますが、
package ksbysample.webapp.lending.webapi.library; import ksbysample.common.test.extension.db.TestDataExtension; import ksbysample.common.test.extension.mockmvc.SecurityMockMvcExtension; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.junit4.SpringRunner; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.startsWith; import static org.hamcrest.Matchers.hasSize; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @RunWith(SpringRunner.class) @SpringBootTest public class LibraryControllerTest { @Rule @Autowired public TestDataExtension testDataExtension; @Rule @Autowired public SecurityMockMvcExtension mvc; @Test public void 正しい都道府県を指定した場合には図書館一覧が返る() throws Exception { mvc.noauth.perform(get("/webapi/library/getLibraryList?pref=東京都")) .andExpect(status().isOk()) .andExpect(content().contentType("application/json;charset=UTF-8")) .andExpect(jsonPath("$.errcode", is(0))) .andExpect(jsonPath("$.errmsg", is(""))) .andExpect(jsonPath("$.content[0].address", startsWith("東京都"))) .andExpect(jsonPath("$.content[?(@.formal=='国立国会図書館東京本館')]").exists()); } @Test public void 間違った都道府県を指定した場合にはエラーが返る() throws Exception { mvc.noauth.perform(get("/webapi/library/getLibraryList?pref=東a京都")) .andExpect(status().isOk()) .andExpect(content().contentType("application/json;charset=UTF-8")) .andExpect(jsonPath("$.errcode", is(-2))) .andExpect(jsonPath("$.errmsg", is("都道府県名が正しくありません。"))) .andExpect(jsonPath("$.content", hasSize(0))); } }
これを実行するとテストは成功します。
次に JUnit 5 で以下のように書き直してから、
package ksbysample.webapp.lending.webapi.library; import ksbysample.common.test.extension.db.TestDataExtension; import ksbysample.common.test.extension.mockmvc.SecurityMockMvcExtension; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.startsWith; import static org.hamcrest.Matchers.hasSize; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @SpringBootTest public class LibraryControllerTest { @RegisterExtension @Autowired public TestDataExtension testDataExtension; @RegisterExtension @Autowired public SecurityMockMvcExtension mvc; @Test void 正しい都道府県を指定した場合には図書館一覧が返る() throws Exception { mvc.noauth.perform(get("/webapi/library/getLibraryList?pref=東京都")) .andExpect(status().isOk()) .andExpect(content().contentType("application/json;charset=UTF-8")) .andExpect(jsonPath("$.errcode", is(0))) .andExpect(jsonPath("$.errmsg", is(""))) .andExpect(jsonPath("$.content[0].address", startsWith("東京都"))) .andExpect(jsonPath("$.content[?(@.formal=='国立国会図書館東京本館')]").exists()); } @Test void 間違った都道府県を指定した場合にはエラーが返る() throws Exception { mvc.noauth.perform(get("/webapi/library/getLibraryList?pref=東a京都")) .andExpect(status().isOk()) .andExpect(content().contentType("application/json;charset=UTF-8")) .andExpect(jsonPath("$.errcode", is(-2))) .andExpect(jsonPath("$.errmsg", is("都道府県名が正しくありません。"))) .andExpect(jsonPath("$.content", hasSize(0))); } }
import org.junit.Test;
→import org.junit.jupiter.api.Test;
に変更します。@RunWith(SpringRunner.class)
を削除します。@Rule
→@RegisterExtension
に変更します。- テストメソッドの
public
を削除します。
実行すると、こちらもテストが成功します。
全てのテストを JUnit 5 のテストに書き直した後、動作確認する
JUnit 4 で @RunWith(Enclosed.class)
アノテーションでテストをネストさせている場合の修正方法を記載していなかったので記載します。
以下のように実装している場合には、
@RunWith(Enclosed.class) public class LendingappControllerTest { @RunWith(SpringRunner.class) @SpringBootTest public static class 貸出申請画面の初期表示のテスト_エラー処理 { ..........
JUnit 5 にする場合には以下のように変更します。
public class LendingappControllerTest { @Nested @SpringBootTest class 貸出申請画面の初期表示のテスト_エラー処理 { ..........
@RunWith(Enclosed.class)
を削除します。- インナークラスの
@RunWith(SpringRunner.class)
→@Nested
に変更し、public static class ...
→class ...
に変更します(public static
を削除します)。
JUnit 4 のテストを JUnit 5 のテストに書き直した後、clean タスク実行 → Rebuild Project 実行 → build タスクを実行すると "BUILD SUCCESSFUL" のメッセージが出力されました。
Project Tool Window で src/test/groovy/ksbysample と src/test/java/ksbysample でコンテキストメニューを表示して「Run 'All Tests'」を選択すると、こちらもテストが全て成功しました。
メモ書き
IntelliJ IDEA で Optional#flatMap で変更された後の型が表示されるようになっていた
ExtensionContextWrapper クラスを実装していた時に気づきましたが、flatMap を呼び出して変換した後の型が表示されるようになっていました。さりげなく便利になっていますね。
履歴
2019/03/10
初版発行。