Skip to content

Commit 3a98650

Browse files
Unit Testing Framework (#25)
Summary: Added unit testing framework (and code coverage). Files Added: - mocks/aws-sdk-go/sagemaker/ApplicationAutoScalingAPI.go - Contains unit testing api mocks. - pkg/testutil/test_suite_config.go - Defines structures related to the unit testing test suite. - pkg/testutil/test_suite_runner.go - Contains main workflow functions for running unit tests. - pkg/testutil/util.go - Contains helper functions used in test_suite_runner.go. - scripts/install-mockery.sh - A script that installs the mockery CLI tool that is used to build Go mocks for our interfaces to use in unit testing. Files Edited: - Makefile - Defined unit testing, mock, and unit testing coverage related testing calls.
1 parent 8ed05c1 commit 3a98650

File tree

8 files changed

+1419
-4
lines changed

8 files changed

+1419
-4
lines changed

Makefile

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ SHELL := /bin/bash # Use bash syntax
33
# Set up variables
44
GO111MODULE=on
55

6+
AWS_SDK_GO_VERSION="$(shell echo $(shell go list -m -f '{{.Version}}' github.com/aws/aws-sdk-go))"
7+
AWS_SDK_GO_VERSIONED_PATH="$(shell echo github.com/aws/aws-sdk-go@$(AWS_SDK_GO_VERSION))"
8+
APPLICATIONAUTOSCALING_API_PATH="$(shell echo $(shell go env GOPATH))/pkg/mod/$(AWS_SDK_GO_VERSIONED_PATH)/service/applicationautoscaling/applicationautoscalingiface"
9+
SERVICE_CONTROLLER_SRC_PATH="$(shell pwd)"
10+
611
# Build ldflags
712
VERSION ?= "v0.0.0"
813
GITCOMMIT=$(shell git rev-parse HEAD)
@@ -11,13 +16,32 @@ GO_LDFLAGS=-ldflags "-X main.version=$(VERSION) \
1116
-X main.buildHash=$(GITCOMMIT) \
1217
-X main.buildDate=$(BUILDDATE)"
1318

14-
.PHONY: all test
19+
.PHONY: all test clean-mocks mocks
1520

1621
all: test
1722

1823
test: ## Run code tests
1924
go test -v ./...
2025

26+
test-cover: | mocks ## Run code tests with resources coverage
27+
go test -coverpkg=./pkg/resource/... -covermode=count -coverprofile=coverage.out ./...
28+
go tool cover -func=coverage.out
29+
30+
clean-mocks: ## Remove mocks directory
31+
rm -r mocks
32+
33+
install-mockery:
34+
@test/scripts/install-mockery.sh
35+
36+
mocks: install-mockery ## Build mocks
37+
go get -d $(AWS_SDK_GO_VERSIONED_PATH)
38+
@echo "building mocks for $(APPLICATIONAUTOSCALING_API_PATH) ... "
39+
@pushd $(APPLICATIONAUTOSCALING_API_PATH) 1>/dev/null; \
40+
$(SERVICE_CONTROLLER_SRC_PATH)/bin/mockery --all --dir=. --output=$(SERVICE_CONTROLLER_SRC_PATH)/mocks/aws-sdk-go/applicationautoscaling/ ; \
41+
popd 1>/dev/null;
42+
@echo "ok."
43+
44+
2145
help: ## Show this help.
2246
@grep -F -h "##" $(MAKEFILE_LIST) | grep -F -v grep | sed -e 's/\\$$//' \
2347
| awk -F'[:#]' '{print $$1 = sprintf("%-30s", $$1), $$4}'

go.mod

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,12 @@ go 1.14
55
require (
66
github.com/aws-controllers-k8s/runtime v0.2.1
77
github.com/aws/aws-sdk-go v1.37.4
8+
github.com/ghodss/yaml v1.0.0
89
github.com/go-logr/logr v0.1.0
10+
github.com/google/go-cmp v0.3.1
911
github.com/spf13/pflag v1.0.5
12+
github.com/stretchr/testify v1.5.1
13+
go.uber.org/zap v1.10.0
1014
k8s.io/api v0.18.2
1115
k8s.io/apimachinery v0.18.6
1216
k8s.io/client-go v0.18.2

go.sum

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5Kwzbycv
7070
github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
7171
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
7272
github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
73+
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
7374
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
7475
github.com/globalsign/mgo v0.0.0-20180905125535-1ca0a4f7cbcb/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q=
7576
github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q=
@@ -310,7 +311,6 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
310311
golang.org/x/crypto v0.0.0-20190320223903-b7391e95e576/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
311312
golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
312313
golang.org/x/crypto v0.0.0-20190617133340-57b3e21c3d56/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
313-
golang.org/x/crypto v0.0.0-20200220183623-bac4c82f6975 h1:/Tl7pH94bvbAAHBdZJT947M/+gp0+CqQXDtMRC0fseo=
314314
golang.org/x/crypto v0.0.0-20200220183623-bac4c82f6975/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
315315
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
316316
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
@@ -363,14 +363,12 @@ golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7w
363363
golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
364364
golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
365365
golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
366-
golang.org/x/sys v0.0.0-20191022100944-742c48ecaeb7 h1:HmbHVPwrPEKPGLAcHSrMe6+hqSUlvZU0rab6x5EXfGU=
367366
golang.org/x/sys v0.0.0-20191022100944-742c48ecaeb7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
368367
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f h1:+Nyd8tzPX9R7BWHguqsrbFdRx3WQ/1ib8I44HXV5yTA=
369368
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
370369
golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
371370
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
372371
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
373-
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
374372
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
375373
golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
376374
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=

pkg/testutil/test_suite_config.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License"). You may
4+
// not use this file except in compliance with the License. A copy of the
5+
// License is located at
6+
//
7+
// http://aws.amazon.com/apache2.0/
8+
//
9+
// or in the "license" file accompanying this file. This file is distributed
10+
// on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
11+
// express or implied. See the License for the specific language governing
12+
// permissions and limitations under the License.
13+
14+
package testutil
15+
16+
// TestSuite represents instructions to run unit tests using test fixtures and mock service apis
17+
type TestSuite struct {
18+
Tests []TestConfig `json:"tests"`
19+
}
20+
21+
// TestConfig represents declarative unit test
22+
type TestConfig struct {
23+
Name string `json:"name"`
24+
Description string `json:"description"`
25+
Scenarios []TestScenario `json:"scenarios"`
26+
}
27+
28+
// TestScenario represents declarative test scenario details
29+
type TestScenario struct {
30+
Name string `json:"name"`
31+
Description string `json:"description"`
32+
// Fixture lets you specify test scenario given input fixtures
33+
Fixture Fixture `json:"given"`
34+
// UnitUnderTest lets you specify the unit to test
35+
// For example resource manager API: ReadOne, Create, Update, Delete
36+
UnitUnderTest string `json:"invoke"`
37+
// Expect lets you specify test scenario expected outcome fixtures
38+
Expect Expect `json:"expect"`
39+
}
40+
41+
// Fixture represents test scenario fixture to load from file paths
42+
type Fixture struct {
43+
// DesiredState lets you specify fixture path to load the desired state fixture
44+
DesiredState string `json:"desired_state"`
45+
// LatestState lets you specify fixture path to load the current state fixture
46+
LatestState string `json:"latest_state"`
47+
// ServiceAPIs lets you specify fixture path to mock service sdk api response
48+
ServiceAPIs []ServiceAPI `json:"svc_api"`
49+
}
50+
51+
// ServiceAPI represents details about the the service sdk api and fixture path to mock its response
52+
type ServiceAPI struct {
53+
Operation string `json:"operation"`
54+
Output string `json:"output_fixture,omitempty"`
55+
ServiceAPIError *ServiceAPIError `json:"error,omitempty"`
56+
}
57+
58+
// ServiceAPIError contains the specification for the error of the mock API response
59+
type ServiceAPIError struct {
60+
// Code here is usually the type of fault/error, not the HTTP status code
61+
Code string `json:"code"`
62+
Message string `json:"message"`
63+
}
64+
65+
// Expect represents test scenario expected outcome fixture to load from file path
66+
type Expect struct {
67+
LatestState string `json:"latest_state"`
68+
// Error is a string matching the message of the expected error returned from the ResourceManager operation.
69+
// Possible errors can be found in runtime/pkg/errors/error.go
70+
Error string `json:"error"`
71+
}

pkg/testutil/test_suite_runner.go

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License"). You may
4+
// not use this file except in compliance with the License. A copy of the
5+
// License is located at
6+
//
7+
// http://aws.amazon.com/apache2.0/
8+
//
9+
// or in the "license" file accompanying this file. This file is distributed
10+
// on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
11+
// express or implied. See the License for the specific language governing
12+
// permissions and limitations under the License.
13+
14+
package testutil
15+
16+
import (
17+
"context"
18+
"errors"
19+
"fmt"
20+
mocksvcsdkapi "github.com/aws-controllers-k8s/applicationautoscaling-controller/test/mocks/aws-sdk-go/applicationautoscaling"
21+
acktypes "github.com/aws-controllers-k8s/runtime/pkg/types"
22+
"github.com/stretchr/testify/assert"
23+
"github.com/stretchr/testify/mock"
24+
"path/filepath"
25+
"strings"
26+
"testing"
27+
)
28+
29+
var RecoverPanicString = "\t--- PANIC ON ERROR:"
30+
31+
// TestSuiteRunner runs given test suite config with the help of delegate supplied to it
32+
type TestSuiteRunner struct {
33+
TestSuite *TestSuite
34+
Delegate TestRunnerDelegate
35+
}
36+
37+
// fixtureContext is runtime context for test scenario given fixture.
38+
type fixtureContext struct {
39+
desired acktypes.AWSResource
40+
latest acktypes.AWSResource
41+
mocksdkapi *mocksvcsdkapi.ApplicationAutoScalingAPI
42+
resourceManager acktypes.AWSResourceManager
43+
}
44+
45+
//TODO: remove if no longer used
46+
// expectContext is runtime context for test scenario expectation fixture.
47+
type expectContext struct {
48+
latest acktypes.AWSResource
49+
err error
50+
}
51+
52+
// TestRunnerDelegate provides interface for custom resource tests to implement.
53+
// TestSuiteRunner depends on it to run tests for custom resource.
54+
type TestRunnerDelegate interface {
55+
ResourceDescriptor() acktypes.AWSResourceDescriptor
56+
Equal(desired acktypes.AWSResource, latest acktypes.AWSResource) bool // remove it when ResourceDescriptor.Delta() is available
57+
YamlEqual(expected string, actual acktypes.AWSResource) bool // new
58+
ResourceManager(*mocksvcsdkapi.ApplicationAutoScalingAPI) acktypes.AWSResourceManager
59+
EmptyServiceAPIOutput(apiName string) (interface{}, error)
60+
GoTestRunner() *testing.T
61+
}
62+
63+
// RunTests runs the tests from the test suite
64+
func (runner *TestSuiteRunner) RunTests() {
65+
if runner.TestSuite == nil || runner.Delegate == nil {
66+
panic(errors.New("failed to run test suite"))
67+
}
68+
69+
for _, test := range runner.TestSuite.Tests {
70+
fmt.Printf("Starting test: %s\n", test.Name)
71+
for _, scenario := range test.Scenarios {
72+
runner.startScenario(scenario)
73+
}
74+
fmt.Printf("Test: %s completed.\n", test.Name)
75+
}
76+
}
77+
78+
// Wrapper for running test scenarios that catches any panics thrown.
79+
func (runner *TestSuiteRunner) startScenario (scenario TestScenario) {
80+
t := runner.Delegate.GoTestRunner()
81+
t.Run(scenario.Name, func(t *testing.T) {
82+
defer func() {
83+
if r := recover(); r != nil {
84+
fmt.Println(RecoverPanicString, r)
85+
t.Fail()
86+
}
87+
}()
88+
fmt.Printf("Running test scenario: %s\n", scenario.Name)
89+
fixtureCxt := runner.setupFixtureContext(&scenario.Fixture)
90+
runner.runTestScenario(t, scenario.Name, fixtureCxt, scenario.UnitUnderTest, &scenario.Expect)
91+
})
92+
}
93+
94+
// runTestScenario runs given test scenario which is expressed as: given fixture context, unit to test, expected fixture context.
95+
func (runner *TestSuiteRunner) runTestScenario(t *testing.T, scenarioName string, fixtureCxt *fixtureContext, unitUnderTest string, expectation *Expect) {
96+
rm := fixtureCxt.resourceManager
97+
assert := assert.New(t)
98+
99+
var actual acktypes.AWSResource = nil
100+
var err error = nil
101+
switch unitUnderTest {
102+
case "ReadOne":
103+
actual, err = rm.ReadOne(context.Background(), fixtureCxt.desired)
104+
case "Create":
105+
actual, err = rm.Create(context.Background(), fixtureCxt.desired)
106+
case "Update":
107+
delta := runner.Delegate.ResourceDescriptor().Delta(fixtureCxt.desired, fixtureCxt.latest)
108+
actual, err = rm.Update(context.Background(), fixtureCxt.desired, fixtureCxt.latest, delta)
109+
case "Delete":
110+
err = rm.Delete(context.Background(), fixtureCxt.desired)
111+
default:
112+
panic(errors.New(fmt.Sprintf("unit under test: %s not supported", unitUnderTest)))
113+
}
114+
runner.assertExpectations(assert, expectation, actual, err)
115+
}
116+
117+
/* assertExpectations validates the actual outcome against the expected outcome.
118+
There are two components to the expected outcome, corresponding to the return values of the resource manager's CRUD operation:
119+
1) the actual return value of type AWSResource ("expect.latest_state" in test_suite.yaml)
120+
2) the error ("expect.error" in test_suite.yaml)
121+
With each of these components, there are three possibilities in test_suite.yaml, which are interpreted as follows:
122+
1) the key does not exist, or was provided with no value: no explicit expectations, don't assert anything
123+
2) the key was provided with value "nil": explicit expectation; assert that the error or return value is nil
124+
3) the key was provided with value other than "nil": explicit expectation; assert that the value matches the
125+
expected value
126+
However, if neither expect.latest_state nor error are provided, assertExpectations will fail the test case.
127+
*/
128+
func (runner *TestSuiteRunner) assertExpectations(assert *assert.Assertions, expectation *Expect, actual acktypes.AWSResource, err error) {
129+
if expectation.LatestState == "" && expectation.Error == "" {
130+
fmt.Println("Invalid test case: no expectation given for either latest_state or error")
131+
assert.True(false)
132+
return
133+
}
134+
135+
// expectation exists for at least one of LatestState and Error; assert results independently
136+
if expectation.LatestState == "nil" {
137+
assert.Nil(actual)
138+
} else if expectation.LatestState != "" {
139+
expectedLatest := runner.loadAWSResource(expectation.LatestState)
140+
assert.NotNil(actual)
141+
142+
delta := runner.Delegate.ResourceDescriptor().Delta(expectedLatest, actual)
143+
assert.Equal(0, len(delta.Differences))
144+
if len(delta.Differences) > 0 {
145+
fmt.Println("Unexpected differences:")
146+
for _, difference := range delta.Differences {
147+
fmt.Printf("Path: %v, expected: %v, actual: %v\n", difference.Path, difference.A, difference.B)
148+
fmt.Printf("See expected differences below:\n")
149+
}
150+
}
151+
152+
// Check that the yaml files are equivalent.
153+
// This makes it easier to make changes to unit test cases.
154+
assert.True(runner.Delegate.YamlEqual(expectation.LatestState, actual))
155+
// Delta only contains `Spec` differences. Thus, we need Delegate.Equal to compare `Status`.
156+
assert.True(runner.Delegate.Equal(expectedLatest, actual))
157+
}
158+
159+
if expectation.Error == "nil" {
160+
assert.Nil(err)
161+
} else if expectation.Error != "" {
162+
expectedError := errors.New(expectation.Error)
163+
assert.NotNil(err)
164+
165+
assert.Equal(expectedError.Error(), err.Error())
166+
}
167+
}
168+
169+
// setupFixtureContext provides runtime context for test scenario given fixture.
170+
func (runner *TestSuiteRunner) setupFixtureContext(fixture *Fixture) *fixtureContext {
171+
if fixture == nil {
172+
return nil
173+
}
174+
var cxt = fixtureContext{}
175+
if fixture.DesiredState != "" {
176+
cxt.desired = runner.loadAWSResource(fixture.DesiredState)
177+
}
178+
if fixture.LatestState != "" {
179+
cxt.latest = runner.loadAWSResource(fixture.LatestState)
180+
}
181+
mocksdkapi := &mocksvcsdkapi.ApplicationAutoScalingAPI{}
182+
for _, serviceApi := range fixture.ServiceAPIs {
183+
if serviceApi.Operation != "" {
184+
185+
if serviceApi.ServiceAPIError != nil {
186+
mockError := CreateAWSError(*serviceApi.ServiceAPIError)
187+
mocksdkapi.On(serviceApi.Operation, mock.Anything, mock.Anything).Return(nil, mockError)
188+
} else if serviceApi.Operation != "" && serviceApi.Output != "" {
189+
var outputObj, err = runner.Delegate.EmptyServiceAPIOutput(serviceApi.Operation)
190+
apiOutputFixturePath := append([]string{"testdata"}, strings.Split(serviceApi.Output, "/")...)
191+
LoadFromFixture(filepath.Join(apiOutputFixturePath...), outputObj)
192+
mocksdkapi.On(serviceApi.Operation, mock.Anything, mock.Anything).Return(outputObj, nil)
193+
if err != nil {
194+
panic(err)
195+
}
196+
} else if serviceApi.ServiceAPIError == nil && serviceApi.Output == "" {
197+
// Default case for no defined output fixture or error.
198+
mocksdkapi.On(serviceApi.Operation, mock.Anything, mock.Anything).Return(nil, nil)
199+
}
200+
}
201+
}
202+
cxt.mocksdkapi = mocksdkapi
203+
cxt.resourceManager = runner.Delegate.ResourceManager(mocksdkapi)
204+
return &cxt
205+
}
206+
207+
// loadAWSResource loads AWSResource from the supplied fixture file.
208+
func (runner *TestSuiteRunner) loadAWSResource(resourceFixtureFilePath string) acktypes.AWSResource {
209+
if resourceFixtureFilePath == "" {
210+
panic(errors.New(fmt.Sprintf("resourceFixtureFilePath not specified")))
211+
}
212+
var rd = runner.Delegate.ResourceDescriptor()
213+
ro := rd.EmptyRuntimeObject()
214+
path := append([]string{"testdata"}, strings.Split(resourceFixtureFilePath, "/")...)
215+
LoadFromFixture(filepath.Join(path...), ro)
216+
return rd.ResourceFromRuntimeObject(ro)
217+
}

0 commit comments

Comments
 (0)