Skip to content

Protoc plugin to generate contract tests for gRPC in Go

License

Notifications You must be signed in to change notification settings

faunists/deal-go

Repository files navigation

Deal - Go

test codecov Go Report Card

Introduction

This plugin allows us to write Consumer-Driver Contracts tests!

Deal generates some code for us:

  • A Client to be used in the client side to mock the responses based on the contract
  • A Stub Server to be used in the client side as the Client above, but you should run it as another application
  • Server Test Function, where you should pass your server implementation to the function and all the contracts will be validated against it

You can check out an example project here.

Installation

Assuming that you are using Go Modules, it's recommended to use a tool dependency in order to track your tools version:

//go:build tools
// +build tools

package tools

import (
    _ "github.com/faunists/deal-go/protoc-gen-go-deal"
    _ "google.golang.org/grpc/cmd/protoc-gen-go-grpc"
    _ "google.golang.org/protobuf/cmd/protoc-gen-go"
)

Once you have added the required packages run go mod tidy to resolve the versions and then install them by running:

go install \
    github.com/faunists/deal-go/protoc-gen-go-deal \
    google.golang.org/protobuf/cmd/protoc-gen-go \
    google.golang.org/grpc/cmd/protoc-gen-go-grpc

Usage example

Proto service

First you need a proto service:

syntax = "proto3";

import "google/protobuf/struct.proto";
import "deal/v1/contract/annotations.proto";

option go_package = "YOUR_PACKAGE_HERE/example";

message RequestMessage {
  string requestField = 1;
}

message ResponseMessage {
  int64 responseField = 1;
}

service MyService {
  rpc MyMethod(RequestMessage) returns (ResponseMessage);
}

Contract file

After that you need to write the contract that should be respected, the contract file can be written using a JSON or YAML file. You can set both, Success and Failures cases:

JSON Contract
{
  "name": "Some Name Here",
  "services": {
    "MyService": {
      "MyMethod": {
        "successCases": [
          {
            "description": "Should do something",
            "request": {
              "requestField": "VALUE"
            },
            "response": {
              "responseField": 42
            }
          }
        ],
        "failureCases": [
          {
            "description": "Some description here",
            "request": {
              "requestField": "ANOTHER_VALUE"
            },
            "error": {
              "errorCode": "NotFound",
              "message": "ANOTHER_VALUE NotFound"
            }
          }
        ]
      }
    }
  }
}
YAML Contract
name: Some Name Here
services:
  MyService:
    MyMethod:
      successCases:
        - description: Should do something
          request:
            requestField: VALUE
            someMessage:
              firstField: FIRST_FIELD_VALUE
            someEnum: TWO
          response:
            responseField: 42
            myList:
              - firstField: first
              - firstField: second
            intList: [1, 2, 3]
      failureCases:
        - description: Some description here
          request:
            requestField: ANOTHER_VALUE
          error:
            errorCode: NotFound
            message: ANOTHER_VALUE NotFound"

Generating code

If you're using buf just add the following entries to buf.gen.yaml and execute buf generate passing your contract file path:

version: v1beta1
plugins:
  - name: go
    out: protogen
    opt: paths=source_relative
  - name: go-grpc
    out: protogen
    opt: paths=source_relative
  - name: go-deal
    out: protogen
    opt:
      - paths=source_relative
      - contract-file=contract.yml

Disclaimer: You must be using go-grpc in order to make the things work

Using generated client on tests

Here is an example using the generated client, in the example we're using it inside a test, but it could be used anywhere!

package main_test

import (
	"context"
	"testing"

	"github.com/faunists/deal-go-example/protogen/proto/example"
	"github.com/stretchr/testify/require"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
	"google.golang.org/protobuf/proto"
)

func TestClient(t *testing.T) {
	t.Run("should return a response", func(t *testing.T) {
		ctx := context.Background()
		expectedResp := &example.ResponseMessage{ResponseField: 42}
		// Generated client
		client := example.MyServiceContractClient{}

		actualResp, err := client.MyMethod(ctx, &example.RequestMessage{
			RequestField: "VALUE",
		})

		require.NoError(t, err)
		require.True(t, proto.Equal(expectedResp, actualResp))
	})

	t.Run("should return an error", func(t *testing.T) {
		ctx := context.Background()
		expectedError := status.Error(codes.NotFound, "ANOTHER_VALUE NotFound")
		// Generated client
		client := example.MyServiceContractClient{}

		_, err := client.MyMethod(ctx, &example.RequestMessage{
			RequestField: "ANOTHER_VALUE",
		})

		require.EqualError(t, err, expectedError.Error())
	})
}

Stub Server (Mock Server)

Deal generates a stub server that you can run it a test against it.

package main

import (
	"log"
	"net"

	"github.com/faunists/deal-go-example/protogen/proto/example"
	"google.golang.org/grpc"
)

func main() {
	// Generated stub server
	stubServer := example.MyServiceStubServer{}

	listener, err := net.Listen("tcp", ":50051")
	if err != nil {
		log.Fatalf("error opening the listener: %v", err)
	}
	defer func() { _ = listener.Close() }()

	grpcServer := grpc.NewServer()
	example.RegisterMyServiceServer(grpcServer, &stubServer)

	log.Printf("grpc server listening at %v", listener.Addr())
	if err = grpcServer.Serve(listener); err != nil {
		log.Fatalf("failed to serve: %v", err)
	}
	grpcServer.GracefulStop()
}

Validating contract with server

The first step is to implement our server, the below implementation is compliant with the presented contract:

package server

import (
	"context"

	"github.com/faunists/deal-go-example/protogen/proto/example"

	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
)

type MyServer struct {
	example.UnimplementedMyServiceServer
}

func (svc *MyServer) MyMethod(
	_ context.Context,
	req *example.RequestMessage,
) (*example.ResponseMessage, error) {
	if req.RequestField == "ANOTHER_VALUE" {
		return nil, status.Error(codes.NotFound, "ANOTHER_VALUE NotFound")
	}

	return &example.ResponseMessage{ResponseField: 42}, nil
}

Now we can use the generated test function that will validate our implementation:

package main_test

import (
	"context"
	"testing"

	"github.com/faunists/deal-go-example/protogen/proto/example"

	"github.com/faunists/deal-go-example/api/server"
	"google.golang.org/grpc"
)

func TestMyServiceContract(t *testing.T) {
	grpcServer := grpc.NewServer()
	example.RegisterMyServiceServer(grpcServer, &server.MyServer{})

	// Testing the implementation
	example.MyServiceContractTest(t, context.Background(), grpcServer)
}