Skip to content

gRPC

    dependencies {
        testImplementation("com.trendyol:stove-testing-e2e-grpc:$version")
    }

Configure

After getting the library from the maven source, while configuring TestSystem you will have access to grpc:

TestSystem()
  .with {
    grpc {
      GrpcSystemOptions(
        host = "localhost",
        port = 50051
      )
    }
  }
  .run()

Configuration Options

data class GrpcSystemOptions(
  /**
   * The gRPC server host.
   */
  val host: String,

  /**
   * The gRPC server port.
   */
  val port: Int,

  /**
   * Whether to use plaintext (no TLS). Default is true for testing.
   */
  val usePlaintext: Boolean = true,

  /**
   * Request timeout duration (default: 30 seconds).
   */
  val timeout: Duration = 30.seconds,

  /**
   * List of client interceptors for logging, auth, tracing, etc.
   */
  val interceptors: List<ClientInterceptor> = emptyList(),

  /**
   * Default metadata (headers) to send with every request.
   */
  val metadata: Map<String, String> = emptyMap(),

  /**
   * Factory function for creating the underlying ManagedChannel.
   */
  val createChannel: (host: String, port: Int) -> ManagedChannel = { h, p ->
    defaultChannelBuilder(h, p, usePlaintext, timeout, interceptors, metadata)
  },

  /**
   * Factory function for creating Wire's GrpcClient with resources.
   */
  val createWireClient: (host: String, port: Int) -> WireClientResources = { h, p ->
    defaultWireGrpcClient(h, p, timeout, metadata)
  }
)

With Authentication

grpc {
  GrpcSystemOptions(
    host = "localhost",
    port = 50051,
    metadata = mapOf("authorization" to "Bearer $token"),
    interceptors = listOf(LoggingInterceptor())
  )
}

Usage

Stove's gRPC module supports multiple gRPC providers through a provider-agnostic design:

  • Wire clients (wireClient<T>) - For Wire-generated clients
  • Typed channel (channel<T>) - For any stub with a Channel constructor
  • Custom providers (withEndpoint) - For any gRPC library
  • Raw channel (rawChannel) - For advanced scenarios

Wire Clients

For services generated with Wire:

TestSystem.validate {
  grpc {
    wireClient<GreeterServiceClient> {
      val response = SayHello().execute(HelloRequest(name = "World"))
      response.message shouldBe "Hello, World!"
    }
  }
}

Typed Channel (grpc-kotlin and Wire stubs)

For any stub that takes a Channel constructor. This works with both grpc-kotlin generated stubs and Wire-generated stubs:

TestSystem.validate {
  grpc {
    channel<GreeterServiceStub> {
      // 'this' is the stub - direct method calls
      val response = sayHello(HelloRequest(name = "World"))
      response.message shouldBe "Hello, World!"
    }
  }
}

With Per-Call Metadata

TestSystem.validate {
  grpc {
    channel<GreeterServiceStub>(
      metadata = mapOf("authorization" to "Bearer custom-token")
    ) {
      val response = sayHello(HelloRequest(name = "Authenticated"))
      response.message shouldBe "Hello, Authenticated!"
    }
  }
}

Custom Providers

For any other gRPC library, use withEndpoint with a factory function:

TestSystem.validate {
  grpc {
    withEndpoint({ host, port -> 
      // Create your client however you want
      MyCustomGrpcClient.connect(host, port)
    }) {
      // 'this' is your client
      this.call() shouldBe expected
    }
  }
}

Raw Channel Access

For advanced scenarios where you need full control:

TestSystem.validate {
  grpc {
    rawChannel { channel ->
      // Full control over channel
      val stub = GreeterGrpc.newBlockingStub(channel)
      val response = stub.sayHello(request)
      response.message shouldBe "Hello!"
    }
  }
}

Streaming

All streaming types work naturally with Kotlin coroutines.

Server Streaming

TestSystem.validate {
  grpc {
    channel<StreamServiceStub> {
      val responses = serverStream(request).toList()

      responses.size shouldBe 5
      responses[0].message shouldBe "Item 0"
      responses[4].message shouldBe "Item 4"
    }
  }
}

Client Streaming

TestSystem.validate {
  grpc {
    channel<StreamServiceStub> {
      val requestFlow = flow {
        emit(Request(message = "First"))
        emit(Request(message = "Second"))
        emit(Request(message = "Third"))
      }

      val response = clientStream(requestFlow)
      response.message shouldBe "Received: First, Second, Third"
      response.count shouldBe 3
    }
  }
}

Bidirectional Streaming

TestSystem.validate {
  grpc {
    channel<StreamServiceStub> {
      val requestFlow = flow {
        emit(Request(message = "A"))
        emit(Request(message = "B"))
      }

      val responses = bidiStream(requestFlow).toList()
      responses.size shouldBe 2
      responses[0].message shouldBe "Echo: A"
      responses[1].message shouldBe "Echo: B"
    }
  }
}

Wire Client Details

Direct GrpcClient Access

TestSystem.validate {
  grpc {
    rawWireClient { client ->
      val service = client.create(GreeterServiceClient::class)
      val response = service.SayHello().execute(HelloRequest(name = "Direct"))
      response.message shouldBe "Hello, Direct!"
    }
  }
}

Wire Client with Custom OkHttp Configuration

TestSystem.validate {
  grpc {
    withEndpoint({ host, port ->
      val okHttpClient = OkHttpClient.Builder()
        .protocols(listOf(Protocol.H2_PRIOR_KNOWLEDGE))
        .addInterceptor { chain ->
          val request = chain.request().newBuilder()
            .addHeader("authorization", "Bearer my-token")
            .build()
          chain.proceed(request)
        }
        .build()

      GrpcClient.Builder()
        .client(okHttpClient)
        .baseUrl("http://$host:$port")
        .build()
        .create(GreeterServiceClient::class)
    }) {
      val response = SayHello().execute(HelloRequest(name = "Custom"))
      response.message shouldBe "Hello, Custom!"
    }
  }
}

Authentication & Interceptors

Global Interceptors

class LoggingInterceptor : ClientInterceptor {
  override fun <ReqT, RespT> interceptCall(
    method: MethodDescriptor<ReqT, RespT>,
    callOptions: CallOptions,
    next: Channel
  ): ClientCall<ReqT, RespT> {
    println("Calling: ${method.fullMethodName}")
    return next.newCall(method, callOptions)
  }
}

TestSystem()
  .with {
    grpc {
      GrpcSystemOptions(
        host = "localhost",
        port = 50051,
        interceptors = listOf(LoggingInterceptor())
      )
    }
  }

Per-Call Metadata

TestSystem.validate {
  grpc {
    // Metadata is applied via interceptor automatically
    channel<SecureServiceStub>(
      metadata = mapOf(
        "authorization" to "Bearer jwt-token",
        "x-request-id" to "12345"
      )
    ) {
      val response = secureEndpoint(request)
      response.success shouldBe true
    }
  }
}

Error Handling

Testing Authentication Errors

TestSystem.validate {
  grpc {
    // Wire client - throws GrpcException
    wireClient<SecureServiceClient> {
      val exception = shouldThrow<GrpcException> {
        SecureCall().execute(Request(message = "Hello"))
      }
      exception.grpcStatus shouldBe GrpcStatus.UNAUTHENTICATED
    }

    // grpc-kotlin - throws StatusException
    channel<SecureServiceStub> {
      val exception = shouldThrow<StatusException> {
        secureCall(request)
      }
      exception.status.code shouldBe Status.Code.UNAUTHENTICATED
    }
  }
}

Testing Not Found

TestSystem.validate {
  grpc {
    channel<UserServiceStub> {
      val exception = shouldThrow<StatusException> {
        getUser(GetUserRequest(id = 999999))
      }
      exception.status.code shouldBe Status.Code.NOT_FOUND
    }
  }
}

Complete Example

Here's a complete test example with various gRPC operations:

test("should perform gRPC operations") {
  TestSystem.validate {
    // Test unary call
    grpc {
      channel<UserServiceStub> {
        val response = createUser(CreateUserRequest(name = "John", email = "john@example.com"))
        response.id shouldNotBe null
        response.name shouldBe "John"
      }
    }

    // Test with authentication
    grpc {
      channel<UserServiceStub>(
        metadata = mapOf("authorization" to "Bearer admin-token")
      ) {
        val users = listUsers(ListUsersRequest(limit = 10)).toList()
        users.size shouldBeGreaterThan 0
      }
    }

    // Test error handling
    grpc {
      channel<UserServiceStub> {
        shouldThrow<StatusException> {
          getUser(GetUserRequest(id = -1))
        }.status.code shouldBe Status.Code.INVALID_ARGUMENT
      }
    }
  }
}

Integration with Other Components

gRPC + Database

TestSystem.validate {
  // Create via gRPC
  var userId: Long = 0
  grpc {
    channel<UserServiceStub> {
      val response = createUser(CreateUserRequest(name = "John"))
      userId = response.id
    }
  }

  // Verify in database
  postgresql {
    shouldQuery(
      query = "SELECT * FROM users WHERE id = $userId",
      mapper = { row -> User(row.long("id"), row.string("name")) }
    ) { users ->
      users.size shouldBe 1
      users.first().name shouldBe "John"
    }
  }
}

gRPC + Kafka

TestSystem.validate {
  // Trigger event via gRPC
  grpc {
    channel<OrderServiceStub> {
      createOrder(CreateOrderRequest(amount = 100.0))
    }
  }

  // Verify event was published
  kafka {
    shouldBePublished<OrderCreatedEvent>(atLeastIn = 10.seconds) {
      actual.amount == 100.0
    }
  }
}

Provider Support

Provider DSL Method Notes
Wire wireClient<T> For Wire-generated service clients
grpc-kotlin channel<T> Works with any stub with Channel constructor
Wire stubs channel<T> Works with Wire server stubs
Custom withEndpoint Any library with factory function
Advanced rawChannel Direct ManagedChannel access
Advanced rawWireClient Direct Wire GrpcClient access