JUnitの並列実行で起こる問題

gradleでtestmaxParallelForksを設定してJUnitを並列で実行できるようにしている場合、あるいは並列実行の設定をしていなかったとしても共用のDB等を使っている環境において複数のCIや複数の開発者が同時にJUnitを実行した場合、どのような問題が発生するか。

例: Redisのキャッシュキーのバッティング

例えば、あるメソッドの結果をRedisにキャッシュさせ、そのメソッドをさまざまなメソッドが呼び出しているとする。あるメソッドには引数がなく、Redisのキャッシュキーは引数によってバリエーションがない単一のものとする。

// import org.springframework.cache.annotation.Cacheable;

@Cacheable(cacheNames = "SomeService", key = "'someCache'")
public SomeResult someCache() {
    var result = new SomeResult();
    // 略
    return result;
}

さまざまなメソッドをJUnitでテストしていく際に、各テストがRedisのキャッシュの作成・利用・削除をする。

つまり、並列でJUnitが実行されると、各テストでキャッシュキーが同じなので、あるテストが作成したものを別のテストが利用してしまったり、まだ利用中のものを削除してしまったりすることにつながる。

具体的にはJUnitでキャッシュが利用されていることを確認するためにあるメソッドが一回しか呼ばれていないことをverifyしようとしても回数が合わなくなってしまう。

// times: 並列で動くテストのせいで0になったり2になったり安定しない
verify(someClass, times(1)).someMethodCached();

Redisのロックで解決する

並列実行で起きる問題を、RedisのNXオプションとEXオプションで時間制限付きのロックをすることによって解決する。

NXオプション + EXオプション

  • NXオプション: SETコマンドでNXオプションを付けると、キーがまだ存在しない場合にのみキーを設定できる
  • EXオプション: SETコマンドでEXオプションを付けると、指定された有効期限を秒単位で設定できる

ふたつのオプションを同時に使用すると、「〜秒間有効なロック」を取得できることになる。

JUnitサンプル

Redisのロックを取得するユーティリティクラスを作成する。

import lombok.SneakyThrows;
import org.springframework.data.redis.connection.RedisStringCommands.SetOption;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.types.Expiration;

import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.UUID;

public class CommonCacheTestUtil {

    @SneakyThrows(InterruptedException.class)
    public static byte[] prepareCacheCommonKey(String key, LettuceConnectionFactory lettuceConnectionFactory) {
        var connection = lettuceConnectionFactory.getClusterConnection();

        byte[] lockValue = UUID.randomUUID().toString().getBytes(StandardCharsets.UTF_8);

        // ttl(20秒)と最大試行回数=失敗許容時間(20*4=80)はある程度適当でOK。
        // ttlはJUnitの1つのテストにかかる時間より長い必要がある。
        long ttl = 20;
        for (int i = 0; i < ttl * 4; i++) {
            Boolean success = connection.set(getJUnitParallelLockKey(key),
                                             lockValue,
                                             Expiration.seconds(ttl),
                                             SetOption.SET_IF_ABSENT);
            assert success != null;
            if (success) {
                return lockValue;
            }
            Thread.sleep(1000);
        }
        throw new RuntimeException("could not get lock");
    }

    public static void deleteCacheCommonKey(String key, byte[] lockValue, LettuceConnectionFactory lettuceConnectionFactory) {
        var connection = lettuceConnectionFactory.getClusterConnection();
        byte[] stored = connection.get(getJUnitParallelLockKey(key));
        if (stored != null && !Arrays.equals(lockValue, stored)) {
            // in case @AfterEach is invoked after lock expired and other test has got lock
            throw new RuntimeException("lock seems to have been set by other");
        }
        connection.del(getJUnitParallelLockKey(key), // lock key
                       key.getBytes(StandardCharsets.UTF_8) // cache key
        );
    }

    private static byte[] getJUnitParallelLockKey(String key) {
        return ("JUnit::" + key).getBytes(StandardCharsets.UTF_8);
    }

}

利用側のテストクラスその1

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.SpyBean;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;

import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;

@SpringBootTest
public class UseCase1Test {

    @Autowired
    UseCase1 useCase1;

    @SpyBean
    SomeCache someCache;

    @Autowired
    LettuceConnectionFactory lettuceConnectionFactory;

    byte[] lockValue;

    @BeforeEach
    public void beforeEach() {
        // lock will be created.
        lockValue = CommonCacheTestUtil.prepareCacheCommonKey("SomeService::someKey", lettuceConnectionFactory);
    }

    @AfterEach
    public void afterEach() {
        // delete lock for cache and cache itself.
        CommonCacheTestUtil.deleteCacheCommonKey("SomeService::someKey", lockValue, lettuceConnectionFactory);
    }

    @Test
    public void test() {
        // cache will be created in exec() method.
        useCase1.exec();

        // cache will be used.
        useCase1.exec();

        // verify cache is used, and called only once.
        verify(someCache, times(1)).someCache();
    }

}

上記とほぼ同じUseCase2, UseCase3, ...があって並列実行されても、CommonCacheTestUtil.prepareCacheCommonKeyがロックを取得できるまで待機してくれる。

Redlockアルゴリズム

マスター(リーダー)からスレーブ(フォロワー)に伝搬する前にフェイルオーバーが発生した場合に、ロック情報が失われるから、NXオプションを使ったロックは安全性・正確性に欠けると言われている。

とはいえ、今回の対象はユニットテストであり安全性・正確性を求めるところではないし、そもそも安全性を重視する場合はリレーショナルデータベースのトランザクション制御とかを使用する場合が多いだろうから、Redisのロックでより安全性・正確性が高いRedlockアルゴリズムはこのような例で用いるべきではないと思う。