はじめに
Webアプリケーションを作成するにあたって、データの保存処理を行うボタンが存在する。
そんなアプリたくさんあると思います。
- 回線速度によって、保存ボタンが何回も押せちゃってデータがバグった
- 複数ユーザーが同時に保存処理をかけてデータがバグった
なんてこと結構あるのではないでしょうか?
TDDでアプリを作るにあたって、あるユーザー1人が適切な入力の後、保存ボタンを押したら正しくデータベースにデータが保存されている。まではテストしてたりするのですが、複数回ボタンが押された時〜とか、同時に複数のユーザーが同じメソッドを呼んだ時〜とかのテストって忘れられていて、後から…「あ…。」なんてことが稀によくあるのではないでしょうか?
「よし、テスト書こう!!」
と思ったものの、同時にメソッド呼ばれた時、ってテストどう書くの?問題があるわけで…。
今回、そのテスト作成に困ったので、備忘録として記録を残したいと思います。
前提条件
使用言語: kotlin
結論
テストでは、2つのスレッドで同時にメソッドを呼び出すことで、平行して処理が実行される状況をシミュレート。
具体的には、
val future1 = CompletableFuture.runAsync {
dataService.updateData(userId, "dataA")
}
val future1 = CompletableFuture.runAsync {
dataService.updateData(userId, "dataB")
}
CompletableFuture.allOf(future1, future2).join()
の様な形で、runAsync
を用いて非同期タスクを実行する。
もしくは、
val barrier = CyclicBarrier(2)
val task = Callable {
barrier.await()
dataService.updateData(userId, "dataA")
}
val executor = Executors.newFixedThreadPool(2)
val future1 = executor.submit(task)
val future2 = executor.submit(task)
future1.get()
future2.get()
このようにCyclicBarrierを用いて、
複数のスレッドをbarrier.await()
の時点で待機させて同時に実行することで、
複数のスレッドがリソースにアクセスするタイミングを揃える方法がある。
コードの記述量からすると、runAsync
の方が理解しやすい+覚えやすいですが、
CyclicBarrier
の方も使いこなせるようになりたい…ですね。
結果
上記アクションとともに適切なアサーションを含めると、
Serviceのメソッドに@Transactional
, @Lock(LockModeType.PESSIMISTIC_WRITE)
のアノテーションをつける必要があり複数ユーザーからの同時アクセスにおいて正しく処理が実行できているかをテストすることができる。
code sample
テストのアレンジの一部分だけの記述では全体像がわかりにくいと思うので、
チャッピー(ChatGPT)さんにサンプルコードを生成してもらいました。
(※runAsync
の場合のみを以下に示します)
理解の一助になれば幸いです。
2つのリクエストが同時に処理されるケースを想定したテスト
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import java.util.concurrent.CompletableFuture
import kotlin.test.assertEquals
@SpringBootTest
class MyServiceTest {
@Autowired
lateinit var myService: MyService
@Autowired
lateinit var myRepository: MyRepository
@Test
fun `test concurrent update with pessimistic lock`() {
val id = 1L
val initialData = "initial"
val entity = MyEntity(id = id, data = initialData)
myRepository.save(entity)
// 非同期で2つのリクエストを実行
val future1 = CompletableFuture.runAsync {
myService.updateData(id, "data_1")
}
val future2 = CompletableFuture.runAsync {
myService.updateData(id, "data_2")
}
// 両方のタスクが完了するのを待つ
CompletableFuture.allOf(future1, future2).join()
// 2つ目のリクエストがロック解除を待ってから処理され、最後の状態を確認
val updatedEntity = myRepository.findById(id).orElseThrow()
assertEquals("data_2", updatedEntity.data) // 最後に実行された更新が反映されているはず
}
}
悲観的ロックを使ったサービスのメソッド
import org.springframework.data.jpa.repository.Lock
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import javax.persistence.LockModeType
@Service
class MyService(private val myRepository: MyRepository) {
@Transactional
@Lock(LockModeType.PESSIMISTIC_WRITE)
fun updateData(id: Long, newData: String) {
val entity = myRepository.findById(id).orElseThrow { IllegalArgumentException("Entity not found") }
entity.data = newData
myRepository.save(entity)
}
}
最後に
悲観的ロックが出てきましたが、今までずっとロックされたタイミングでリクエストが別のところから飛んできた場合、エラーになると勘違いしていました。
2回目のリクエストは待機状態になり、ロックが解除された後、リクエストが実行される様です。
この勘違いで、アサーションを間違えまくってテスト通らん…と悩んでしまったので基礎的な知識をもっと幅広く身につける必要がありますね…。(自戒)