Java

Spring BootとDBUnitで外部キー制約エラー対策!外部キーを一時的に無効に(解除)する

Spring Bootで作成中のプロジェクトでDB周りのテストを行おうとした時に、外部キーのエラーに悩まされたことはありませんか?

あるある、ですよね?
できることなら外部キーは貼らないで欲しいのですが…
データ設計者が貼ってしまったものに対しては大人しく従わざるを得ません…

でも、外部キーの連鎖でコード単体テストのためにマスタからトランザクションデータから整合性のとれたデータを作成するのは、かなりの労力です。

というわけで、外部キーを一時的に解除する方法を調べて、
DBUnit実行時に無効にする方法を考えてみました。

データベースの外部キーを一時解除するには?

外部キーを一時的に無効にする方法はデータベースによって異なります。

PostgreSQLの外部キーを無効にする

使用しているデータベースがPostgresの場合、解除方法は2通りあります。
1.テーブル単位
2.データベース一括で

■テーブル単位で外部キーの無効

テーブル単位で無効にするSQLはこちら

無効:ALTER TABLE [tableName] DISABLE TRIGGER ALL;

有効:ALTER TABLE [tableName] ENABLE TRIGGER ALL;

 

■データベース単位で外部キーを無効

データベース単位で有効→無効を切り替えるには、前提条件があります。
外部キー制約を生成する時に、「DEFERRABLE」を指定していることです。

無効:SET CONSTRAINTS ALL DEFERRED;

有効:SET CONSTRAINTS ALL IMMEDIATE;

データベース単位で無効にできればよかったのですが、私の場合は「DEFERRABLE」を指定せずに外部キーを作成されていなかったため、諦めました。

MySQLの外部キーを無効にする

テーブル単位で外部キーの無効

テーブル単位の有効→無効の切り替えはできないようです。
その場合、いったん削除「Drop」してから外部キーを貼りなおすことになります。

データベース単位で外部キーを無効

データベース単位のSQLはありました。

無効:SET FOREIGN_KEY_CHECKS=0;

有効:SET FOREIGN_KEY_CHECKS=1;

 

でも、無効にする方法がわかってもDBUnitの実行のたびにSQLを叩くのは現実的ではありません。(テスト自動化もできません。)

というわけで、DBUnitのテストデータ投入前に動くListnerを自作してみました。

DBUnitの実行前にデータベースの外部キーを無効にするListnerを作成

SetUp()で無効にできないか試しましたが、データ投入後に動くようなのでので使えませんでした。
というわけで、カスタムListnerを自作することにしました。

DBUnitの前処理で外部キーを無効にするSQLを実行する

TransactionalTestExecutionListenerを継承してListenerを作成します。

package jp.or.example;

import java.lang.reflect.Method;
import java.sql.Connection;
import java.util.Map;

import javax.sql.DataSource;

import org.springframework.beans.factory.NoSuchBeanDefinitionException;
import org.springframework.jdbc.datasource.DataSourceUtils;
import org.springframework.test.context.TestContext;
import org.springframework.test.context.transaction.TransactionalTestExecutionListener;

/**
 * アノテーションで指定されたテーブルの外部キーを無効にするListner
 */
public class ForeignKeyDisableTestExecutionListener extends TransactionalTestExecutionListener {

    private static final String DEFAULT_DATASOURCE_NAME = "dataSource";

    @Override
    public void beforeTestMethod(TestContext testContext) throws Exception {

        super.beforeTestMethod(testContext);

        DataSource dataSource = getDataSource(testContext);
        Connection conn = DataSourceUtils.getConnection(dataSource);
        try {
            Method method = testContext.getTestMethod();
            ForeignKey foreignKey = method.getAnnotation(ForeignKey.class);
            if (foreignKey == null) {
                return;
            }
            // 外部キーを無効にする
            for (String table : foreignKey.tables()) {
                // 使用するDBの外部キーを無効にするSQLを実行
                String sql = "ALTER TABLE public." + table + " DISABLE TRIGGER ALL;";
                conn.createStatement().execute(sql);
            }
        } finally {
            DataSourceUtils.releaseConnection(conn, dataSource);
        }
    }

    private DataSource getDataSource(TestContext context) {
        DataSource dataSource;
        Map<String, DataSource> beans = context.getApplicationContext().getBeansOfType(DataSource.class);
        if ( beans.size() > 1 ) {
            dataSource = (DataSource) beans.get(DEFAULT_DATASOURCE_NAME);

            if ( dataSource == null ) {
                throw new NoSuchBeanDefinitionException("Unable to locate default data source.");
            }
        } else {
            dataSource = (DataSource) beans.values().iterator().next();
        }

        return dataSource;
    }
}

 

テーブル単位での無効なので、どのテーブルの外部キーを無効にするかはTestクラスのメソッドに付与するアノテーションで指定することにしました。

テストクラスの@TestExecutionListenersに自作した
「TransactionalTestExecutionListener」→「ForeignKeyDisableTestExecutionListener.class」に入れ替えます。

@TestExecutionListeners({        DependencyInjectionTestExecutionListener.class,
DirtiesContextTestExecutionListener.class,
  ForeignKeyDisableTestExecutionListener.class,
DbUnitTestExecutionListener.class })

 

無効にするテーブルを指定するアノテーションを作成

以下のようなアノテーションを作成します。

package jp.or.example;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.TYPE, ElementType.METHOD })
public @interface ForeignKey {
    String[] tables();
}

 

そして、テスト実行するメソッドに外部キーを無効にしたいテーブルを指定します。

@ForeignKey(tables = { “table_name1”, “table_name2”, “table_name3” })

実際のテストクラスの設定はこちら

これが実際のテストクラスです。

@RunWith(SpringJUnit4ClassRunner.class)
@TestExecutionListeners({ DependencyInjectionTestExecutionListener.class,
        DirtiesContextTestExecutionListener.class,
        ForeignKeyDisableTestExecutionListener.class,
        DbUnitTestExecutionListener.class })
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@TestPropertySource(locations = "/test.properties")
@Transactional
@DbUnitConfiguration(dataSetLoader = CsvDataSetLoader.class)
public class DatabaseServiceTest {

    @Test
    @DatabaseSetup(value = "/jp/or/example/service/testService/")
    @ForeignKey(tables = { "table_name1", "table_name2", "table_name3" })
    void testCaseOK() {
        // テスト内容
        assertTrue(true);
    }
}

 

 

この設定により、DBUnitがテストデータを投入する前に、アノテーションで指定されたテーブルの外部キーを無効にして、テストデータを投入してくれます。

テストが終了すると、ロールバックされて外部キーは有効に戻りますので、元に戻す処理は必要ありません。

これで、外部キー制約に煩わされることなく、単体テスト用のデータを投入することができました。
同じような事に悩んでいる方は試してみてください。