We would like a strategy for unit testing our functions that does not require using the network to make API calls to AWS resources.
The AWS SDK for Go offers a clear strategy: mock service clients.
Every service in the SDK exports a Go interface with all the client function signatures. Let's look at the DynamoDB interface:
type DynamoDBAPI interface {
CreateTableWithContext(aws.Context, *dynamodb.CreateTableInput, ...request.Option) (*dynamodb.CreateTableOutput, error)
DeleteItemWithContext(aws.Context, *dynamodb.DeleteItemInput, ...request.Option) (*dynamodb.DeleteItemOutput, error)
GetItemWithContext(aws.Context, *dynamodb.GetItemInput, ...request.Option) (*dynamodb.GetItemOutput, error)
PutItemWithContext(aws.Context, *dynamodb.PutItemInput, ...request.Option) (*dynamodb.PutItemOutput, error)
QueryWithContext(aws.Context, *dynamodb.QueryInput, ...request.Option) (*dynamodb.QueryOutput, error)
UpdateItemWithContext(aws.Context, *dynamodb.UpdateItemInput, ...request.Option) (*dynamodb.UpdateItemOutput, error)
...
From aws-sdk-go dynamodbiface/interface.go
There are close to 100 signatures in the interface that map to the 35+ DynamoDB API methods and utility functions for interacting with them.
The SDK satisfies the interface with methods that map to the real DynamoDB API. For example we can see how the GetItemWithContext
takes a dynamodb.GetItemInput
struct, crafts an HTTP POST with a GetItem
header and a JSON body with the table and key name, and converts the HTTP response to a dynamodb.GetItemOutput
struct.
See aws-sdk-go dynamodb/api
Our program interacts with a DynamoDB
variable and methods like PutItemWithContext
on it:
func userPut(ctx context.Context, u *User) error {
_, err := DynamoDB.PutItemWithContext(ctx, &dynamodb.PutItemInput{
Item: map[string]*dynamodb.AttributeValue{
"id": &dynamodb.AttributeValue{
S: aws.String(u.ID),
},
"token": &dynamodb.AttributeValue{
B: u.Token,
},
"username": &dynamodb.AttributeValue{
S: aws.String(u.Username),
},
},
TableName: aws.String(os.Getenv("TABLE_NAME")),
})
return errors.WithStack(err)
}
From user.go
By default we initialize the DynamoDB
variable as the SDK dynamodb client, with standard configuration for the AWS region and credentials. The only trick is that the variable type is the DynamoDBAPI
interface which the SDK client satisfies.
var DynamoDB = NewDynamoDB()
// DynamoDBAPI is a subset of dynamodbiface.DynamoDBAPI
type DynamoDBAPI interface {
DeleteItemWithContext(ctx aws.Context, input *dynamodb.DeleteItemInput, opts ...request.Option) (*dynamodb.DeleteItemOutput, error)
GetItemWithContext(ctx aws.Context, input *dynamodb.GetItemInput, opts ...request.Option) (*dynamodb.GetItemOutput, error)
PutItemWithContext(ctx aws.Context, input *dynamodb.PutItemInput, opts ...request.Option) (*dynamodb.PutItemOutput, error)
}
// NewDynamoDB is an xray instrumented DynamoDB client
func NewDynamoDB() DynamoDBAPI {
c := dynamodb.New(sess)
xray.AWS(c.Client)
return c
}
From aws.go
Note that we made our own DynamoDBAPI
interface as a subset of the SDK dynamodbiface.DynamoDBAPI
. This is so we don't have to write out 100s of methods for our test implementation. Now we can build a simple mock client that returns deterministic outputs:
// MockDynamoDB is a mock DynamoDBAPI implementation
type MockDynamoDB struct {
DeleteItemOutput *dynamodb.DeleteItemOutput
GetItemOutput *dynamodb.GetItemOutput
PutItemOutput *dynamodb.PutItemOutput
}
func (m *MockDynamoDB) DeleteItemWithContext(ctx aws.Context, input *dynamodb.DeleteItemInput, opts ...request.Option) (*dynamodb.DeleteItemOutput, error) {
return m.DeleteItemOutput, nil
}
func (m *MockDynamoDB) GetItemWithContext(ctx aws.Context, input *dynamodb.GetItemInput, opts ...request.Option) (*dynamodb.GetItemOutput, error) {
return m.GetItemOutput, nil
}
func (m *MockDynamoDB) PutItemWithContext(ctx aws.Context, input *dynamodb.PutItemInput, opts ...request.Option) (*dynamodb.PutItemOutput, error) {
return m.PutItemOutput, nil
}
From aws_test.go
Sometimes it is useful for the test implementation to process the input argument into the output variable. Here we make a mock KMS client that "encrypts" the input via Base64, a deterministic process that is easy to test.
// MockKMS is a mock KMSAPI implementation
type MockKMS struct{}
func (m *MockKMS) DecryptWithContext(ctx aws.Context, input *kms.DecryptInput, opts ...request.Option) (*kms.DecryptOutput, error) {
s, _ := base64.StdEncoding.DecodeString(string(input.CiphertextBlob))
return &kms.DecryptOutput{
Plaintext: s,
}, nil
}
func (m *MockKMS) EncryptWithContext(ctx aws.Context, input *kms.EncryptInput, opts ...request.Option) (*kms.EncryptOutput, error) {
return &kms.EncryptOutput{
CiphertextBlob: []byte(base64.StdEncoding.EncodeToString(input.Plaintext)),
}, nil
}
From aws_test.go
Now in our test programs we can replace DynamoDB
, KMS
, etc. with our mock implementations. Our UserCreate
function calls DynamoDB.PutItemWithContext
and our mock client controls the output.
func TestUserCreate(t *testing.T) {
DynamoDB = &MockDynamoDB{
GetItemOutput: &dynamodb.GetItemOutput{},
}
KMS = &MockKMS{}
UUIDGen = func() uuid.UUID {
return uuid.Must(uuid.FromString("26f0dc9f-4483-4b65-8724-3d1598ff6d14"))
}
r, err := UserCreate(context.Background(), events.APIGatewayProxyRequest{
Body: `{"username": "test"}`,
})
assert.NoError(t, err)
assert.EqualValues(t,
events.APIGatewayProxyResponse{
Body: "{\n \"id\": \"26f0dc9f-4483-4b65-8724-3d1598ff6d14\",\n \"username\": \"test\"\n}\n",
Headers: map[string]string{
"Content-Type": "application/json",
},
StatusCode: 200,
},
r,
)
}
From user_test.go
The AWS SDK for Go offers a clear strategy for testing our code:
- Define an interface with the AWS SDK methods in use
- Use the AWS SDK clients as the interface implementation by default
- Build a mock client for the interface implementation for testing
We no longer have to worry about:
- Recording HTTP API request/response pairs
- Building a test API HTTP server
Go interfaces and the AWS SDK for Go make our software easy to build and test.