DEV Community

Cover image for Implementing Dependency Injection By Testing gRPC Service Call
Syukur
Syukur

Posted on

Implementing Dependency Injection By Testing gRPC Service Call

Photo by Shruti Parthasarathy on Unsplash

Motivation

  • Need to test business logic that depends on gRPC call
  • Avoid coupling test with network, connection setup, and external services
  • Ensure tests are deterministic and fast

Scenario

Below is definition of ServiceV1 and its dependency

package service

type ServiceV1 struct{}

func NewServiceV1() ServiceV1 {
    return ServiceV1{}
}
Enter fullscreen mode Exit fullscreen mode

Below is content of student_v1.proto, which defines request and response for student gRPC service.

syntax = "proto3";

option go_package = "../grpc/student";

package student;

message GetStudentRequest {
  int32 id = 1;
}

message GetStudentResponse {
  int32 id = 1;
}

service Student {
  rpc GetStudent (GetStudentRequest) returns (GetStudentResponse);
}
Enter fullscreen mode Exit fullscreen mode

Function NewStudentGRPCService() creates gRPC client to student gRPC service.

func NewStudentGRPCService(
    addr string,
) (studentGRPCClient.StudentClient, error){
    client, err := grpc.NewClient(addr)
    if err != nil {
        return nil, err
    }

    return studentGRPCClient.NewStudentClient(client), nil
}
Enter fullscreen mode Exit fullscreen mode

GetStudentV1() calls GetStudent gRPC request to student gRPC service.

func (s *ServiceV1) GetStudentV1(
    ctx context.Context,
    studentClientAddr string,
    id int32,
) (int32, error) {
    client, err := grpc.NewClient(studentClientAddr)
    if err != nil {
        return 0, err
    }

    studentGRPCClient := student.NewStudentClient(client)

    responseID, err := studentGRPCClient.GetStudent(ctx, &student.GetStudentRequest{
        Id: id,
    })
    if err != nil {
        return 0, err
    }

    return responseID.Id, nil
}
Enter fullscreen mode Exit fullscreen mode

Let's create test for function above.

package service_test

type serviceProxy struct {
    svcV1 service.ServiceV1
}

func newServiceProxy(
) serviceProxy{
    svcV1 := service.NewServiceV1()
    return serviceProxy{
        svcV1: svcV1,
    }
}

func TestGetStudent(t *testing.T){
    cctx := context.Background()
    svcProxy := newServiceProxy()

    t.Run("success get student", func(t *testing.T) {
        resp, err := svcProxy.svcV1.GetStudentV1(cctx, "",1)
        assert.NoError(t, err)
        assert.Equal(t, 1, resp)
    })
}
Enter fullscreen mode Exit fullscreen mode

It will return grpc: no transport security set error, because there is no live student gRPC server, does not matter student gRPC client address you are passing for GetStudentV1 argument, the test will always fail.

This gRPC service calls makes GetStudentV1() hard to test,
because it tightly coupled with student gRPC service by using
connection client on function body.

To make this less coupled, student gRPC server instantiation
must be done outside GetStudentV1() function, and
GetStudentV1() gRPC service call must be done using interface.

Solution

Note that gRPC code generates interface StudentClient and a concrete type studentClient that implements/has gRPC services. This allows us to replace real client with our own implementation for testing purposes.

If we dig to generated gRPC service code, note that NewStudentClient returns studentClient struct that implements/has GetStudent method. Thinking the same way, we create our own mock client that implements/has GetStudent method with same argument and return values implemented by studentClient.

type StudentGRPCMock struct{}

func NewStudentGRPCMock() *StudentGRPCMock{
    return &StudentGRPCMock{}
}

func (m *StudentGRPCMock) GetStudent(ctx context.Context, in *studentGRPCClient.GetStudentRequest, opts ...grpc.CallOption) (*studentGRPCClient.GetStudentResponse, error) {
    return &studentGRPCClient.GetStudentResponse{
        Id: 1,
    }, nil
}
Enter fullscreen mode Exit fullscreen mode

Note that function body of GetStudent implemented by studentClient's generated code is different compared to StudentGRPCMock. This is valid interface definition because interface forces a type to have same arguments/signature and return types, without considering content of function body.

package service

type Service struct {
    studentGRPCClient student.StudentClient
}

func NewService(
    studentGRPCClient student.StudentClient,
) Service {
    return Service{
        studentGRPCClient: studentGRPCClient,
    }
}
Enter fullscreen mode Exit fullscreen mode
package service_test

type serviceProxy struct {
    svc service.Service
    svcV1 service.ServiceV1
}

func newServiceProxy(
) serviceProxy{
    mockStudentClient := student.NewStudentGRPCMock()
    svc := service.NewService(mockStudentClient)

    svcV1 := service.NewServiceV1()
    return serviceProxy{
        svc: svc,
        svcV1: svcV1,
    }
}
Enter fullscreen mode Exit fullscreen mode

After rewriting ServiceV1 to Service struct by injecting StudentClient interface, we are no longer depends on real gRPC connection to call GetStudent service. This approach is called dependency injection where dependencies are provided from outside instead of being created inside the function.

func (s *Service) GetStudent(
    ctx context.Context,
    id int32,
) (int32, error) {
    responseID, err := s.studentGRPCClient.GetStudent(ctx, &student.GetStudentRequest{
        Id: id,
    })
    if err != nil {
        return 0, err
    }

    return responseID.Id, nil
}
Enter fullscreen mode Exit fullscreen mode

Let's update test file to verify that refactored GetSudent() is working.

package service_test

func TestGetStudent(t *testing.T){
    cctx := context.Background()
    svcProxy := newServiceProxy()

    t.Run("success get student", func(t *testing.T) {
        resp, err := svcProxy.svc.GetStudent(cctx, 1)
        assert.NoError(t, err)
        assert.Equal(t, 1, resp)
    })
}
Enter fullscreen mode Exit fullscreen mode

Run go test -v -count=1 -timeout 30m -coverprofile=coverage.out ./service/... on terminal to test and generate test coverage for service package, followed by go tool cover -html=coverage.out to generate HTML file to visualize code coverage.

At this point, we've been succesfully test service/business logic that calls gRPC service by injecting gRPC interface instead of using actual gRPC connection.

Limitation

Mock implementation shows us fixed response. While this approach is sufficient for simple case, it becomes limiting when we want to test various cases and edge cases.

A more flexible approach is allows mock to define its behavior per test and cases.

Resource

Complete code for this article can be found here.

Top comments (0)