gRPC Integration Testing with Testcontainers
Unit tests are fast but incomplete — they mock dependencies that can behave differently in production. Integration tests with Testcontainers close this gap by running real databases, caches, and dependent services in Docker containers that your test starts and manages.
For gRPC services, this means testing against real PostgreSQL instead of an in-memory fake, real Redis instead of a mocked cache, and real dependent gRPC services instead of stubs.
What Is Testcontainers?
Testcontainers is a library (available for Java, Go, Python, .NET, Node.js) that manages Docker containers in tests:
- Start specified Docker images before tests
- Wait for health checks to pass
- Provide dynamic connection parameters (host, port) to tests
- Stop and remove containers after tests complete
Your tests get real dependencies with zero manual setup. CI gets the same containers as local development.
Why Testcontainers for gRPC?
gRPC services typically have:
- A relational database (PostgreSQL, MySQL)
- A cache layer (Redis, Memcached)
- Other gRPC services they call
- Message brokers (Kafka, RabbitMQ)
Mocking all of these with in-memory fakes is fast but introduces divergence risk. Testcontainers lets you use the real thing in tests while keeping tests self-contained and reproducible.
Java Setup
Dependencies (Maven):
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>1.19.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>1.19.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.19.0</version>
<scope>test</scope>
</dependency>gRPC Service with PostgreSQL
@Testcontainers
@ExtendWith(GrpcMockExtension.class)
class UserServiceIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")
.withDatabaseName("testdb")
.withUsername("testuser")
.withPassword("testpass")
.withInitScript("schema.sql"); // runs SQL on startup
static Server grpcServer;
static ManagedChannel channel;
static UserServiceGrpc.UserServiceBlockingStub stub;
@BeforeAll
static void startGrpcServer() throws IOException {
// Configure service with test container's PostgreSQL
DataSource dataSource = DataSourceBuilder.create()
.url(postgres.getJdbcUrl())
.username(postgres.getUsername())
.password(postgres.getPassword())
.build();
UserRepository repository = new PostgresUserRepository(dataSource);
UserServiceImpl serviceImpl = new UserServiceImpl(repository);
grpcServer = ServerBuilder.forPort(0)
.addService(serviceImpl)
.build()
.start();
channel = ManagedChannelBuilder
.forAddress("localhost", grpcServer.getPort())
.usePlaintext()
.build();
stub = UserServiceGrpc.newBlockingStub(channel);
}
@AfterAll
static void tearDown() {
channel.shutdown();
grpcServer.shutdown();
}
@Test
void createUser_persistsToDatabase() {
CreateUserResponse response = stub.createUser(
CreateUserRequest.newBuilder()
.setName("Alice")
.setEmail("alice@example.com")
.build()
);
assertThat(response.getUserId()).isNotEmpty();
// Verify directly in DB
GetUserResponse getResponse = stub.getUser(
GetUserRequest.newBuilder()
.setUserId(response.getUserId())
.build()
);
assertThat(getResponse.getName()).isEqualTo("Alice");
assertThat(getResponse.getEmail()).isEqualTo("alice@example.com");
}
@Test
void createUser_duplicateEmail_returnsAlreadyExists() {
String email = "duplicate@example.com";
stub.createUser(CreateUserRequest.newBuilder()
.setName("First User")
.setEmail(email)
.build());
StatusRuntimeException exception = assertThrows(
StatusRuntimeException.class,
() -> stub.createUser(CreateUserRequest.newBuilder()
.setName("Second User")
.setEmail(email)
.build())
);
assertThat(exception.getStatus().getCode())
.isEqualTo(Status.Code.ALREADY_EXISTS);
}
}gRPC + Redis Caching Test
@Testcontainers
class CachedUserServiceTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")
.withDatabaseName("testdb")
.withUsername("user").withPassword("pass");
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7")
.withExposedPorts(6379)
.waitingFor(Wait.forLogMessage(".*Ready to accept connections.*\\n", 1));
static Server grpcServer;
static UserServiceGrpc.UserServiceBlockingStub stub;
@BeforeAll
static void setUp() throws IOException {
// Configure Jedis/Lettuce with test container
RedisClient redisClient = RedisClient.create(
RedisURI.create(redis.getHost(), redis.getMappedPort(6379))
);
DataSource dataSource = buildDataSource(postgres);
UserRepository repository = new PostgresUserRepository(dataSource);
UserCache cache = new RedisUserCache(redisClient);
UserServiceImpl service = new UserServiceImpl(repository, cache);
grpcServer = ServerBuilder.forPort(0)
.addService(service)
.build()
.start();
channel = ManagedChannelBuilder
.forAddress("localhost", grpcServer.getPort())
.usePlaintext()
.build();
stub = UserServiceGrpc.newBlockingStub(channel);
}
@Test
void getUser_secondCall_servedFromCache() {
// Create user
CreateUserResponse created = stub.createUser(CreateUserRequest.newBuilder()
.setName("Bob").setEmail("bob@example.com").build());
// First call — fetches from DB, populates cache
long start1 = System.currentTimeMillis();
GetUserResponse resp1 = stub.getUser(
GetUserRequest.newBuilder().setUserId(created.getUserId()).build());
long time1 = System.currentTimeMillis() - start1;
// Second call — served from Redis cache
long start2 = System.currentTimeMillis();
GetUserResponse resp2 = stub.getUser(
GetUserRequest.newBuilder().setUserId(created.getUserId()).build());
long time2 = System.currentTimeMillis() - start2;
// Both responses identical
assertThat(resp1).isEqualTo(resp2);
// Cache hit is faster (with some tolerance for CI variance)
assertThat(time2).isLessThanOrEqualTo(time1);
}
}Go Setup
go get github.com/testcontainers/testcontainers-go
go get github.com/testcontainers/testcontainers-go/modules/postgres
go get github.com/testcontainers/testcontainers-go/modules/redisGo Integration Test
package integration_test
import (
"context"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/test/bufconn"
pb "github.com/your-org/user/pb"
"github.com/your-org/user/service"
"github.com/your-org/user/repository"
)
func TestUserService_WithPostgres(t *testing.T) {
ctx := context.Background()
// Start PostgreSQL container
pgContainer, err := postgres.RunContainer(ctx,
testcontainers.WithImage("docker.io/postgres:15"),
postgres.WithDatabase("testdb"),
postgres.WithUsername("testuser"),
postgres.WithPassword("testpass"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2),
),
)
require.NoError(t, err)
defer pgContainer.Terminate(ctx)
connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
require.NoError(t, err)
// Initialize schema
db, err := sql.Open("pgx", connStr)
require.NoError(t, err)
_, err = db.Exec(`
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
)
`)
require.NoError(t, err)
// Wire up gRPC server with real Postgres
repo := repository.NewPostgresRepository(db)
svc := service.NewUserService(repo)
// Use bufconn for gRPC transport
const bufSize = 1 << 20
listener := bufconn.Listen(bufSize)
server := grpc.NewServer()
pb.RegisterUserServiceServer(server, svc)
go server.Serve(listener)
defer server.Stop()
conn, err := grpc.DialContext(ctx, "bufnet",
grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
return listener.Dial()
}),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
require.NoError(t, err)
defer conn.Close()
client := pb.NewUserServiceClient(conn)
t.Run("create and retrieve user", func(t *testing.T) {
createResp, err := client.CreateUser(ctx, &pb.CreateUserRequest{
Name: "Alice",
Email: "alice@example.com",
})
require.NoError(t, err)
assert.NotEmpty(t, createResp.UserId)
getResp, err := client.GetUser(ctx, &pb.GetUserRequest{
UserId: createResp.UserId,
})
require.NoError(t, err)
assert.Equal(t, "Alice", getResp.Name)
assert.Equal(t, "alice@example.com", getResp.Email)
})
t.Run("duplicate email returns ALREADY_EXISTS", func(t *testing.T) {
_, err := client.CreateUser(ctx, &pb.CreateUserRequest{
Name: "User 1",
Email: "dupe@example.com",
})
require.NoError(t, err)
_, err = client.CreateUser(ctx, &pb.CreateUserRequest{
Name: "User 2",
Email: "dupe@example.com",
})
require.Error(t, err)
st, _ := status.FromError(err)
assert.Equal(t, codes.AlreadyExists, st.Code())
})
}Python Setup
pip install testcontainers[postgres,redis]Python Integration Test
import unittest
import grpc
from concurrent import futures
from testcontainers.postgres import PostgresContainer
from testcontainers.redis import RedisContainer
import user_pb2
import user_pb2_grpc
from user_service import UserServicer
from user_repository import PostgresUserRepository
class TestUserServiceWithContainers(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.postgres = PostgresContainer("postgres:15")
cls.postgres.start()
# Initialize schema
import psycopg2
conn = psycopg2.connect(
host=cls.postgres.get_container_host_ip(),
port=cls.postgres.get_exposed_port(5432),
database=cls.postgres.POSTGRES_DB,
user=cls.postgres.POSTGRES_USER,
password=cls.postgres.POSTGRES_PASSWORD,
)
with conn.cursor() as cur:
cur.execute("""
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL
)
""")
conn.commit()
conn.close()
# Start gRPC server with real Postgres
repository = PostgresUserRepository(
host=cls.postgres.get_container_host_ip(),
port=cls.postgres.get_exposed_port(5432),
database=cls.postgres.POSTGRES_DB,
user=cls.postgres.POSTGRES_USER,
password=cls.postgres.POSTGRES_PASSWORD,
)
cls.server = grpc.server(futures.ThreadPoolExecutor(max_workers=4))
user_pb2_grpc.add_UserServiceServicer_to_server(
UserServicer(repository), cls.server
)
cls.port = cls.server.add_insecure_port('[::]:0')
cls.server.start()
channel = grpc.insecure_channel(f'localhost:{cls.port}')
cls.stub = user_pb2_grpc.UserServiceStub(channel)
@classmethod
def tearDownClass(cls):
cls.server.stop(grace=None)
cls.postgres.stop()
def test_create_and_retrieve_user(self):
create_resp = self.stub.CreateUser(
user_pb2.CreateUserRequest(name="Alice", email="alice@test.com")
)
self.assertNotEqual(create_resp.user_id, "")
get_resp = self.stub.GetUser(
user_pb2.GetUserRequest(user_id=create_resp.user_id)
)
self.assertEqual(get_resp.name, "Alice")
self.assertEqual(get_resp.email, "alice@test.com")
def test_duplicate_email_raises_already_exists(self):
self.stub.CreateUser(
user_pb2.CreateUserRequest(name="User 1", email="dupe@test.com")
)
with self.assertRaises(grpc.RpcError) as ctx:
self.stub.CreateUser(
user_pb2.CreateUserRequest(name="User 2", email="dupe@test.com")
)
self.assertEqual(ctx.exception.code(), grpc.StatusCode.ALREADY_EXISTS)Testing Downstream gRPC Services
Use Testcontainers to spin up real dependent services:
@Testcontainers
class OrderServiceWithRealDependenciesTest {
// Start the inventory service container
@Container
static GenericContainer<?> inventoryService = new GenericContainer<>(
"your-org/inventory-service:latest"
)
.withExposedPorts(50051)
.withEnv("DB_URL", "jdbc:postgresql://...")
.waitingFor(Wait.forLogMessage(".*gRPC server started.*", 1));
@Test
void placeOrder_reservesInventory() {
String inventoryHost = inventoryService.getHost();
int inventoryPort = inventoryService.getMappedPort(50051);
// Configure order service to use the container's inventory service
OrderService orderService = new OrderService(
String.format("%s:%d", inventoryHost, inventoryPort)
);
OrderResult result = orderService.placeOrder("product-123", 2);
assertThat(result.isSuccess()).isTrue();
assertThat(result.getReservationId()).isNotEmpty();
}
}CI/CD Integration
# GitHub Actions — Docker is available by default
- name: Run integration tests
run: mvn test -Dtest=*IntegrationTest
# Docker is available on ubuntu-latest runners
# GitLab CI — requires Docker-in-Docker
e2e-tests:
image: maven:3.9-eclipse-temurin-17
services:
- docker:dind
variables:
DOCKER_HOST: tcp://docker:2376
DOCKER_TLS_VERIFY: "1"
script:
- mvn test -Dtest=*IntegrationTestProduction Health Monitoring
Testcontainers integration tests run in pre-production environments. For production monitoring, HelpMeTest runs continuous health checks:
curl -fsSL https://helpmetest.com/install | bash
helpmetest health <span class="hljs-string">"user-grpc-service" <span class="hljs-string">"5m"
helpmetest health <span class="hljs-string">"order-grpc-service" <span class="hljs-string">"5m"Add these heartbeats to your deployment pipeline. If a service crashes post-deployment, the missed heartbeat triggers an alert within 5 minutes.
Summary
Testcontainers brings real infrastructure to your integration test suite without manual Docker management. For gRPC services:
- Use
PostgresContainer/RedisContainermodules for common dependencies - Combine with bufconn (Go) or in-process servers (Java/Python) for the gRPC layer
- Use
GenericContainerfor dependent gRPC microservices with Docker images - Run in CI — GitHub Actions and GitLab CI both support Docker
The result: integration tests that actually test integration, not integration tests that test your mocks.