🔖

kotlin × Spring × kotest × testcontainersで書くインテグレーションテスト(gRPC編)

2022/07/03に公開
  1. kotlin × Spring × kotest × mockkで書くユニットテスト
  2. kotlin × Spring × kotest × testcontainersで書くインテグレーションテスト(Repository編)
  3. kotlin × Spring × kotest × testcontainersで書くインテグレーションテスト(gRPC編) <- 今ここ

前回の続きで今回はリクエストからレスポンスまでの一連の処理をインテグレーションテストとして書いてみます。ただ繋げるだけもつまらないので今回はgRPCを導入してみます。

gRPCを実装する

今回は以下の記事を参考にさせていただいてます。
https://blog.takehata-engineer.com/entry/grpc-kotlin-with-grpc-spring-boot-starter

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を初期化しています。

少し古いですが同じような質問があったので参考までに
https://stackoverflow.com/questions/52776385/kotlintest-with-spring-test-transactional-not-working-applied

まとめ

kotest, testcontainers, mockkがあれば大体テスト書くのに困らない。以上!

GitHubで編集を提案

Discussion