AWS SDK Go Lambda Unit Testing

Viewed 1317

I'm trying to wrap my head around writing some unit tests for the first time ever, and I'm doing it in golang for a side project utilising aws lambda.

Below are two files.

main.go takes an event containing an email address, and creates the user in a cognito user pool.

main_test.go is supposed to mock the createUser function in main.go, but I'm getting an error when I try to run the test.

I've just switched my code from instantiating the client globally to using pointer receiver methods on the aws sdk interfaces after watching this youtube video.

main.go

package main

import (
    "fmt"
    "log"
    "os"

    "github.com/aws/aws-lambda-go/events"
    "github.com/aws/aws-lambda-go/lambda"
    "github.com/aws/aws-sdk-go/aws"
    "github.com/aws/aws-sdk-go/aws/awserr"
    "github.com/aws/aws-sdk-go/aws/session"
    cidp "github.com/aws/aws-sdk-go/service/cognitoidentityprovider"
    cidpif "github.com/aws/aws-sdk-go/service/cognitoidentityprovider/cognitoidentityprovideriface"
    lib "github.com/sean/repo/lib"
)

type createUserEvent struct {
    EmailAddress string `json:"email_address"`
}

type awsService struct {
    cidpif.CognitoIdentityProviderAPI
}

func (c *awsService) createUser(e createUserEvent) error {
    input := &cidp.AdminCreateUserInput{
        UserPoolId:             aws.String(os.Getenv("USER_POOL_ID")),
        Username:               aws.String(e.EmailAddress),
        DesiredDeliveryMediums: []*string{aws.String("EMAIL")},
        ForceAliasCreation:     aws.Bool(true),
        UserAttributes: []*cidp.AttributeType{
            {
                Name:  aws.String("email"),
                Value: aws.String(e.EmailAddress),
            },
        },
    }
    _, err := c.AdminCreateUser(input)
    if err != nil {
        if aerr, ok := err.(awserr.Error); ok {
            switch aerr.Code() {
            case cidp.ErrCodeResourceNotFoundException:
                log.Printf("[ERROR] %v, %v", cidp.ErrCodeResourceNotFoundException, aerr.Error())
            case cidp.ErrCodeInvalidParameterException:
                log.Printf("[ERROR] %v, %v", cidp.ErrCodeInvalidParameterException, aerr.Error())
            case cidp.ErrCodeUserNotFoundException:
                log.Printf("[ERROR] %v, %v", cidp.ErrCodeUserNotFoundException, aerr.Error())
            case cidp.ErrCodeUsernameExistsException:
                log.Printf("[ERROR] %v, %v", cidp.ErrCodeUsernameExistsException, aerr.Error())
            case cidp.ErrCodeInvalidPasswordException:
                log.Printf("[ERROR] %v, %v", cidp.ErrCodeInvalidPasswordException, aerr.Error())
            case cidp.ErrCodeCodeDeliveryFailureException:
                log.Printf("[ERROR] %v, %v", cidp.ErrCodeCodeDeliveryFailureException, aerr.Error())
            case cidp.ErrCodeUnexpectedLambdaException:
                log.Printf("[ERROR] %v, %v", cidp.ErrCodeUnexpectedLambdaException, aerr.Error())
            case cidp.ErrCodeUserLambdaValidationException:
                log.Printf("[ERROR] %v, %v", cidp.ErrCodeUserLambdaValidationException, aerr.Error())
            case cidp.ErrCodeInvalidLambdaResponseException:
                log.Printf("[ERROR] %v, %v", cidp.ErrCodeInvalidLambdaResponseException, aerr.Error())
            case cidp.ErrCodePreconditionNotMetException:
                log.Printf("[ERROR] %v, %v", cidp.ErrCodePreconditionNotMetException, aerr.Error())
            case cidp.ErrCodeInvalidSmsRoleAccessPolicyException:
                log.Printf("[ERROR] %v, %v", cidp.ErrCodeInvalidSmsRoleAccessPolicyException, aerr.Error())
            case cidp.ErrCodeInvalidSmsRoleTrustRelationshipException:
                log.Printf("[ERROR] %v, %v", cidp.ErrCodeInvalidSmsRoleTrustRelationshipException, aerr.Error())
            case cidp.ErrCodeTooManyRequestsException:
                log.Printf("[ERROR] %v, %v", cidp.ErrCodeTooManyRequestsException, aerr.Error())
            case cidp.ErrCodeNotAuthorizedException:
                log.Printf("[ERROR] %v, %v", cidp.ErrCodeNotAuthorizedException, aerr.Error())
            case cidp.ErrCodeUnsupportedUserStateException:
                log.Printf("[ERROR] %v, %v", cidp.ErrCodeUnsupportedUserStateException, aerr.Error())
            case cidp.ErrCodeInternalErrorException:
                log.Printf("[ERROR] %v, %v", cidp.ErrCodeInternalErrorException, aerr.Error())
            default:
                log.Printf("[ERROR] %v", err.Error())
            }
        } else {
            log.Printf("[ERROR] %v", err.Error())
        }
        return err
    }
    log.Printf("[INFO] Created new user %v successfully", e.EmailAddress)
    return nil
}

func (c *awsService) handler(e createUserEvent) (events.APIGatewayProxyResponse, error) {
    headers := map[string]string{"Content-Type": "application/json"}

    err := c.createUser(e)
    if err != nil {
        resp := lib.GenerateResponseBody(fmt.Sprintf("Error creating user %v", e.EmailAddress), 404, err, headers)
        return resp, nil
    }

    resp := lib.GenerateResponseBody(fmt.Sprintf("Created new user %v", e.EmailAddress), 200, nil, headers)
    return resp, nil
}

func main() {
    c := awsService{cidp.New(session.New())}

    lambda.Start(c.handler)
}

main_test.go

package main

import (
    "os"
    "testing"

    cidp "github.com/aws/aws-sdk-go/service/cognitoidentityprovider"
    cidpif "github.com/aws/aws-sdk-go/service/cognitoidentityprovider/cognitoidentityprovideriface"
)

type mockCreateUser struct {
    cidpif.CognitoIdentityProviderAPI
    Response cidp.AdminCreateUserOutput
}

func (d mockCreateUser) CreateUser(e createUserEvent) error {
    return nil
}

func TestCreateUser(t *testing.T) {
    t.Run("Successfully create user", func(t *testing.T) {
        m := mockCreateUser{Response: cidp.AdminCreateUserOutput{}}
        c := awsService{m}

        err := os.Setenv("USER_POOL_ID", "ap-southeast-2_ovum4dzAL")
        if err != nil {
            t.Fatal(err)
        }

        err = c.createUser(createUserEvent{EmailAddress: "user@example.com"})
        if err != nil {
            t.Fatal("User should have been created")
        }
    })
}

the error

Running tool: /usr/local/go/bin/go test -timeout 30s -run ^TestCreateUser$ github.com/sean/repo/src/create_user

--- FAIL: TestCreateUser (0.00s)
    --- FAIL: TestCreateUser/Successfully_create_user (0.00s)
panic: runtime error: invalid memory address or nil pointer dereference [recovered]
    panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x60 pc=0x137c2f2]

goroutine 6 [running]:
testing.tRunner.func1.1(0x13e00c0, 0x173fd70)
    /usr/local/go/src/testing/testing.go:1072 +0x30d
testing.tRunner.func1(0xc000001b00)
    /usr/local/go/src/testing/testing.go:1075 +0x41a
panic(0x13e00c0, 0x173fd70)
    /usr/local/go/src/runtime/panic.go:969 +0x1b9
github.com/sean/repo/src/create_user.(*mockCreateUser).AdminCreateUser(0xc00000ee40, 0xc00006a200, 0xc00001e39d, 0x18, 0xc0000b24b0)
    <autogenerated>:1 +0x32
github.com/sean/repo/src/create_user.(*awsService).createUser(0xc000030738, 0x145bbca, 0x10, 0x18, 0x0)
    /Users/sean/code/github/sean/repo/src/create_user/main.go:39 +0x2b7
github.com/sean/repo/src/create_user.TestCreateUser.func1(0xc000001b00)
    /Users/sean/code/github/sean/repo/src/create_user/main_test.go:30 +0x10c
testing.tRunner(0xc000001b00, 0x1476718)
    /usr/local/go/src/testing/testing.go:1123 +0xef
created by testing.(*T).Run
    /usr/local/go/src/testing/testing.go:1168 +0x2b3
FAIL    github.com/sean/repo/src/create_user    0.543s
FAIL
1 Answers

I think the issue here is, that you wanted to mock the AdminCreateUser() method, but did actually mock the CreateUser() method.

Therefore, when you create a new instance of the mockCreateUser struct, that "implements" the cidpif.CognitoIdentityProviderAPI interface, and then call the AdminCreateUser() method on it, it is not implemented and fails.

The relevant code in your main_test.go should be this:

type mockCreateUser struct {
    cidpif.CognitoIdentityProviderAPI
    Response cidp.AdminCreateUserOutput
}

func (d mockCreateUser) CreateUser(e createUserEvent) error {
    return nil
}

It should be enough to add the following "dummy" (and remove the CreateUser() method):

func (d mockCreateUser) AdminCreateUser(*cidp.AdminCreateUserInput) (*cidp.AdminCreateUserOutput, error) {
    return d.Response, nil
}

Furthermore, I'd like to propose a slightly different approach to unit testing your Lambdas. You code is already pretty good in terms of testability. But you can do better.

I would propose to create an "application" that acts similar to your awsService struct, but does not implement any of the AWS interfaces. Instead, it contains a configuration struct. This configuration contains values that you read from the environment (e.g. USER_POOL_ID, EMAIL) and also instances of the AWS services.

The idea is that all of your methods and functions use this configuration, allowing you to use mock AWS services during unit testing and use "proper" service instances during runtime.

What follows is a simplified version of your Lambda. Obviously, naming etc. is up to you. There is also a lot of error handling missing etc.

The biggest advantage I think is, that you can very easily modify the input to your methods/functions through the config of the application. If you want to use different email etc. per test and different behaviour, you do that by simply changing the configuration.

main.go

package main

import (
    "os"

    "github.com/aws/aws-lambda-go/events"
    "github.com/aws/aws-lambda-go/lambda"
    "github.com/aws/aws-sdk-go/aws"
    "github.com/aws/aws-sdk-go/aws/session"
    "github.com/aws/aws-sdk-go/service/cognitoidentityprovider"
    "github.com/aws/aws-sdk-go/service/cognitoidentityprovider/cognitoidentityprovideriface"
)

type createUserEvent struct {
    EmailAddress string `json:"email_address"`
}

type configuration struct {
    poolId string
    idp    cognitoidentityprovideriface.CognitoIdentityProviderAPI
}

type application struct {
    config configuration
}

func (app *application) createUser(event createUserEvent) error {
    input := &cognitoidentityprovider.AdminCreateUserInput{
        UserPoolId:             aws.String(app.config.poolId),
        Username:               aws.String(event.EmailAddress),
        DesiredDeliveryMediums: aws.StringSlice([]string{"EMAIL"}),
        ForceAliasCreation:     aws.Bool(true),
        UserAttributes: []*cognitoidentityprovider.AttributeType{
            {
                Name:  aws.String("email"),
                Value: aws.String(event.EmailAddress),
            },
        },
    }

    _, err := app.config.idp.AdminCreateUser(input)
    if err != nil {
        return err
    }

    return nil
}

func (app *application) handler(event createUserEvent) (events.APIGatewayProxyResponse, error) {
    err := app.createUser(event)
    if err != nil {
        return events.APIGatewayProxyResponse{}, err
    }

    return events.APIGatewayProxyResponse{}, nil
}

func main() {
    config := configuration{
        poolId: os.Getenv("USER_POOL_ID"),
        idp:    cognitoidentityprovider.New(session.Must(session.NewSession())),
    }

    app := application{config: config}

    lambda.Start(app.handler)
}

main_test.go

package main

import (
    "testing"

    "github.com/aws/aws-sdk-go/service/cognitoidentityprovider"
    "github.com/aws/aws-sdk-go/service/cognitoidentityprovider/cognitoidentityprovideriface"
)

type mockAdminCreateUser struct {
    cognitoidentityprovideriface.CognitoIdentityProviderAPI
    Response *cognitoidentityprovider.AdminCreateUserOutput
    Error    error
}

func (d mockAdminCreateUser) AdminCreateUser(*cognitoidentityprovider.AdminCreateUserInput) (*cognitoidentityprovider.AdminCreateUserOutput, error) {
    return d.Response, d.Error
}

func TestCreateUser(t *testing.T) {
    t.Run("Successfully create user", func(t *testing.T) {
        idpMock := mockAdminCreateUser{
            Response: &cognitoidentityprovider.AdminCreateUserOutput{},
            Error:    nil,
        }

        app := application{config: configuration{
            poolId: "test",
            idp:    idpMock,
        }}

        err := app.createUser(createUserEvent{EmailAddress: "user@example.com"})
        if err != nil {
            t.Fatal("User should have been created")
        }
    })
}
Related