かんがるーさんの日記

最近自分が興味をもったものを調べた時の手順等を書いています。今は Spring Boot をいじっています。

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 4 のテストを JUnit 5 へ書き直します。

参照したサイト・書籍

目次

  1. TestDataResource クラスを JUnit 5 の Extension としても使用できるようにする
    1. 方針
    2. TestContextWrapper インターフェースと JUnit 4 用の DescriptionWrapper クラス、JUnit 5 用の ExtensionContextWrapper クラスを実装する
    3. TestDataResource クラス → TestDataExtension クラスに変更する+関連クラスも変更する
    4. 動作確認
  2. 全てのテストを JUnit 5 のテストに書き直した後、動作確認する
  3. メモ書き
    1. IntelliJ IDEA で Optional#flatMap で変更された後の型が表示されるようになっていた

手順

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());
                    }
                });
    }

}
  • クラス名を TestDataResourceTestDataExtension に変更します。
  • implements BeforeEachCallback, AfterEachCallback を追加します。
  • private void before(TestContextWrapper testContextWrapper) { ... } を追加し、starting メソッド内の処理を移動してから descriptiontestContextWrapper に変更します。
  • private void after(TestContextWrapper testContextWrapper) { ... } を追加し、finished メソッド内の処理を移動してから descriptiontestContextWrapper に変更します。
  • hasNoUseTestDataResourceAnnotation, getBaseTestDir, loadTestData の引数を Description descriptionTestContextWrapper 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 descriptionTestContextWrapper testContextWrapper に変更します。

動作確認

JUnit 4 のテストで正常に動作することを確認します。src/test/java/ksbysample/webapp/lending/webapi/library/LibraryControllerTest.javaJUnit 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)));
    }

}

これを実行するとテストは成功します。

f:id:ksby:20190309203727p:plain

次に 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 を削除します。

実行すると、こちらもテストが成功します。

f:id:ksby:20190309204234p:plain

全てのテストを 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" のメッセージが出力されました。

f:id:ksby:20190309231802p:plain

Project Tool Window で src/test/groovy/ksbysample と src/test/java/ksbysample でコンテキストメニューを表示して「Run 'All Tests'」を選択すると、こちらもテストが全て成功しました。

f:id:ksby:20190309232530p:plain f:id:ksby:20190309232852p:plain

メモ書き

IntelliJ IDEA で Optional#flatMap で変更された後の型が表示されるようになっていた

ExtensionContextWrapper クラスを実装していた時に気づきましたが、flatMap を呼び出して変換した後の型が表示されるようになっていました。さりげなく便利になっていますね。

f:id:ksby:20190309194600p:plain

履歴

2019/03/10
初版発行。