Serverless in Go: How to write testable Lambdas?
prozz
Posted on December 6, 2019
Lambda functions should take input parameters, pass them to your domain code and react to results accordingly. What is, in my opinion, the most testable way to do it with use of Go?
First we need to decide what is the interface, that will execute our domain logic. Let's say we are writing a banking application and someone just requested a funds transfer. This can be expressed as:
package main
import "context"
type Transferer interface {
TransferFunds(ctx context.Context, from, to string, amount int) error
}
Let's put this interface into transfer.go
.
Now we are ready to generate a mock from this interface. I will use excellent go-mock library for that. Let's create mock/mock.go
file with following content:
//go:generate mockgen -package=mock -source=../transfer.go -destination=transfer.go
package mock
Please note a new line between go:generate
and package declaration, it's needed as our directive shouldn't be treated as package level comment.
Let's run go mod init
, then go generate
. Assuming proper go-mock installation, file mock/transfer.go
should appear. It's a good practice to keep all mocking generator directives together in mock/
folder to not pollute our source files with them, as it gets messy really fast otherwise.
Before writing our first test, let's see how lambda handlers may look like according to AWS documentation:
// Valid function signatures:
//
// func ()
// func () error
// func (TIn) error
// func () (TOut, error)
// func (TIn) (TOut, error)
// func (context.Context) error
// func (context.Context, TIn) error
// func (context.Context) (TOut, error)
// func (context.Context, TIn) (TOut, error)
In case of handling SQS messages signature for out handler may look like this:
import "github.com/aws/aws-lambda-go/events"
type Handler func(context.Context, events.SQSEvent) error
In our case we will write some custom AppSync resolver defined as:
import "context"
type Args struct {
From string `json:"from"`
To string `json:"to"`
Amount int `json:"amount"`
}
type Response struct {
Error string `json:"error"`
}
type Handler func(context.Context, Args) (Response, error)
Now we are ready. Let's write our first test:
package main_test
import (
...
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
)
func TestTransfer(t *testing.T) {
ctx := context.Background()
t.Run("args invalid", func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
transferer := mock.NewMockTransferer(ctrl)
handler := main.NewHandler(transferer)
response, err := handler(ctx, main.Args{Amount: -100})
assert.NoError(t, err)
assert.Equal(t, "Invalid arguments.", response.Error)
})
}
Mock controller will make sure that expectations set on mocks are met. Here there are no expectations defined, so in case we call transferer
by accident the test will fail.
Let's just make our first test pass, by adding this function to transfer.go
file:
func NewHandler(transferer Transferer) Handler {
return func(ctx context.Context, args Args) (Response, error) {
return Response{Error: "Invalid arguments."}, nil
}
}
It's green. Yay!
To make it deployable all we need to do is to write main
function.
// +build !test
package main
import "github.com/aws/aws-lambda-go/lambda"
func main() {
// boostrap proper implementation from your domain package here.
transferer := ...
lambda.Start(NewHandler(transferer))
}
As dependencies initialisation code may get lengthy sometimes, I like to exclude it from tests, so that code coverage stats stay sharp.
Let's write a few more tests. Handling success transfer could look like this:
t.Run("success", func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
transferer := mock.NewMockTransferer(ctrl)
transferer.EXPECT().TransferFunds(ctx, "amelie", "john", 100).
Return(nil)
handler := main.NewHandler(transferer)
response, err := handler(ctx, main.Args{
From: "amelie",
To: "john",
Amount: 100,
})
assert.NoError(t, err)
assert.Empty(t, response.Error)
})
Things may go south during funds transfer despite args being all right. Here is how it can be expressed in test:
t.Run("error", func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
transferer := mock.NewMockTransferer(ctrl)
transferer.EXPECT().TransferFunds(ctx, "amelie", "john", 100).
Return(errors.New("boom"))
handler := main.NewHandler(transferer)
response, err := handler(ctx, main.Args{
From: "amelie",
To: "john",
Amount: 100,
})
assert.NoError(t, err)
assert.Equal(t, "meaningful error message", response.Error)
})
We have skeleton of our lambda now and a few tests. Assuming all of them are green, we can even deploy the function and make some integration testing going.
There is a lot of design decisions to make during this process tho. How to validate the request? How do we want to handle transferer
errors? In what cases do we want our lambda to return an error? Do we want it to return them at all? Do we need more dependencies? Maybe we want our lambda to gather statistics about transfers? Or maybe we want it to pass results from the call to some internal service in an async manner via an SQS queue? There is a lot to consider.
Specifying tests first helps me thinking about all of the requirements and possible scenarios. It also builds confidence to ship new features quickly.
As an excercise, you may want to check out the code from Github and make all the tests pass.
prozz / lambda-golang-sample
Testable Lambda in Go.
If you find this tutorial post helpful, please let me know. I'm thinking about writing more, so any kind of feedback would be helpful. In the meantime, check out my previous article:
Posted on December 6, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.