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