gRPC Integration Testing with Testcontainers

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:

  1. Start specified Docker images before tests
  2. Wait for health checks to pass
  3. Provide dynamic connection parameters (host, port) to tests
  4. 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/redis

Go 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=*IntegrationTest

Production 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:

  1. Use PostgresContainer / RedisContainer modules for common dependencies
  2. Combine with bufconn (Go) or in-process servers (Java/Python) for the gRPC layer
  3. Use GenericContainer for dependent gRPC microservices with Docker images
  4. 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.

Read more