JUnitの並列実行で起こる問題
gradleでtest
のmaxParallelForks
を設定して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アルゴリズムはこのような例で用いるべきではないと思う。