Help us understand the problem. What is going on with this article?

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

More than 1 year has passed since last update.

今回は、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:

kazuki43zoo
Javaエンジニアで、SpringやMyBatisらへんにそれなりに詳しいです。お仕事のつながりで「Spring徹底入門 Spring FrameworkによるJavaアプリケーション開発」を共著させてもらいました!
https://kazuki43zoo.github.io
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした