14 - Testing gRPC Services
📋 Jump to TakeawaysYou can test gRPC services without starting a real server or opening a network port. The bufconn package creates an in-memory connection that behaves exactly like a real one. Fast, reliable, no port conflicts.
Prerequisites: You should have a working server with CreateLink, GetLink, DeleteLink, and ListLinks from previous lessons.
Setup
Create main_test.go in your project root (same directory as main.go). It must use package main so it can access newLinkServer():
package main
import (
"context"
"io"
"net"
"testing"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
"google.golang.org/grpc/test/bufconn"
"shortener/pb"
)
const bufSize = 1024 * 1024
func setupTest(t *testing.T) pb.LinkServiceClient {
t.Helper()
lis := bufconn.Listen(bufSize)
srv := grpc.NewServer()
pb.RegisterLinkServiceServer(srv, newLinkServer())
go func() {
if err := srv.Serve(lis); err != nil {
t.Logf("server exited: %v", err)
}
}()
t.Cleanup(func() {
srv.Stop()
lis.Close()
})
conn, err := grpc.NewClient("passthrough:///bufconn",
grpc.WithContextDialer(func(ctx context.Context, s string) (net.Conn, error) {
return lis.DialContext(ctx)
}),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
t.Fatalf("failed to dial: %v", err)
}
t.Cleanup(func() { conn.Close() })
return pb.NewLinkServiceClient(conn)
}bufconn creates a net.Listener backed by a buffer instead of a TCP socket. You start a real gRPC server on it, connect a real client to it, and everything works — just without the network. Every test gets a fresh server with no shared state.
Testing Unary RPCs
Add these tests to the same main_test.go:
func TestCreateLink(t *testing.T) {
client := setupTest(t)
resp, err := client.CreateLink(context.Background(), &pb.CreateLinkRequest{
Url: "https://golang.org",
})
if err != nil {
t.Fatalf("CreateLink failed: %v", err)
}
if resp.GetLink().GetUrl() != "https://golang.org" {
t.Errorf("got url %q, want %q", resp.GetLink().GetUrl(), "https://golang.org")
}
if resp.GetLink().GetShortCode() == "" {
t.Error("expected non-empty short code")
}
}
func TestGetLink_NotFound(t *testing.T) {
client := setupTest(t)
_, err := client.GetLink(context.Background(), &pb.GetLinkRequest{ShortCode: "nonexistent"})
if err == nil {
t.Fatal("expected error, got nil")
}
st, ok := status.FromError(err)
if !ok {
t.Fatalf("expected gRPC status error, got %v", err)
}
if st.Code() != codes.NotFound {
t.Errorf("got code %v, want NotFound", st.Code())
}
}Testing the Full Flow
func TestCreateAndGetLink(t *testing.T) {
client := setupTest(t)
ctx := context.Background()
// Create
created, err := client.CreateLink(ctx, &pb.CreateLinkRequest{
Url: "https://grpc.io",
})
if err != nil {
t.Fatalf("CreateLink: %v", err)
}
// Get
fetched, err := client.GetLink(ctx, &pb.GetLinkRequest{
ShortCode: created.GetLink().GetShortCode(),
})
if err != nil {
t.Fatalf("GetLink: %v", err)
}
if fetched.GetLink().GetUrl() != "https://grpc.io" {
t.Errorf("got %q, want %q", fetched.GetLink().GetUrl(), "https://grpc.io")
}
// Delete
_, err = client.DeleteLink(ctx, &pb.DeleteLinkRequest{
ShortCode: created.GetLink().GetShortCode(),
})
if err != nil {
t.Fatalf("DeleteLink: %v", err)
}
// Verify deleted
_, err = client.GetLink(ctx, &pb.GetLinkRequest{
ShortCode: created.GetLink().GetShortCode(),
})
st, _ := status.FromError(err)
if st.Code() != codes.NotFound {
t.Errorf("expected NotFound after delete, got %v", st.Code())
}
}Testing Streaming RPCs
func TestListLinks(t *testing.T) {
client := setupTest(t)
ctx := context.Background()
// Create a few links
urls := []string{"https://a.com", "https://b.com", "https://c.com"}
for _, url := range urls {
_, err := client.CreateLink(ctx, &pb.CreateLinkRequest{Url: url})
if err != nil {
t.Fatalf("CreateLink: %v", err)
}
}
// List them
stream, err := client.ListLinks(ctx, &pb.ListLinksRequest{})
if err != nil {
t.Fatalf("ListLinks: %v", err)
}
var received int
for {
_, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("Recv: %v", err)
}
received++
}
if received != len(urls) {
t.Errorf("got %d links, want %d", received, len(urls))
}
}Note: If your
ListLinkshas atime.Sleepfor demonstration purposes, this test will be slow. Remove or reduce the sleep for testing.
Testing with Interceptors
If your server uses interceptors (from lesson 11), include them in the test setup:
func setupTestWithAuth(t *testing.T) pb.LinkServiceClient {
t.Helper()
lis := bufconn.Listen(bufSize)
srv := grpc.NewServer(
grpc.ChainUnaryInterceptor(
loggingInterceptor,
authInterceptor,
),
)
pb.RegisterLinkServiceServer(srv, newLinkServer())
go func() {
if err := srv.Serve(lis); err != nil {
t.Logf("server exited: %v", err)
}
}()
t.Cleanup(func() {
srv.Stop()
lis.Close()
})
conn, err := grpc.NewClient("passthrough:///bufconn",
grpc.WithContextDialer(func(ctx context.Context, s string) (net.Conn, error) {
return lis.DialContext(ctx)
}),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
t.Fatalf("failed to dial: %v", err)
}
t.Cleanup(func() { conn.Close() })
return pb.NewLinkServiceClient(conn)
}This requires loggingInterceptor and authInterceptor from lesson 11 to be defined in main.go. If you haven't added them yet, skip this section.
func TestAuthRequired(t *testing.T) {
client := setupTestWithAuth(t)
// Call without auth metadata — should fail
_, err := client.CreateLink(context.Background(), &pb.CreateLinkRequest{Url: "https://test.com"})
st, _ := status.FromError(err)
if st.Code() != codes.Unauthenticated {
t.Errorf("expected Unauthenticated, got %v", st.Code())
}
// Call with auth metadata — should succeed
ctx := metadata.AppendToOutgoingContext(context.Background(),
"authorization", "Bearer my-secret-key",
)
_, err = client.CreateLink(ctx, &pb.CreateLinkRequest{Url: "https://test.com"})
if err != nil {
t.Fatalf("expected success with auth, got %v", err)
}
}If using metadata.AppendToOutgoingContext, add "google.golang.org/grpc/metadata" to your imports.
Table-Driven Tests
Same pattern as regular Go tests. Works well for testing validation:
func TestCreateLink_Validation(t *testing.T) {
client := setupTest(t)
tests := []struct {
name string
url string
wantErr codes.Code
}{
{"empty url", "", codes.InvalidArgument},
{"valid url", "https://example.com", codes.OK},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := client.CreateLink(context.Background(), &pb.CreateLinkRequest{Url: tt.url})
if tt.wantErr == codes.OK {
if err != nil {
t.Fatalf("expected success, got %v", err)
}
return
}
st, _ := status.FromError(err)
if st.Code() != tt.wantErr {
t.Errorf("got %v, want %v", st.Code(), tt.wantErr)
}
})
}
}Running Tests
go test -vRun from your project root (where main.go and main_test.go live).
Key Takeaways
bufconncreates in-memory gRPC connections — no network needed- Each test gets a fresh server with
setupTesthelper andt.Cleanup - Test file must be
package mainto access unexported helpers likenewLinkServer() - Test error codes with
status.FromErrorandst.Code() - Streaming tests use the same
Recv()loop as production code - Include interceptors in test setup to test auth and middleware
- Table-driven tests work great for validation and error cases