11
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Spring AOP+CyclicBarrierを活用してSpring Bootアプリ上での楽観ロックのテスト条件を確実に整える

Last updated at Posted at 2017-06-03

今回は、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を活用してみたいと思います。

pom.xml
<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上でブレークポイント設けてテスト〜とかやめましょうね :wink:

11
13
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?