diff --git a/.github/workflows/code-health.yml b/.github/workflows/code-health.yml index 16ce8668cf..0d25304fcd 100644 --- a/.github/workflows/code-health.yml +++ b/.github/workflows/code-health.yml @@ -16,22 +16,30 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Set up Go - uses: actions/setup-go@v5 + - uses: actions/setup-go@v5 with: go-version-file: 'go.mod' + - name: Mock generation + run: make tools && mockery + - name: Check for uncommited files + run: | + export FILES=$(git ls-files -o -m --directory --exclude-standard --no-empty-directory) + export LINES=$(echo "$FILES" | awk 'NF' | wc -l) + if [ $LINES -ne 0 ]; then + echo "Detected files that need to be committed:" + echo "$FILES" | sed -e "s/^/ /" + echo "" + echo "Mock skeletons are not up-to-date, you may have forgotten to run mockery before committing your changes." + exit 1 + fi - name: Build run: make build unit-test: needs: build runs-on: ubuntu-latest - permissions: - pull-requests: write steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Set up Go - uses: actions/setup-go@v5 + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 with: go-version-file: 'go.mod' - name: Unit Test @@ -70,14 +78,11 @@ jobs: uses: golangci/golangci-lint-action@v3.7.0 with: version: v1.55.0 - args: --timeout 10m website-lint: runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Install Go - uses: actions/setup-go@v5 + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 with: go-version-file: 'go.mod' - name: website lint diff --git a/.golangci.yml b/.golangci.yml index 9ba6881ade..ba5397f903 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -145,9 +145,8 @@ issues: text: "^hugeParam: req is heavy" run: + timeout: 10m tests: true build-tags: - integration - skip-dirs: - - internal/mocks modules-download-mode: readonly diff --git a/.mockery.yaml b/.mockery.yaml new file mode 100644 index 0000000000..edf68dcb9e --- /dev/null +++ b/.mockery.yaml @@ -0,0 +1,11 @@ +with-expecter: false +disable-version-string: true +dir: internal/testutil/mocksvc +outpkg: mocksvc +filename: "{{ .InterfaceName | snakecase }}.go" +mockname: "{{.InterfaceName}}" + +packages: + ? github.com/mongodb/terraform-provider-mongodbatlas/internal/service/searchdeployment + : interfaces: + DeploymentService: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 44eafb6177..f0752063b3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -303,6 +303,8 @@ To do this you can: - Helper methods must have their own tests, e.g. `common_advanced_cluster_test.go` has tests for `common_advanced_cluster.go`. - `internal/testutils/acc` contains helper test methods for Acceptance and Migration tests. - Tests that need the provider binary like End-to-End tests don’t belong to the source code packages and go in `test/e2e`. +- [Testify Mock](https://pkg.go.dev/github.com/stretchr/testify/mock) and [Mockery](https://github.com/vektra/mockery) are used for test doubles in unit tests. Mocked interfaces are generated in folder `internal/testutil/mocksvc`. + ### Creating New Resource and Data Sources diff --git a/GNUmakefile b/GNUmakefile index 23830ba9dd..251fa83706 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -14,6 +14,7 @@ VERSION=$(GITTAG:v%=%) LINKER_FLAGS=-s -w -X 'github.com/mongodb/terraform-provider-mongodbatlas/version.ProviderVersion=${VERSION}' GOLANGCI_VERSION=v1.55.0 +MOCKERY_VERSION=v2.38.0 export PATH := $(shell go env GOPATH)/bin:$(PATH) export SHELL := env PATH=$(PATH) /bin/bash @@ -74,6 +75,7 @@ tools: ## Install dev tools go install github.com/terraform-linters/tflint@v0.49.0 go install github.com/rhysd/actionlint/cmd/actionlint@latest go install golang.org/x/tools/go/analysis/passes/fieldalignment/cmd/fieldalignment@latest + go install github.com/vektra/mockery/v2@$(MOCKERY_VERSION) curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(shell go env GOPATH)/bin $(GOLANGCI_VERSION) .PHONY: check diff --git a/internal/service/searchdeployment/state_transition_search_deployment_test.go b/internal/service/searchdeployment/state_transition_search_deployment_test.go index 4de26dc2bd..9541b046b6 100644 --- a/internal/service/searchdeployment/state_transition_search_deployment_test.go +++ b/internal/service/searchdeployment/state_transition_search_deployment_test.go @@ -3,139 +3,111 @@ package searchdeployment_test import ( "context" "errors" - "log" "net/http" - "reflect" "testing" "time" + "github.com/mongodb/terraform-provider-mongodbatlas/internal/common/conversion" "github.com/mongodb/terraform-provider-mongodbatlas/internal/common/retrystrategy" "github.com/mongodb/terraform-provider-mongodbatlas/internal/service/searchdeployment" + "github.com/mongodb/terraform-provider-mongodbatlas/internal/testutil/mocksvc" + "github.com/stretchr/testify/assert" "go.mongodb.org/atlas-sdk/v20231115002/admin" ) -type stateTransitionTestCase struct { - expectedResult *admin.ApiSearchDeploymentResponse - name string - mockResponses []SearchDeploymentResponse - expectedError bool +var ( + updating = "UPDATING" + idle = "IDLE" + unknown = "" + sc400 = conversion.IntPtr(400) + sc500 = conversion.IntPtr(500) + sc503 = conversion.IntPtr(503) +) + +type testCase struct { + expectedState *string + name string + mockResponses []response + expectedError bool } func TestSearchDeploymentStateTransition(t *testing.T) { - testCases := []stateTransitionTestCase{ + testCases := []testCase{ { name: "Successful transition to IDLE", - mockResponses: []SearchDeploymentResponse{ - { - DeploymentResp: responseWithState("UPDATING"), - }, - { - DeploymentResp: responseWithState("IDLE"), - }, + mockResponses: []response{ + {state: &updating}, + {state: &idle}, }, - expectedResult: responseWithState("IDLE"), - expectedError: false, + expectedState: &idle, + expectedError: false, }, { name: "Successful transition to IDLE with 503 error in between", - mockResponses: []SearchDeploymentResponse{ - { - DeploymentResp: responseWithState("UPDATING"), - }, - { - DeploymentResp: nil, - HTTPResponse: &http.Response{StatusCode: 503}, - Err: errors.New("Service Unavailable"), - }, - { - DeploymentResp: responseWithState("IDLE"), - }, + mockResponses: []response{ + {state: &updating}, + {statusCode: sc503, err: errors.New("Service Unavailable")}, + {state: &idle}, }, - expectedResult: responseWithState("IDLE"), - expectedError: false, + expectedState: &idle, + expectedError: false, }, { name: "Error when transitioning to an unknown state", - mockResponses: []SearchDeploymentResponse{ - { - DeploymentResp: responseWithState("UPDATING"), - }, - { - DeploymentResp: responseWithState(""), - }, + mockResponses: []response{ + {state: &updating}, + {state: &unknown}, }, - expectedResult: nil, - expectedError: true, + expectedState: nil, + expectedError: true, }, { name: "Error when API responds with error", - mockResponses: []SearchDeploymentResponse{ - { - DeploymentResp: nil, - HTTPResponse: &http.Response{StatusCode: 500}, - Err: errors.New("Internal server error"), - }, + mockResponses: []response{ + {statusCode: sc500, err: errors.New("Internal server error")}, }, - expectedResult: nil, - expectedError: true, + expectedState: nil, + expectedError: true, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - mockService := MockSearchDeploymentService{ - MockResponses: tc.mockResponses, - } - - resp, err := searchdeployment.WaitSearchNodeStateTransition(context.Background(), dummyProjectID, "Cluster0", &mockService, testTimeoutConfig) - - if (err != nil) != tc.expectedError { - t.Errorf("Case %s: Received unexpected error: %v", tc.name, err) - } - - if !reflect.DeepEqual(tc.expectedResult, resp) { - t.Errorf("Case %s: Response did not match expected output", tc.name) + svc := mocksvc.NewDeploymentService(t) + ctx := context.Background() + for _, resp := range tc.mockResponses { + svc.On("GetAtlasSearchDeployment", ctx, dummyProjectID, clusterName).Return(resp.get()...).Once() } + resp, err := searchdeployment.WaitSearchNodeStateTransition(ctx, dummyProjectID, "Cluster0", svc, testTimeoutConfig) + assert.Equal(t, tc.expectedError, err != nil) + assert.Equal(t, responseWithState(tc.expectedState), resp) + svc.AssertExpectations(t) }) } } func TestSearchDeploymentStateTransitionForDelete(t *testing.T) { - testCases := []stateTransitionTestCase{ + testCases := []testCase{ { name: "Regular transition to DELETED", - mockResponses: []SearchDeploymentResponse{ - { - DeploymentResp: responseWithState("UPDATING"), - }, - { - DeploymentResp: nil, - HTTPResponse: &http.Response{StatusCode: 400}, - Err: errors.New(searchdeployment.SearchDeploymentDoesNotExistsError), - }, + mockResponses: []response{ + {state: &updating}, + {statusCode: sc400, err: errors.New(searchdeployment.SearchDeploymentDoesNotExistsError)}, }, expectedError: false, }, { name: "Error when API responds with error", - mockResponses: []SearchDeploymentResponse{ - { - DeploymentResp: nil, - HTTPResponse: &http.Response{StatusCode: 500}, - Err: errors.New("Internal server error"), - }, + mockResponses: []response{ + {statusCode: sc500, err: errors.New("Internal server error")}, }, expectedError: true, }, { name: "Failed delete when responding with unknown state", - mockResponses: []SearchDeploymentResponse{ - { - DeploymentResp: responseWithState("UPDATING"), - }, - { - DeploymentResp: responseWithState(""), - }, + mockResponses: []response{ + {state: &updating}, + {state: &unknown}, }, expectedError: true, }, @@ -143,15 +115,14 @@ func TestSearchDeploymentStateTransitionForDelete(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - mockService := MockSearchDeploymentService{ - MockResponses: tc.mockResponses, - } - - err := searchdeployment.WaitSearchNodeDelete(context.Background(), dummyProjectID, clusterName, &mockService, testTimeoutConfig) - - if (err != nil) != tc.expectedError { - t.Errorf("Case %s: Received unexpected error: %v", tc.name, err) + svc := mocksvc.NewDeploymentService(t) + ctx := context.Background() + for _, resp := range tc.mockResponses { + svc.On("GetAtlasSearchDeployment", ctx, dummyProjectID, clusterName).Return(resp.get()...).Once() } + err := searchdeployment.WaitSearchNodeDelete(ctx, dummyProjectID, clusterName, svc, testTimeoutConfig) + assert.Equal(t, tc.expectedError, err != nil) + svc.AssertExpectations(t) }) } } @@ -162,7 +133,10 @@ var testTimeoutConfig = retrystrategy.TimeConfig{ Delay: 0, } -func responseWithState(state string) *admin.ApiSearchDeploymentResponse { +func responseWithState(state *string) *admin.ApiSearchDeploymentResponse { + if state == nil { + return nil + } return &admin.ApiSearchDeploymentResponse{ GroupId: admin.PtrString(dummyProjectID), Id: admin.PtrString(dummyDeploymentID), @@ -172,26 +146,20 @@ func responseWithState(state string) *admin.ApiSearchDeploymentResponse { NodeCount: nodeCount, }, }, - StateName: admin.PtrString(state), + StateName: state, } } -type MockSearchDeploymentService struct { - MockResponses []SearchDeploymentResponse - index int +type response struct { + state *string + statusCode *int + err error } -func (a *MockSearchDeploymentService) GetAtlasSearchDeployment(ctx context.Context, groupID, clusterName string) (*admin.ApiSearchDeploymentResponse, *http.Response, error) { - if a.index >= len(a.MockResponses) { - log.Fatal(errors.New("no more mocked responses available")) +func (r *response) get() []interface{} { + var httpResp *http.Response + if r.statusCode != nil { + httpResp = &http.Response{StatusCode: *r.statusCode} } - resp := a.MockResponses[a.index] - a.index++ - return resp.DeploymentResp, resp.HTTPResponse, resp.Err -} - -type SearchDeploymentResponse struct { - DeploymentResp *admin.ApiSearchDeploymentResponse - HTTPResponse *http.Response - Err error + return []interface{}{responseWithState(r.state), httpResp, r.err} } diff --git a/internal/testutil/mocksvc/deployment_service.go b/internal/testutil/mocksvc/deployment_service.go new file mode 100644 index 0000000000..62c96ad9bd --- /dev/null +++ b/internal/testutil/mocksvc/deployment_service.go @@ -0,0 +1,71 @@ +// Code generated by mockery. DO NOT EDIT. + +package mocksvc + +import ( + context "context" + + admin "go.mongodb.org/atlas-sdk/v20231115002/admin" + + http "net/http" + + mock "github.com/stretchr/testify/mock" +) + +// DeploymentService is an autogenerated mock type for the DeploymentService type +type DeploymentService struct { + mock.Mock +} + +// GetAtlasSearchDeployment provides a mock function with given fields: ctx, groupID, clusterName +func (_m *DeploymentService) GetAtlasSearchDeployment(ctx context.Context, groupID string, clusterName string) (*admin.ApiSearchDeploymentResponse, *http.Response, error) { + ret := _m.Called(ctx, groupID, clusterName) + + if len(ret) == 0 { + panic("no return value specified for GetAtlasSearchDeployment") + } + + var r0 *admin.ApiSearchDeploymentResponse + var r1 *http.Response + var r2 error + if rf, ok := ret.Get(0).(func(context.Context, string, string) (*admin.ApiSearchDeploymentResponse, *http.Response, error)); ok { + return rf(ctx, groupID, clusterName) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string) *admin.ApiSearchDeploymentResponse); ok { + r0 = rf(ctx, groupID, clusterName) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*admin.ApiSearchDeploymentResponse) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string) *http.Response); ok { + r1 = rf(ctx, groupID, clusterName) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*http.Response) + } + } + + if rf, ok := ret.Get(2).(func(context.Context, string, string) error); ok { + r2 = rf(ctx, groupID, clusterName) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// NewDeploymentService creates a new instance of DeploymentService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewDeploymentService(t interface { + mock.TestingT + Cleanup(func()) +}) *DeploymentService { + mock := &DeploymentService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +}