grpcsteps
uses go.nhat.io/grpcmock
to provide steps for cucumber/godog
and makes
it easy to run tests with grpc server and client.
Go >= 1.19
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())
}
}
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.
}
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.
- 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"
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())
}
}
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),
)
}
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
orlocalhost: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.
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"
- 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"
"""