🔖
kotlin × Spring × kotest × testcontainersで書くインテグレーションテスト(gRPC編)
- kotlin × Spring × kotest × mockkで書くユニットテスト
- kotlin × Spring × kotest × testcontainersで書くインテグレーションテスト(Repository編)
- kotlin × Spring × kotest × testcontainersで書くインテグレーションテスト(gRPC編) <- 今ここ
前回の続きで今回はリクエストからレスポンスまでの一連の処理をインテグレーションテストとして書いてみます。ただ繋げるだけもつまらないので今回はgRPCを導入してみます。
gRPCを実装する
今回は以下の記事を参考にさせていただいてます。
build.gradle.ktsを修正
gRPCの依存関係などを追加していきます。
build.gradle.kts
import com.google.protobuf.gradle.*
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
id("org.springframework.boot") version "2.7.0"
id("io.spring.dependency-management") version "1.0.11.RELEASE"
kotlin("jvm") version "1.6.21"
kotlin("plugin.spring") version "1.6.21"
+ id("io.github.lognet.grpc-spring-boot") version "4.7.0"
+ id("com.google.protobuf") version "0.8.18"
}
group = "com.example"
version = "0.0.1-SNAPSHOT"
java.sourceCompatibility = JavaVersion.VERSION_17
repositories {
mavenCentral()
}
extra["testcontainersVersion"] = "1.17.2"
+ val grpcKotlinVersion = "1.3.0"
+ val grpcVersion = "1.47.0"
+ val protobufVersion = "3.21.1"
+
+ protobuf {
+ protoc {
+ artifact = "com.google.protobuf:protoc:$protobufVersion"
+ }
+ plugins {
+ id("grpc") {
+ artifact = "io.grpc:protoc-gen-grpc-java:$grpcVersion"
+ }
+ id("grpckt") {
+ artifact = "io.grpc:protoc-gen-grpc-kotlin:$grpcKotlinVersion:jdk8@jar"
+ }
+ }
+ generateProtoTasks {
+ all().forEach { task ->
+ task.plugins {
+ id("grpc") { }
+ id("grpckt") { }
+ }
+ }
+ }
+ }
dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-mongodb")
implementation("org.springframework.boot:spring-boot-starter-data-redis")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
+ implementation("io.github.lognet:grpc-spring-boot-starter:4.7.0")
+ implementation("io.grpc:grpc-kotlin-stub:$grpcKotlinVersion")
+ implementation("io.grpc:grpc-protobuf:$grpcVersion")
+ implementation("com.google.protobuf:protobuf-kotlin:$protobufVersion")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.testcontainers:junit-jupiter")
testImplementation("org.testcontainers:mongodb")
testImplementation(platform("io.kotest:kotest-bom:5.3.1"))
testImplementation("io.kotest:kotest-runner-junit5")
testImplementation("io.kotest.extensions:kotest-extensions-spring:1.1.1")
testImplementation("io.mockk:mockk:1.12.4")
}
dependencyManagement {
imports {
mavenBom("org.testcontainers:testcontainers-bom:${property("testcontainersVersion")}")
}
}
tasks.withType<KotlinCompile> {
kotlinOptions {
freeCompilerArgs = listOf("-Xjsr305=strict")
jvmTarget = "17"
}
}
tasks.withType<Test> {
useJUnitPlatform()
}
protoファイルを作成する
以下のようなprotoファイルを作成します。
user.proto
syntax = "proto3";
option java_package = "com.example.testdemo.proto.user";
option java_multiple_files = true;
package user;
import "google/protobuf/empty.proto";
service User {
rpc Register(CreateUserRequest) returns (UserResponse) {}
rpc GetUser(GetUserRequest) returns (UserResponse) {}
rpc GetUsers(google.protobuf.Empty) returns (UserListResponse) {}
}
message CreateUserRequest {
string name = 1;
int32 age = 2;
}
message UserResponse {
string id = 1;
string name = 2;
int32 age = 3;
}
message GetUserRequest {
string id = 1;
}
message UserListResponse {
repeated UserResponse user_list = 1;
}
コード生成
以下のコマンドで作成したprotoファイルからコードが自動生成されます。
./gradlew generateProto
GrpcServiceの実装
先に前回までに作成していたUserServiceを修正します。
UserService
@Service
class UserService(
private val userRepository: UserRepository
) {
fun create(user: User): User {
val document = this.userRepository.save(UserDocument(user))
return User(document)
}
+ fun getUser(id: String) = userRepository.findByIdOrNull(ObjectId(id))?.let { User(it) }
+
+ fun getUsers() = userRepository.findAll().map { User(it) }
}
次にGrpcServiceクラスを作成します。
UserGrpcService
@GRpcService
class UserGrpcService(
private val userService: UserService
) : UserGrpcKt.UserCoroutineImplBase() {
override suspend fun register(request: CreateUserRequest): UserResponse {
return this.userService.create(User(name = request.name, age = request.age)).toResponse()
}
override suspend fun getUser(request: GetUserRequest): UserResponse {
return this.userService.getUser(request.id)?.toResponse() ?: UserResponse.getDefaultInstance()
}
override suspend fun getUsers(request: Empty): UserListResponse {
val users = this.userService.getUsers().map { it.toResponse() }
return UserListResponse.newBuilder().addAllUserList(users).build()
}
private fun User.toResponse(): UserResponse {
return UserResponse.newBuilder()
.setId(this.id.toString())
.setName(this.name)
.setAge(this.age)
.build()
}
}
これで準備は完了です。
テストを書く
TestHelper.kt
fun getChannel(vararg interceptors: ClientInterceptor): ManagedChannel {
return ManagedChannelBuilder.forAddress("localhost", 6565)
.intercept(*interceptors)
.usePlaintext()
.build()
}
UserGrpcTest
@SpringBootTest
//@Transactional
internal class UserGrpcTest(
private val userRepository: UserRepository
) : StringSpec({
val stub: UserGrpcKt.UserCoroutineStub by lazy { UserGrpcKt.UserCoroutineStub(getChannel()) }
fun buildCreateUserRequest(name: String = "user", age: Int = 32) = CreateUserRequest.newBuilder()
.setName(name)
.setAge(age)
.build()
fun buildGetUserRequest(id: String) = GetUserRequest.newBuilder()
.setId(id)
.build()
fun buildUserResponse(id: String, name: String = "user", age: Int = 32) = UserResponse.newBuilder()
.setId(id)
.setName(name)
.setAge(age)
.build()
afterTest { userRepository.deleteAll() }
"User.Register" {
val response = withContext(Dispatchers.Default) {
stub.register(buildCreateUserRequest())
}
response shouldBe buildUserResponse(id = response.id)
}
"User.GetUser" {
//given
val savedDocument = userRepository.save(UserDocument(name = "user", age = 32))
val savedId = savedDocument.id.toString()
//when
val response = async { stub.getUser(buildGetUserRequest(savedId)) }
//then
response.await() shouldBe buildUserResponse(id = savedId)
}
"User.GetUsers" {
//given
userRepository.save(UserDocument(name = "user1"))
userRepository.save(UserDocument(name = "user2"))
//when
val response = stub.getUsers(Empty.getDefaultInstance())
//then
response.userListCount shouldBe 2
}
})
特筆することはあまりないのですが1点だけ@Transactionalをクラスに付与するとテストがうまくいきませんでした。挙動的にstubのメソッドを実行しているときには前段で用意していたテストデータがロールバックされてしまっているような動きでした。kotestはJunitよりも複雑な作りになっており、こちらが予期した通りには動いてくれない様です。(kotestでいい感じに@Transactionalを使える方法ご存知の方いましたらコメントください!)以前の記事で書いたような簡単なユニットテストなら問題は見られませんでしたが今回は@Transactionalは使用せず、テスト実行後に手動でDBを初期化しています。
少し古いですが同じような質問があったので参考までに
まとめ
kotest, testcontainers, mockkがあれば大体テスト書くのに困らない。以上!
Discussion