14 - Testing gRPC Services

📋 Jump to Takeaways

You 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 ListLinks has a time.Sleep for 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 -v

Run from your project root (where main.go and main_test.go live).

Key Takeaways

  • bufconn creates in-memory gRPC connections — no network needed
  • Each test gets a fresh server with setupTest helper and t.Cleanup
  • Test file must be package main to access unexported helpers like newLinkServer()
  • Test error codes with status.FromError and st.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

💻 Examples

Complete examples for this lesson. Copy and run locally.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

Spot something off? Report an issue

© 2026 ByteLearn.dev. Free courses for developers. · Privacy