86. Case Study: Testing a CRUD Service with gRPC
86. Case Study: Testing a CRUD Service with gRPC
These days, large-scale application development tends to adopt microservices architectures along with efficient communication protocols such as gRPC. Many companies make gRPC their primary choice because of its high performance and schema-driven API powered by Protocol Buffers. However, the biggest challenge that frequently arises is how to test these services effectively, especially for CRUD (Create, Read, Update, Delete) services.
In this article, I want to walk you through a real-world case study on testing a gRPC-based CRUD service. I’ll cover the service structure, unit testing strategies, integration testing, and even test automation. The code samples will use Go (Golang) because of its popularity within the gRPC ecosystem.
Background of the Problem
Imagine a team building a BookService that offers CRUD operations on a Book entity. This service has to be fast, reliable, and easy to test. Automated testing is needed to make sure that code changes don’t introduce regressions. The tests must also be easy to run, both in a local environment and in a CI/CD pipeline.
CRUD Flow Diagram in BookService
flowchart TD
C(Client) -->|CreateRequest| S(BookService)
C(Client) -->|ReadRequest| S(BookService)
C(Client) -->|UpdateRequest| S(BookService)
C(Client) -->|DeleteRequest| S(BookService)
subgraph S
direction TB
DB[(Database)]
end
S --> DB
Protobuf Definition
The first step in developing a gRPC service is describing the API with protobuf:
1syntax = "proto3";
2
3package book;
4
5service BookService {
6 rpc CreateBook (Book) returns (BookId) {}
7 rpc GetBook (BookId) returns (Book) {}
8 rpc UpdateBook (Book) returns (Book) {}
9 rpc DeleteBook (BookId) returns (Empty) {}
10}
11
12message Book {
13 string id = 1;
14 string title = 2;
15 string author = 3;
16}
17
18message BookId {
19 string id = 1;
20}
21
22message Empty {}With this schema, the API structure is clear, easy to document, and easy to consume by clients across different languages.
Go Project Structure
Let’s simulate a simple directory structure for BookService.
1bookservice/
2 ├── proto/
3 │ └── book.proto
4 ├── main.go
5 ├── server.go
6 ├── repository.go
7 └── repository_test.goA Simple Server Implementation
The server side handles the CRUD requests. The repository is abstracted away to make testing easier:
1// repository.go
2type Book struct {
3 ID string
4 Title string
5 Author string
6}
7
8type BookRepository interface {
9 Create(book Book) (string, error)
10 Get(id string) (Book, error)
11 Update(book Book) error
12 Delete(id string) error
13}
14
15type InMemoryBookRepo struct {
16 books map[string]Book
17}
18
19func (r *InMemoryBookRepo) Create(book Book) (string, error) {
20 r.books[book.ID] = book
21 return book.ID, nil
22}
23
24func (r *InMemoryBookRepo) Get(id string) (Book, error) {
25 if book, ok := r.books[id]; ok {
26 return book, nil
27 }
28 return Book{}, errors.New("not found")
29}
30
31func (r *InMemoryBookRepo) Update(book Book) error {
32 if _, ok := r.books[book.ID]; ok {
33 r.books[book.ID] = book
34 return nil
35 }
36 return errors.New("not found")
37}
38
39func (r *InMemoryBookRepo) Delete(id string) error {
40 if _, ok := r.books[id]; ok {
41 delete(r.books, id)
42 return nil
43 }
44 return errors.New("not found")
45}Unit Testing: Focusing on the Repository
Before testing the full service, it’s important to write unit tests for the repository. The goal is to make sure the core CRUD logic is correct, independent of gRPC. Here’s an example test case using Go’s testing package:
1// repository_test.go
2func TestInMemoryBookRepo_CRUD(t *testing.T) {
3 repo := &InMemoryBookRepo{books: map[string]Book{}}
4 book := Book{ID: "1", Title: "Go in Action", Author: "John Doe"}
5
6 // Test Create
7 id, err := repo.Create(book)
8 if err != nil || id != "1" {
9 t.Errorf("Create failed, got id=%v, err=%v", id, err)
10 }
11
12 // Test Read
13 b, err := repo.Get("1")
14 if err != nil || b.Title != "Go in Action" {
15 t.Errorf("Get failed, got book=%v, err=%v", b, err)
16 }
17
18 // Test Update
19 book.Title = "Go Lang in Action"
20 err = repo.Update(book)
21 if err != nil {
22 t.Errorf("Update failed: %v", err)
23 }
24
25 // Test Delete
26 err = repo.Delete("1")
27 if err != nil {
28 t.Errorf("Delete failed: %v", err)
29 }
30}Benefits of Unit Testing
- Isolation: Ensures the repository component behaves as expected, regardless of the communication protocol.
- Speed: The tests run extremely fast.
Integration Testing with gRPC
Next, we need to test whether the gRPC server actually translates requests into the correct CRUD operations.
Setting Up the Integration Test
- Spin up a gRPC server backed by the in-memory repository.
- Send requests to the server directly using a gRPC client.
1// integration_test.go (pseudo)
2func TestBookServiceIntegration(t *testing.T) {
3 // 1. Run the server (no need to listen on a production port)
4 lis := bufconn.Listen(1024 * 1024)
5 s := grpc.NewServer()
6 repo := &InMemoryBookRepo{books: map[string]Book{}}
7 RegisterBookServiceServer(s, &BookServiceServer{repo: repo})
8 go s.Serve(lis)
9 defer s.Stop()
10
11 // 2. Set up the client with a custom dialer
12 ctx := context.Background()
13 conn, _ := grpc.DialContext(
14 ctx, "bufnet",
15 grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
16 return lis.Dial()
17 }),
18 grpc.WithInsecure(),
19 )
20 defer conn.Close()
21 client := NewBookServiceClient(conn)
22
23 // 3. Run the end-to-end integration test
24 resp, err := client.CreateBook(ctx, &Book{Id: "123", Title: "gRPC for Dummies", Author: "Jane"})
25 // 4. Assert validations
26 if err != nil || resp.Id != "123" {
27 t.Fatalf("Expected Id=123, got %v, err=%v", resp.Id, err)
28 }
29}CRUD Scenario Test Results Table
| Operation | Input | Output / Expectation | Status |
|---|---|---|---|
| Create | id=123, title=“gRPC for Dummies” | Success, id=123 | Pass |
| Read | id=123 | Book found | Pass |
| Update | id=123, title=“gRPC 101” | Book updated | Pass |
| Delete | id=123 | Book deleted | Pass |
| Read | id=999 (does not exist) | Error: not found | Pass |
Test Automation in a CI/CD Pipeline
The gRPC service tests above can be automated directly within a CI/CD pipeline (for example, with GitHub Actions or GitLab CI). The key to success: no dependency on external infrastructure, so the service can spin up and test itself using in-memory dependencies.
Test Automation Tips:
- Use test doubles for external resources (e.g. in-memory or mock).
- Make sure you have at least minimal test coverage for every CRUD feature.
- Separate unit tests from integration tests (for example, using the
_test.gosuffix).
Key Takeaways
- gRPC is extremely powerful for high-performance, schema-first services.
- Testing CRUD must be done at two levels: unit (logic) and integration (the gRPC interface).
- Using an in-memory repository makes it easy to isolate components and automate tests in the pipeline.
- Test coverage shouldn’t be limited to the “happy path” only; it must also cover edge cases and error conditions.
Conclusion
Testing a gRPC-based CRUD service can be made both simple and powerful by modularizing components, writing unit and integration tests systematically, and relying on in-memory dependencies. With this approach, errors are caught faster, development velocity improves, and the codebase becomes more resilient to change.
In practice, a model like this is easy to replicate for other microservices, and it builds a strong foundation of engineering excellence within your development team.
References:
- grpc-go/testing
- “gRPC - Google’s high performance, open source universal RPC framework”
- Go testing pkg
I hope this case study deepens your insight into building and testing scalable, reliable gRPC CRUD services!