Testing sdkv2 with Interfaces

Test with interface Mock

We use the CloudFormation Stack counter example from here and enhance it with testing.

To achieve that we have to “inject” the client once as “real” aws client and once as mocked client. So the init concept does not work with this type of tests.

Architecture

To have a testable architecture we define an interface CounterInterface.

type CounterInterface interface {
	DescribeStacks(...)) (*cloudformation.DescribeStacksOutput, error)
}

All classes which have all functions defined in the interface implement it. In this case only DescribeStacks is needed. The CloudFormation client from AWS SDK for go surely has it. (and some more)

To create a test, I code a test-class which also has the function DescribeStacks.

type CounterInterfaceMock struct {
	DescribeStacksFunc func(...) (*cloudformation.DescribeStacksOutput, error)
}
classDiagram CounterInterface <|-- CloudformationClient : implements CounterInterface <|-- CounterInterfaceMock : implements class CounterInterface{ +DescribeStacks(ctx, params, optFunc...) } class CounterInterfaceMock{ +DescribeStacks(ctx, params, optFunc...) } class CloudformationClient{ +DescribeStacks(ctx, params, optFunc...) }

In the simple business logic part with AWS api calls, I do not create the AWS client in the function. I just pass the client as a parameter to the function:

func Count(client CounterInterface)

When testing - this client is a mocked client. In real life I pass an AWS CloudFormation api client.

Getting test input data

The development cycle will be faster without calling the AWS API all the time. You just fetch a real world DescribeCloudformation output with the AWS cli:

aws cloudformation describe-stacks

The output starts like:

{
    "Stacks": [
        {
            "StackId": "arn:aws:cloudformation:eu-central-1:012345678912:stack/amplify-trainerportal-dev-90853-authtrainerportal0a4ecb86-1DZBYAP6LDL7F/404f2090-0bd1-11eb-af8e-0a3f04c080ce",
            "StackName": "amplify-trainerportal-dev-90853-authtrainerportal0a4ecb86-1DZBYAP6LDL7F",
            "Parameters": [
                {
                    "ParameterKey": "authRoleArn",
                    "ParameterValue": "arn:aws:iam::795048271754:role/amplify-trainerportal-dev-90853-authRole"
                },
				...

I save the output in testdata/cloudformation.json. If a call the cfn api with “describeStacks” I would get a similar response back.

All the files in testdata will be ignored by the go compiler.

The test gives a mocked response, which is the content of the json file. The response is mocked.

Building “counter.go” skeleton

Now I create a counter.go file which at the beginning just defines the interface and a Count function which returns zero. This will give a failed test.

type CounterInterface interface {
	DescribeStacks(ctx context.Context, params *cloudformation.DescribeStacksInput, optFns ...func(*cloudformation.Options)) (*cloudformation.DescribeStacksOutput, error)

}

func Count(client CounterInterface) (int){
	
	return 0
}

This is the first step of the test-driven approach.

  1. write a test
  2. let test fail
  3. write code until test passes
  4. (refactor)

Test-Driven-Development

1 - Write a test

I want to get a little help from friends. So I use a mocking framework moq which generates a helper class:

//go:generate moq -out counter_moq_test.go . CounterInterface

With this remark the command go generate generates a file counter_moq_test.go which uses the interface definition to generate some helper classes. In the counter_moq_test.go there is also a generated documentation how to use the test!

With this help from the helper moq friend, I create the test file counter.test.go. Start the name of the test function with capital letters (remember?):

func TestCountStacks(t *testing.T) {
	expectedValues := 2;

The test function defines the mock “DescribeStacks”, which takes the saved json and return it:

var cloudformationOutput cloudformation.DescribeStacksOutput
// Read json file
data, err := ioutil.ReadFile("testdata/cloudformation.json")
...
json.Unmarshal(data, &cloudformationOutput);
return &cloudformationOutput,nil;

Here the strongly typed nature comes into play: There is a structure for the input of the DescribeStacks and also for the response output. These lines take the response event cloudformation.json and transforms it into a structure aka “Unmarshalling”.

This is the main “trick”: I can now create different json files for corner cases which I want to test. The real counter code “thinks” the cloudformation API itself has send the response.

Now the test function calls the - to be implemented - Counter:

computedValue := stackcount.Count(mockedCounterInterface)

To be able to do that the test imports the “stackcount” package.

Because the call passes the mocked client as an client, the Count function will call the mock client and will get the response defined in the testdata/cloudformation.json file.

Now comes the test assertion:

assert.Equal(t,expectedValues, computedValue)

The Count function should return “2”, because there are two stacks defined in the testdata/cloudformation.json .

2 - Let test FAIL

Now I start go test and get a fail, because at the moment the Count function returns 0.

go test
--- FAIL: TestCountStacks (0.00s)
    counter_test.go:38:
        	Error Trace:	counter_test.go:38
        	Error:      	Not equal:
        	            	expected: 2
        	            	actual  : 0
        	Test:       	TestCountStacks
FAIL
exit status 1
FAIL	letsbuild13	0.178s

3 - Write code until test passes

Then I write the code of the “Count” function until the test passes.

input := &cloudformation.DescribeStacksInput{}
resp, _ := client.DescribeStacks(context.TODO(), input)
count := len(resp.Stacks)
return count
go test
PASS
ok  	letsbuild13	0.133s

Because the response contains the Stacks structure as an array, Count just have to count the number of items (Stacks) in the array, to know how many CloudFormation stacks are deployed in the account.

If the test passes, that means the business functionality works. Now the main function is simple.

Main

At first main needs an aws.config class, which is used to initialize the real client.

With that config the cloudformation client is created.

	cfg, err := config.LoadDefaultConfig(config.WithRegion("eu-central-1"))
    if err != nil {
        panic("unable to load SDK config, " + err.Error())
	}
	
	client := cloudformation.NewFromConfig(cfg);

	count := stackcount.Count(client);

	fmt.Println("Counting CloudFormation Stacks: ",count)
go run main/main.go
Counting CloudFormation Stacks:  8