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{}
}
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);
}
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
}
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
}
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)
})
}
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
}
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,
}
}
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,
}
}
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
}
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)
})
}
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)