今回は、Spring Boot上で楽観ロックのテスト条件を確実に整える方法を紹介したいと思います。
動作確認バージョン
- Spring Boot 1.5.3.RELEASE
テスト対象のサンプルアプリ
以下のように楽観ロックを行うコードに対するテストをみてみましょう。
package com.example.demo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.stereotype.Repository;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.concurrent.atomic.AtomicInteger;
@SpringBootApplication
public class OptimisticLockDemoApplication {
public static void main(String[] args) {
SpringApplication.run(OptimisticLockDemoApplication.class, args);
}
@RestController
static class MyController {
private static final Logger logger = LoggerFactory.getLogger(MyController.class);
private final MyRepository repository;
public MyController(MyRepository repository) {
this.repository = repository;
}
@PostMapping("/version/increment")
boolean incrementVersion() {
int currentVersion = repository.getVersion(); // (1) 楽観ロック用にバージョン番号を取得
logger.info("current version : {}", currentVersion);
boolean result = repository.updateVersion(currentVersion); // (2) 楽観ロックを使用して更新
logger.info("updating result : {}", result);
return result;
}
}
@Repository
static class MyRepository {
private final AtomicInteger version = new AtomicInteger(1);
public int getVersion() {
return version.get();
}
public boolean updateVersion(int currentVersion) {
return version.compareAndSet(currentVersion, currentVersion + 1);
}
}
}
楽観ロックが正しく行われているか?(=(2)の結果がfalse
になるケース)をテストする場合、複数のスレッドで同じバージョン番号が取得されるようにオペレーションを行う必要があります。
上記のサンプルコードだと(1)と(2)の間に何も処理がないので・・・人がオペレーションすると「(2)の結果をfalse
にする」のは結構むずかしいです。
JUnitを使用してテスト
ということで、JUnitを使ってほぼ同時にリクエストを送るテストコードを書いてみましょう。
package com.example.demo;
import org.assertj.core.api.Assertions;
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.boot.test.web.client.TestRestTemplate;
import org.springframework.test.context.junit4.SpringRunner;
import java.util.concurrent.atomic.AtomicInteger;
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class OptimisticLockDemoApplicationTests {
@Autowired TestRestTemplate restTemplate;
@Test
public void contextLoads() throws InterruptedException {
AtomicInteger successCounter = new AtomicInteger(0);
Thread client1 = new Thread(runnable(successCounter));
Thread client2 = new Thread(runnable(successCounter));
client1.start();
client2.start();
client1.join(10000);
client2.join(10000);
Assertions.assertThat(successCounter.get()).isEqualTo(1);
}
private Runnable runnable(AtomicInteger successCounter) {
return () -> {
boolean result = restTemplate.postForObject("/version/increment", null, boolean.class);
if (result) {
successCounter.incrementAndGet();
}
};
}
}
このテストコードを実行すると・・・かなりの確率でテストは成功しました(私のマシンで試した範囲では実は100%成功)。じゃ〜これで問題ないのか?というと話は別で、このテストコードにはテストが失敗する可能性が残っています。
というのも・・・2つのスレッドを使用してほぼ同時にリクエストをサーバに投げるように実装していますが・・・実際にサーバにリクエストが届くタイミングも同時になる保証はありません。
ためしに・・・擬似的にリクエストが届くタイミングをずらしてみると・・・
@Test
public void contextLoads() throws InterruptedException {
AtomicInteger successCounter = new AtomicInteger(0);
Thread client1 = new Thread(runnable(successCounter));
Thread client2 = new Thread(runnable(successCounter));
client1.start();
Thread.sleep(200); // 意図的にリクエストが届くタイミングをずらす
client2.start();
client1.join(10000);
client2.join(10000);
Assertions.assertThat(successCounter.get()).isEqualTo(1);
}
テストは失敗します。つまり・・・上記のテストコードはタイミングによっては失敗する可能性があるのです。
では、確実にテスト条件を充すようにするためには、どうすればよいのか?というと・・・
2つのリクエストが同じバージョン番号を取得するまで更新処理が行われないようにすればよいのです。
Spring AOP+CyclicBarrier
を活用してテスト
テスト対象のソースコードに手を加えずにこのテスト条件を充すために、今回はSpring AOPとCyclicBarrier
を活用してみたいと思います。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId> <!-- AOPのstarterを追加 -->
</dependency>
package com.example.demo;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.assertj.core.api.Assertions;
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.boot.test.context.TestComponent;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
import org.springframework.test.context.junit4.SpringRunner;
import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicInteger;
@EnableAspectJAutoProxy // AspectJのアノテーションを利用したAOP機能を有効化する
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class OptimisticLockDemoApplicationTests {
@Autowired TestRestTemplate restTemplate;
@Test
public void contextLoads() throws InterruptedException {
AtomicInteger successCounter = new AtomicInteger(0);
Thread client1 = new Thread(runnable(successCounter));
Thread client2 = new Thread(runnable(successCounter));
client1.start();
Thread.sleep(1000); // 確実にタイミングをずらすためにsleep時間を0.2秒から1秒に変更
client2.start();
client1.join(10000);
client2.join(10000);
Assertions.assertThat(successCounter.get()).isEqualTo(1);
}
private Runnable runnable(AtomicInteger successCounter) {
return () -> {
boolean result = restTemplate.postForObject("/version/increment", null, boolean.class);
if (result) {
successCounter.incrementAndGet();
}
};
}
// テスト条件が整うまで更新メソッドの実行を待機するためのAspect
// テスト条件: 2つのスレッドが同じバージョン番号を参照した後に更新メソッドを呼び出す
@TestComponent
@Aspect
static class UpdateAwaitAspect {
private final CyclicBarrier barrier = new CyclicBarrier(2);
@Before("execution(* com.example.demo.OptimisticLockDemoApplication.MyRepository.updateVersion(..))")
public void awaitUpdating() throws BrokenBarrierException, InterruptedException, TimeoutException {
barrier.await(10, TimeUnit.SECONDS); // 2回目のリクエストが来ない時に無限待機にならないようにタイムアウト(例では10秒)を設けておく
}
}
}
まとめ
Spring AOP + CyclicBarrier
を活用すると、実施タイミングに左右されるテスト(例:楽観ロックのテストなど)のテスト条件を確実に整えることができます。本エントリーでは組み込みTomcatに対してリクエストを投げてテストしてますが、「Serviceクラス⇄Repository」をつないだテスト(コンポーネント結合テスト?)に対して同じ仕掛けを適用すれば、Spring Bootの仕組みに依存せずに同様のテストを行うこともできます。
複数の人で同時にリクエストを送ってテスト〜とか、IDE上でブレークポイント設けてテスト〜とかやめましょうね