DEV Community

prozz
prozz

Posted on • Updated on

Serverless in Go: How to write testable Lambdas?

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
    })
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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))
}
Enter fullscreen mode Exit fullscreen mode

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)
    })

Enter fullscreen mode Exit fullscreen mode

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)
    })
Enter fullscreen mode Exit fullscreen mode

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.

GitHub logo 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:

Top comments (2)

Collapse
 
ericraio profile image
Eric Raio

Hi @prozz , the mock file in this article is incorrect :)

Collapse
 
prozz profile image
prozz

Thanks! I think I fixed it :)