Skip to content

godogx/grpcsteps

Repository files navigation

Cucumber gRPC steps for Golang

GitHub Releases Build Status codecov Go Report Card GoDevDoc

grpcsteps uses go.nhat.io/grpcmock to provide steps for cucumber/godog and makes it easy to run tests with grpc server and client.

Table of Contents

Prerequisites

  • Go >= 1.19

[table of contents]

Usage

Mock external gPRC Services

This is for describing behaviors of gRPC endpoints that are called by the app during test (e.g. 3rd party APIs). The mock creates an gRPC server for each of registered services and allows control of expected requests and responses with gherkin steps.

In simple case, you can define the expected method and response.

Feature: Get Item

    Scenario: Success
        Given "item-service" receives a grpc request "/grpctest.ItemService/GetItem" with payload:
        """
        {
            "id": 42
        }
        """

        And the grpc service responds with payload:
        """
        {
            "id": 42,
            "name": "Item #42"
        }
        """

        # Your application call.

For starting, initiate a server and register it to the scenario.

package mypackage

import (
	"bytes"
	"math/rand"
	"testing"

	"github.com/cucumber/godog"

	"github.com/godogx/grpcsteps"
)

func TestIntegration(t *testing.T) {
	out := bytes.NewBuffer(nil)

	// Create a new grpc servers manager
	m := grpcsteps.NewExternalServiceManager()

	// Setup the 3rd party services here.

	suite := godog.TestSuite{
		Name: "Integration",
		TestSuiteInitializer: func(sc *godog.TestSuiteContext) {
			sc.AfterSuite(func() {
				m.Close()
			})
		},
		ScenarioInitializer: func(sc *godog.ScenarioContext) {
			m.RegisterContext(sc)
		},
		Options: &godog.Options{
			Strict:    true,
			Output:    out,
			Randomize: rand.Int63(),
		},
	}

	// Run the suite.
	if status := suite.Run(); status != 0 {
		t.Fatal(out.String())
	}
}

[table of contents]

Setup

In order to mock the gPRC server, you have to register it to the manager with AddService() while initializing. The first argument is the service ID, the second argument is the function that prototool generates for you. Something like this:

package mypackage

import "google.golang.org/grpc"

func RegisterItemServiceServer(s grpc.ServiceRegistrar, srv ItemServiceServer) {
	s.RegisterService(&ItemService_ServiceDesc, srv)
}

For example:

package mypackage

import (
	"testing"

	"github.com/godogx/grpcsteps"
)

func TestIntegration(t *testing.T) {
	// Create a new grpc servers manager.
	m := grpcsteps.NewExternalServiceManager()

	itemServiceAddr := m.AddService("item-service", RegisterItemServiceServer)

	// itemServiceAddr is going to be something like "[::]:52299".
	// Use that addr for the client in the application.

	// Run test suite.
}

By default, the manager spins up a gRPC with a random port. If you don't like that, you can specify the one you like with grpcmock.WithPort(). For example:

package mypackage

import (
	"testing"

	"go.nhat.io/grpcmock"

	"github.com/godogx/grpcsteps"
)

func TestIntegration(t *testing.T) {
	// Create a new grpc servers manager
	m := grpcsteps.NewExternalServiceManager()

	itemServiceAddr := m.AddService("item-service", RegisterItemServiceServer,
		grpcmock.WithPort(9000),
	)

	// itemServiceAddr is "[::]:9000".

	// Run test suite.
}

You can also use a listener, for example bufconn

package mypackage

import (
	"testing"

	"go.nhat.io/grpcmock"
	"google.golang.org/grpc/test/bufconn"

	"github.com/godogx/grpcsteps"
)

func TestIntegration(t *testing.T) {
	buf := bufconn.Listen(1024 * 1024)

	// Create a new grpc servers manager
	m := grpcsteps.NewExternalServiceManager()

	m.AddService("item-service", RegisterItemServiceServer,
		grpcmock.WithListener(buf),
	)

	// In this case, use the `buf` to connect to server

	// Run test suite.
}

[table of contents]

Steps

Prepare for a request

Mock a new request with (one of) these patterns

  • ^"([^"]*)" receives [a1] (?:gRPC|GRPC|grpc) request "([^"]*)"$
  • ^"([^"]*)" receives [a1] (?:gRPC|GRPC|grpc) request "([^"]*)" with payload:$
  • ^"([^"]*)" receives [a1] (?:gRPC|GRPC|grpc) request "([^"]*)" with payload from file "([^"]+)"$
  • ^"([^"]*)" receives [a1] (?:gRPC|GRPC|grpc) request "([^"]*)" with payload from file:$

Or, if the service receives multiple requests with the same condition, you could use

  • ^"([^"]*)" receives ([0-9]+) (?:gRPC|GRPC|grpc) requests "([^"]*)"$
  • ^"([^"]*)" receives ([0-9]+) (?:gRPC|GRPC|grpc) requests "([^"]*)" with payload:$
  • ^"([^"]*)" receives ([0-9]+) (?:gRPC|GRPC|grpc) requests "([^"]*)" with payload from file "([^"]+)"$
  • ^"([^"]*)" receives ([0-9]+) (?:gRPC|GRPC|grpc) requests "([^"]*)" with payload from file:$

Or, if you don't know how many times it's going to be, use

  • ^"([^"]*)" receives (?:some|many|several) (?:gRPC|GRPC|grpc) requests "([^"]*)"$
  • ^"([^"]*)" receives (?:some|many|several) (?:gRPC|GRPC|grpc) requests "([^"]*)" with payload:$
  • ^"([^"]*)" receives (?:some|many|several) (?:gRPC|GRPC|grpc) requests "([^"]*)" with payload from file "([^"]+)"$
  • ^"([^"]*)" receives (?:some|many|several) (?:gRPC|GRPC|grpc) requests "([^"]*)" with payload from file:$

And, Optionally, you can:

  • Add a header to the request with
    ^The (?:gRPC|GRPC|grpc) request has(?: a)? header "([^"]*): ([^"]*)"$

For example:

Feature: Get Item

    Scenario: Get item with locale
        Given "item-service" receives a grpc request "/grpctest.ItemService/GetItem" with payload:
        """
        {
            "id": 42
        }
        """
        And The gRPC request has a header "Locale: en-US"

        # Your application call.

Note, you can use "<ignore-diff>" in the payload to tell the assertion to ignore a JSON field. For example:

Feature: Create Items

    Scenario: Create items
        Given "item-service" receives a grpc request "/grpctest.ItemService/CreateItems" with payload:
        """
        {
            "id": 42
            "name": "<ignore-diff>",
            "category": "<ignore-diff>",
            "metadata": "<ignore-diff>"
        }
        """

        And the gRPC service responds with payload:
        """
        {
            "num_items": 1
        }
        """

        # The application call.
        When my application receives a request to create some items:
        """
        [
            {
                "id": 42
                "name": "Item #42",
                "category": 40,
                "metadata": {
                    "tags": ["soup"]
                }
            }
        ]
        """

        Then 1 item is created

"<ignore-diff>" can ignore any types, not just string.

[table of contents]

Response
  • Respond OK with payload
    ^[tT]he (?:gRPC|GRPC|grpc) service responds with payload:?$
    ^[tT]he (?:gRPC|GRPC|grpc) service responds with payload from file "([^"]+)"$
    ^[tT]he (?:gRPC|GRPC|grpc) service responds with payload from file:$
  • Response with code and error message
    ^[tT]he (?:gRPC|GRPC|grpc) service responds with code "([^"]*)"$
    ^[tT]he (?:gRPC|GRPC|grpc) service responds with error (?:message )?"([^"]*)"$
    ^[tT]he (?:gRPC|GRPC|grpc) service responds with code "([^"]*)" and error (?:message )?"([^"]*)"$

    If your error message contains quotes ", better use these with a doc string
    ^[tT]he (?:gRPC|GRPC|grpc) service responds with error(?: message)?:$
    ^[tT]he (?:gRPC|GRPC|grpc) service responds with code "([^"]*)" and error(?: message)?:$

For example:

Feature: Create Items

    Scenario: Create items
        Given "item-service" receives a gRPC request "/grpctest.ItemService/CreateItems" with payload:
        """
        [
            {
                "id": 42,
                "name": "Item #42"
            },
            {
                "id": 43,
                "name": "Item #42"
            }
        ]
        """

        And the gRPC service responds with payload:
        """
        {
            "num_items": 2
        }
        """

        # Your application call.

or

Feature: Create Items

    Scenario: Create items
        Given "item-service" receives a gRPC request "/grpctest.ItemService/CreateItems" with payload:
        """
        [
            {
                "id": 42,
                "name": "Item #42"
            },
            {
                "id": 43,
                "name": "Item #42"
            }
        ]
        """

        And the gRPC service responds with code "InvalidArgument" and error "Invalid ID #42"

[table of contents]

Test a gPRC Server.

Initiate a client and register it to the scenario.

package mypackage

import (
	"bytes"
	"math/rand"
	"testing"

	"github.com/cucumber/godog"
	"google.golang.org/grpc"

	"github.com/godogx/grpcsteps"
)

func TestIntegration(t *testing.T) {
	out := bytes.NewBuffer(nil)

	// Create a new grpc client.
	c := grpcsteps.NewClient(
		grpcsteps.RegisterService(
			grpctest.RegisterItemServiceServer,
			grpcsteps.WithDialOptions(
				grpc.WithInsecure(),
			),
		),
	)

	suite := godog.TestSuite{
		Name:                 "Integration",
		TestSuiteInitializer: nil,
		ScenarioInitializer: func(ctx *godog.ScenarioContext) {
			// Register the client.
			c.RegisterContext(ctx)
		},
		Options: &godog.Options{
			Strict:    true,
			Output:    out,
			Randomize: rand.Int63(),
		},
	}

	// Run the suite.
	if status := suite.Run(); status != 0 {
		t.Fatal(out.String())
	}
}

[table of contents]

Setup

In order to test the gPRC server, you have to register it to the client with grpcsteps.RegisterService() while initializing. The first argument is the function that prototool generates for you. Something like this:

package mypackage

import "google.golang.org/grpc"

func RegisterItemServiceServer(s grpc.ServiceRegistrar, srv ItemServiceServer) {
	s.RegisterService(&ItemService_ServiceDesc, srv)
}

You can configure how the client connects to the server by putting the options. For example:

package mypackage

import "google.golang.org/grpc"

func createClient() *grpcsteps.Client {
	return grpcsteps.NewClient(
		grpcsteps.RegisterService(
			grpctest.RegisterItemServiceServer,
			grpcsteps.WithDialOptions(
				grpc.WithInsecure(),
			),
		),
	)
}

If you have multiple services and want to apply a same set of options to all, use grpcsteps.WithDefaultServiceOptions(). For example:

package mypackage

import "google.golang.org/grpc"

func createClient() *grpcsteps.Client {
	return grpcsteps.NewClient(
		// Set default service options.
		grpcsteps.WithDefaultServiceOptions(
			grpcsteps.WithDialOptions(
				grpc.WithInsecure(),
			),
		),
		// Register other services after this.
		grpcsteps.RegisterService(grpctest.RegisterItemServiceServer),
	)
}

[table of contents]

Options

The options are:

  • grpcsteps.WithAddressProvider(interface{Addr() net.Addr}): Connect to the server using the given address provider, the golang's built-in *net.Listener is an address provider.
  • grpcsteps.WithAddr(string): Connect to the server using the given address. For example: :9090 or localhost:9090.
  • grpcsteps.WithDialOption(grpc.DialOption): Add a dial option for connecting to the server.
  • grpcsteps.WithDialOptions(...grpc.DialOption): Add multiple dial options for connecting to the server.

[table of contents]

Steps

Prepare for a request

Create a new request with (one of) these patterns

  • ^I request(?: a)? (?:gRPC|GRPC|grpc)(?: method)? "([^"]*)" with payload:?$
  • ^I request(?: a)? (?:gRPC|GRPC|grpc)(?: method)? "([^"]*)" with payload from file "([^"]+)"$
  • ^I request(?: a)? (?:gRPC|GRPC|grpc)(?: method)? "([^"]*)" with payload from file:$

Optionally, you can:

  • Add a header to the request with
    ^The (?:gRPC|GRPC|grpc) request has(?: a)? header "([^"]*): ([^"]*)"$
  • Set a timeout for the request with
    ^The (?:gRPC|GRPC|grpc) request timeout is "([^"]*)"$

For example:

Feature: Get Item

    Scenario: Get item with locale
        When I request a gRPC method "/grpctest.ItemService/GetItem" with payload:
        """
        {
            "id": 42
        }
        """
        And The gRPC request has a header "Locale: en-US"

[table of contents]

Execute the request and validate the result.
  • Check only the response code
    ^I should have(?: a)? (?:gRPC|GRPC|grpc) response with code "([^"]*)"$
  • Check if the request is successful and the response payload matches an expectation
    ^I should have(?: a)? (?:gRPC|GRPC|grpc) response with payload:?$
    ^I should have(?: a)? (?:gRPC|GRPC|grpc) response with payload from file "([^"]+)"$
    ^I should have(?: a)? (?:gRPC|GRPC|grpc) response with payload from file:$
  • Check for error code and error message
    ^I should have(?: a)? (?:gRPC|GRPC|grpc) response with error (?:message )?"([^"]*)"$
    ^I should have(?: a)? (?:gRPC|GRPC|grpc) response with code "([^"]*)" and error (?:message )?"([^"]*)"$

    If your error message contains quotes ", better use these with a doc string
    ^I should have(?: a)? (?:gRPC|GRPC|grpc) response with error (?:message )?:$
    ^I should have(?: a)? (?:gRPC|GRPC|grpc) response with code "([^"]*)" and error (?:message )?:$

For example:

Feature: Create Items

    Scenario: Create items
        When I request a gRPC method "/grpctest.ItemService/CreateItems" with payload:
        """
        [
            {
                "id": 42,
                "name": "Item #42"
            },
            {
                "id": 43,
                "name": "Item #42"
            }
        ]
        """

        Then I should have a gRPC response with payload:
        """
        {
            "num_items": 2
        }
        """

or

Feature: Create Items

    Scenario: Create items
        When I request a gRPC method "/grpctest.ItemService/CreateItems" with payload:
        """
        [
            {
                "id": 42,
                "name": "Item #42"
            },
            {
                "id": 43,
                "name": "Item #42"
            }
        ]
        """

        Then I should have a gRPC response with error:
        """
        invalid "id"
        """

[table of contents]