diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..ee8db85 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,27 @@ +name: Go package + +on: [push] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + go-version: [ '1.18', '1.19', '1.20' ] + steps: + - uses: actions/checkout@v3 + + - name: Set up Go ${{ matrix.go-version }} + uses: actions/setup-go@v3 + with: + go-version: ${{ matrix.go-version }} + cache: true + + - name: Test with Go ${{ matrix.go-version }} + run: go test -json > TestResults-${{ matrix.go-version }}.json + + - name: Upload Go test results for ${{ matrix.go-version }} + uses: actions/upload-artifact@v3 + with: + name: Go-results-${{ matrix.go-version }} + path: TestResults-${{ matrix.go-version }}.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f2eb195 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +.idea/ +.vscode/ + +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3d02986 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 ING + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f07fd5f --- /dev/null +++ b/Makefile @@ -0,0 +1,15 @@ +MAKEFLAGS := --no-print-directory --silent + +default: help + +help: + @echo "Please use 'make ' where is one of" + @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z\._-]+:.*?## / {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) + +t: test +test: fmt ## Run unit tests, alias: t + go test ./... -timeout=30s -parallel=8 + +fmt: ## Format go code + @go mod tidy + @go fmt ./... diff --git a/README.md b/README.md new file mode 100644 index 0000000..d000c81 --- /dev/null +++ b/README.md @@ -0,0 +1,122 @@ +# 🦁 Gin Test Utils + +[![Go package](https://github.com/ing-bank/gintestutil/actions/workflows/test.yaml/badge.svg)](https://github.com/ing-bank/gintestutil/actions/workflows/test.yaml) +![GitHub](https://img.shields.io/github/license/ing-bank/gintestutil) +![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/ing-bank/gintestutil) + +Small utility functions for testing Gin-related code. +Such as the creation of a gin context and wait groups with callbacks. + +## ⬇️ Installation + +`go get github.com/ing-bank/gintestutil` + +## 📋 Usage + +### Context creation + +```go +package main + +import ( + "net/http" + "testing" + "github.com/ing-bank/gintestutil" +) + +type TestObject struct { + Name string +} + +func TestProductController_Post_CreatesProducts(t *testing.T) { + // Arrange + context, writer := gintestutil.PrepareRequest(t, + gintestutil.WithJsonBody(t, TestObject{Name: "test"}), + gintestutil.WithMethod(http.MethodPost), + gintestutil.WithUrl("https://my-website.com"), + gintestutil.WithUrlParams(map[string]any{"category": "barbecue"}), + gintestutil.WithQueryParams(map[string]any{"force": "true"})) + + // [...] +} +``` + +### Response Assertions + +```go +package main + +import ( + "github.com/stretchr/testify/assert" + "net/http" + "testing" + "github.com/ing-bank/gintestutil" +) + +type TestObject struct { + Name string +} + +func TestProductController_Index_ReturnsAllProducts(t *testing.T) { + // Arrange + context, writer := gintestutil.PrepareRequest(t) + + // [...] + + // Assert + var actual []TestObject + if gintestutil.Response(t, &actual, http.StatusOK, writer.Result()) { + assert.Equal(t, []TestObject{}, actual) + } +} +``` + +### Hooks + +```go +package main + +import ( + "github.com/gin-gonic/gin" + "github.com/ing-bank/gintestutil" + "net/http" + "net/http/httptest" + "time" + "testing" +) + +func TestHelloController(t *testing.T) { + // Arrange + ginContext := gin.Default() + + // create expectation + expectation := gintestutil.ExpectCalled(t, ginContext, "/hello-world") + + ginContext.GET("/hello-world", func(context *gin.Context) { + context.Status(http.StatusOK) + }) + + // create webserver + ts := httptest.NewServer(ginContext) + + // Send request to webserver path + _, _ = http.Get(fmt.Sprintf("%s/hello-world", ts.URL)) + + // Wait for expectation in bounded time + if ok := gintestutil.EnsureCompletion(t, expectation); !ok { + // do something + } +} +``` + +## 🚀 Development + +1. Clone the repository +2. Run `make t` to run unit tests +3. Run `make fmt` to format code + +You can run `make` to see a list of useful commands. + +## 🔭 Future Plans + +Nothing here yet! diff --git a/await.go b/await.go new file mode 100644 index 0000000..f154952 --- /dev/null +++ b/await.go @@ -0,0 +1,61 @@ +package gintestutil + +import ( + "sync" + "testing" + "time" +) + +const ( + // defaultTimeout is the default value for EnsureCompletion's config + defaultTimeout = 30 * time.Second +) + +// EnsureOption allows various options to be supplied to EnsureCompletion +type EnsureOption func(*ensureConfig) + +// WithTimeout is used to set a timeout for EnsureCompletion +func WithTimeout(timeout time.Duration) EnsureOption { + return func(config *ensureConfig) { + config.timeout = timeout + } +} + +type ensureConfig struct { + timeout time.Duration +} + +// EnsureCompletion ensures that the waitgroup completes within a specified duration or else fails +func EnsureCompletion(t *testing.T, wg *sync.WaitGroup, options ...EnsureOption) bool { + t.Helper() + + if wg == nil { + t.Error("WithExpectation is nil") + return false + } + + config := &ensureConfig{ + timeout: defaultTimeout, + } + + for _, option := range options { + option(config) + } + + // Run waitgroup in goroutine + channel := make(chan struct{}) + go func() { + t.Helper() + defer close(channel) + wg.Wait() + }() + + // Select first response (waitgroup completion or time.After) + select { + case <-channel: + return true + case <-time.After(config.timeout): + t.Errorf("tasks did not complete within: %v", config.timeout) + return false + } +} diff --git a/await_test.go b/await_test.go new file mode 100644 index 0000000..d80de9d --- /dev/null +++ b/await_test.go @@ -0,0 +1,86 @@ +package gintestutil + +import ( + "github.com/stretchr/testify/assert" + "sync" + "testing" + "time" +) + +func TestEnsure_NilWaitGroupFails(t *testing.T) { + t.Parallel() + // Arrange + testingObject := new(testing.T) + + // Act + ok := EnsureCompletion(testingObject, nil) + + // Assert + assert.False(t, ok) +} + +func TestEnsure_NegativeDurationFails(t *testing.T) { + t.Parallel() + // Arrange + testingObject := new(testing.T) + + expectation := &sync.WaitGroup{} + expectation.Add(1) + go func() { + timeout := time.After(1 * time.Second) + + <-timeout + expectation.Done() + }() + + // Act + ok := EnsureCompletion(testingObject, expectation, WithTimeout(-1*time.Second)) + + // Assert + assert.True(t, testingObject.Failed()) + assert.False(t, ok) +} + +func TestEnsure_LongerTaskTimeThanEnsureDurationFails(t *testing.T) { + t.Parallel() + // Arrange + testingObject := new(testing.T) + + expectation := &sync.WaitGroup{} + expectation.Add(1) + go func() { + timeout := time.After(5 * time.Second) + + <-timeout + expectation.Done() + }() + + // Act + ok := EnsureCompletion(testingObject, expectation, WithTimeout(1*time.Second)) + + // Assert + assert.True(t, testingObject.Failed()) + assert.False(t, ok) +} + +func TestEnsure_ShortTaskTimeThanEnsureDurationSucceeds(t *testing.T) { + t.Parallel() + // Arrange + testingObject := new(testing.T) + + expectation := &sync.WaitGroup{} + expectation.Add(1) + go func() { + timeout := time.After(1 * time.Second) + + <-timeout + expectation.Done() + }() + + // Act + ok := EnsureCompletion(testingObject, expectation, WithTimeout(3*time.Second)) + + // Assert + assert.False(t, testingObject.Failed()) + assert.True(t, ok) +} diff --git a/context.go b/context.go new file mode 100644 index 0000000..be8a555 --- /dev/null +++ b/context.go @@ -0,0 +1,148 @@ +package gintestutil + +import ( + "bytes" + "encoding/json" + "fmt" + "github.com/gin-gonic/gin" + "io" + "net/http" + "net/http/httptest" + "net/url" +) + +// RequestOption are functions used in PrepareRequest to configure a request using the Functional Option pattern. +type RequestOption func(*requestConfig) + +// requestConfig is the internal config of PrepareRequest +type requestConfig struct { + method string + url string + body io.ReadCloser + urlParams map[string]any + queryParams map[string]any +} + +// applyQueryParams turn a map of string/[]string/maps into query parameter names as expected from the user. Check +// out the unit-tests for a more in-depth explanation. +func applyQueryParams(params map[string]any, query url.Values, keyPrefix string) { + for key, value := range params { + newKey := key + if keyPrefix != "" { + newKey = fmt.Sprintf("%s[%s]", keyPrefix, key) + } + + switch resultValue := value.(type) { + case string: + query.Add(newKey, resultValue) + case []string: + for _, valueString := range resultValue { + query.Add(newKey, valueString) + } + case fmt.Stringer: + query.Add(newKey, resultValue.String()) + + case map[string]any: + applyQueryParams(resultValue, query, newKey) + } + } +} + +// PrepareRequest Formulate a request with optional properties. This returns a *gin.Context which can be used +// in controller unit-tests. Use the returned *httptest.ResponseRecorder to perform assertions on the response. +func PrepareRequest(t TestingT, options ...RequestOption) (*gin.Context, *httptest.ResponseRecorder) { + t.Helper() + + config := &requestConfig{ + method: http.MethodGet, + url: "https://example.com", + } + + for _, option := range options { + option(config) + } + + writer := httptest.NewRecorder() + context, _ := gin.CreateTestContext(writer) + + var err error + if context.Request, err = http.NewRequest(config.method, config.url, config.body); err != nil { + t.Error(err) + return context, writer + } + + query := context.Request.URL.Query() + applyQueryParams(config.queryParams, query, "") + context.Request.URL.RawQuery = query.Encode() + + for key, value := range config.urlParams { + switch resultValue := value.(type) { + case string: + context.Params = append(context.Params, gin.Param{Key: key, Value: resultValue}) + + case []string: + for _, valueString := range resultValue { + context.Params = append(context.Params, gin.Param{Key: key, Value: valueString}) + } + + case fmt.Stringer: + context.Params = append(context.Params, gin.Param{Key: key, Value: resultValue.String()}) + } + } + + return context, writer +} + +// WithMethod specifies the method to use, defaults to Get +func WithMethod(method string) RequestOption { + return func(config *requestConfig) { + config.method = method + } +} + +// WithUrl specifies the url to use, defaults to https://example.com +func WithUrl(url string) RequestOption { + return func(config *requestConfig) { + config.url = url + } +} + +// WithJsonBody specifies the request body using json.Marshal, will report an error on marshal failure +func WithJsonBody(t TestingT, object any) RequestOption { + data, err := json.Marshal(object) + if err != nil { + t.Error(err) + } + + return func(config *requestConfig) { + config.body = io.NopCloser(bytes.NewBuffer(data)) + } +} + +// WithBody allows you to define a custom body for the request +func WithBody(data []byte) RequestOption { + return func(config *requestConfig) { + config.body = io.NopCloser(bytes.NewBuffer(data)) + } +} + +// WithUrlParams adds url parameters to the request. The value can be either: +// - string +// - []string +// - fmt.Stringer (anything with a String() method) +func WithUrlParams(parameters map[string]any) RequestOption { + return func(config *requestConfig) { + config.urlParams = parameters + } +} + +// WithQueryParams adds query parameters to the request. The value can be either: +// - string +// - []string +// - fmt.Stringer (anything with a String() method) +// - map[string]any +func WithQueryParams(queryParams map[string]any) RequestOption { + return func(config *requestConfig) { + config.queryParams = queryParams + } +} diff --git a/context_test.go b/context_test.go new file mode 100644 index 0000000..e30509d --- /dev/null +++ b/context_test.go @@ -0,0 +1,266 @@ +package gintestutil + +import ( + "encoding/json" + "errors" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "io" + "net/http" + "net/url" + "testing" +) + +type testStringer struct { + input string +} + +func (s testStringer) String() string { + return s.input +} + +func TestPrepareRequest_CreatesExpectedContext(t *testing.T) { + t.Parallel() + tests := map[string]struct { + options []RequestOption + + expectedBody string + expectedUrl string + expectedMethod string + expectedParams gin.Params + expectedError error + }{ + "empty request": { + options: []RequestOption{}, + + expectedMethod: http.MethodGet, + expectedUrl: "https://example.com", + }, + "method post": { + options: []RequestOption{WithMethod(http.MethodPost)}, + + expectedMethod: http.MethodPost, + expectedUrl: "https://example.com", + }, + "with url params": { + options: []RequestOption{ + WithUrlParams( + map[string]any{ + "one": testStringer{input: "two"}, + "three": []string{"four", "five"}, + }), + }, + + expectedMethod: http.MethodGet, + expectedUrl: "https://example.com", + expectedParams: []gin.Param{ + { + Key: "one", + Value: "two", + }, + { + Key: "three", + Value: "four", + }, + { + Key: "three", + Value: "five", + }, + }, + }, + "with query params": { + options: []RequestOption{ + WithUrl("https://maarten.dev"), + WithQueryParams(map[string]any{ + "one": testStringer{input: "two"}, + "three": []string{"four", "five"}, + }), + }, + + expectedMethod: http.MethodGet, + expectedUrl: "https://maarten.dev?one=two&three=four&three=five", + }, + "maarten.dev url": { + options: []RequestOption{WithUrl("https://maarten.dev")}, + + expectedUrl: "https://maarten.dev", + expectedMethod: http.MethodGet, + }, + "json body": { + options: []RequestOption{WithJsonBody(t, map[string]any{"one": "two", "three": 4})}, + + expectedMethod: http.MethodGet, + expectedUrl: "https://example.com", + expectedBody: `{"one": "two", "three": 4}`, + }, + "raw body": { + options: []RequestOption{WithBody([]byte("a b c"))}, + + expectedMethod: http.MethodGet, + expectedUrl: "https://example.com", + expectedBody: "a b c", + }, + "expected error on nonsensical request": { + options: []RequestOption{WithUrl("://://::::///::::")}, + + expectedError: &url.Error{Op: "parse", URL: "://://::::///::::", Err: errors.New("missing protocol scheme")}, + }, + } + + for name, testData := range tests { + testData := testData + t.Run(name, func(t *testing.T) { + t.Parallel() + // Arrange + mockT := new(mockT) + + // Act + context, writer := PrepareRequest(mockT, testData.options...) + + // Assert + assert.NotNil(t, writer) + + if testData.expectedError != nil { + assert.Equal(t, testData.expectedError, mockT.ErrorCalls[0]) + return + } + + assert.Len(t, mockT.ErrorCalls, 0) + + assert.Equal(t, testData.expectedMethod, context.Request.Method) + assert.Equal(t, testData.expectedUrl, context.Request.URL.String()) + assert.ElementsMatch(t, testData.expectedParams, context.Params) + + if testData.expectedBody != "" { + if body, err := io.ReadAll(context.Request.Body); err != nil { + assert.Equal(t, []byte(testData.expectedBody), body) + } + } + }) + } +} + +func TestWithJsonBody_CallsErrorOnFaultyJson(t *testing.T) { + t.Parallel() + // Arrange + mock := &mockT{} + + input := map[bool]string{ + true: "A boolean map is not a thing in json", + false: "so it won't work :-)", + } + + // Act + _ = WithJsonBody(mock, input) + + // Assert + if assert.Len(t, mock.ErrorCalls, 1) { + assert.IsType(t, mock.ErrorCalls[0], &json.UnsupportedTypeError{}) + } +} + +func TestApplyQueryParams_SetsExpectedValues(t *testing.T) { + t.Parallel() + tests := map[string]struct { + input map[string]any + expected url.Values + }{ + "empty": { + input: map[string]any{}, + expected: map[string][]string{}, + }, + "simple": { + input: map[string]any{ + "a": "b", + "c": "d", + }, + expected: map[string][]string{ + "a": {"b"}, + "c": {"d"}, + }, + }, + "multi": { + input: map[string]any{ + "a": []string{"a", "b"}, + "c": []string{"c", "d"}, + }, + expected: map[string][]string{ + "a": {"a", "b"}, + "c": {"c", "d"}, + }, + }, + "level 1": { + input: map[string]any{ + "a": map[string]any{"aa": "bb"}, + "c": map[string]any{"cc": "dd"}, + }, + expected: map[string][]string{ + "a[aa]": {"bb"}, + "c[cc]": {"dd"}, + }, + }, + "level 2": { + input: map[string]any{ + "a": map[string]any{ + "aa": map[string]any{ + "aaa": "bbb", + }, + }, + "c": map[string]any{ + "cc": map[string]any{ + "ccc": "ddd", + }, + }, + }, + expected: map[string][]string{ + "a[aa][aaa]": {"bbb"}, + "c[cc][ccc]": {"ddd"}, + }, + }, + "level 6m ": { + input: map[string]any{ + "a": map[string]any{ + "aa": map[string]any{ + "aaa": map[string]any{ + "aaaa": map[string]any{ + "aaaaa": map[string]any{ + "aaaaaa": "bbbbbb", + }, + }, + }, + }, + }, + "c": map[string]any{ + "cc": map[string]any{ + "ccc": map[string]any{ + "cccc": map[string]any{ + "ccccc": map[string]any{ + "cccccc": "dddddd", + }, + }, + }, + }, + }, + }, + expected: map[string][]string{ + "a[aa][aaa][aaaa][aaaaa][aaaaaa]": {"bbbbbb"}, + "c[cc][ccc][cccc][ccccc][cccccc]": {"dddddd"}, + }, + }, + } + + for name, testData := range tests { + testData := testData + t.Run(name, func(t *testing.T) { + t.Parallel() + // Arrange + params := url.Values{} + + // Act + applyQueryParams(testData.input, params, "") + + // Assert + assert.Equal(t, testData.expected, params) + }) + } +} diff --git a/controller.go b/controller.go new file mode 100644 index 0000000..61ecbd0 --- /dev/null +++ b/controller.go @@ -0,0 +1,50 @@ +package gintestutil + +import ( + "encoding/json" + "io" + "net/http" +) + +// statusHasBody is used to determine whether a response is allowed to have a body +func statusHasBody(status int) bool { + switch { + case status >= http.StatusContinue && status <= 199: + return false + case status == http.StatusNoContent: + return false + case status == http.StatusNotModified: + return false + } + return true +} + +// Response checks the status code and unmarshalls it to the given type. +// If you don't care about the response, Use nil. If the return code is 204 or 304, the response body is not converted. +func Response(t TestingT, result any, code int, res *http.Response) bool { + t.Helper() + + if code != res.StatusCode { + t.Errorf("Status code %d is not %d", res.StatusCode, code) + return false + } + + response, err := io.ReadAll(res.Body) + + if err != nil { + t.Errorf("failed to read body of response") + return false + } + + // Some status codes don't allow bodies + if result == nil || !statusHasBody(code) { + return true + } + + if err := json.Unmarshal(response, &result); err != nil { + t.Errorf("Failed to unmarshall '%s' into '%T': %v", response, result, err) + return false + } + + return true +} diff --git a/controller_test.go b/controller_test.go new file mode 100644 index 0000000..ccf39e1 --- /dev/null +++ b/controller_test.go @@ -0,0 +1,140 @@ +package gintestutil + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +type testObject struct { + Name string +} + +func TestSuccessResponse_ReturnsExpectedResult(t *testing.T) { + t.Parallel() + tests := map[string]struct { + code int + response *http.Response + responseBody any + expected bool + }{ + "no content": { + code: http.StatusNoContent, + response: &http.Response{StatusCode: http.StatusNoContent}, + expected: true, + // This should not be read + responseBody: "{{{}{}{{[][}", + }, + "not modified": { + code: http.StatusNotModified, + response: &http.Response{StatusCode: http.StatusNotModified}, + expected: true, + // This should not be read + responseBody: "{{{}{}{{[][}", + }, + "accepted": { + code: http.StatusAccepted, + response: &http.Response{StatusCode: http.StatusAccepted}, + expected: true, + }, + "bad request": { + code: http.StatusBadRequest, + response: &http.Response{StatusCode: http.StatusOK}, + expected: false, + }, + "broken json": { + code: http.StatusOK, + response: &http.Response{StatusCode: http.StatusOK}, + responseBody: "{{{}{}{{[][}", + expected: false, + }, + } + + for name, testData := range tests { + testData := testData + t.Run(name, func(t *testing.T) { + t.Parallel() + // Arrange + testingObject := new(mockT) + jsonData, _ := json.Marshal(testData.responseBody) + testData.response.Body = io.NopCloser(bytes.NewBuffer(jsonData)) + + // Act + ok := Response(testingObject, &testObject{}, testData.code, testData.response) + + // Assert + assert.Equal(t, testData.expected, ok) + }) + } +} + +func TestSuccessResponse_ReturnsData(t *testing.T) { + t.Parallel() + + // Arrange + testingObject := new(mockT) + + type testObject struct { + Name string `json:"name"` + Other float64 `json:"other"` + } + + object := testObject{Name: "abc", Other: 23} + + jsonData, _ := json.Marshal(object) + response := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBuffer(jsonData)), + } + + var result testObject + + // Act + ok := Response(testingObject, &result, http.StatusOK, response) + + // Assert + assert.Equal(t, object, result) + assert.True(t, ok) +} + +func TestSuccessResponse_FailsOnNoBody(t *testing.T) { + t.Parallel() + // Arrange + testingObject := new(mockT) + response := &http.Response{ + Body: io.NopCloser(bytes.NewBuffer([]byte(""))), + StatusCode: http.StatusOK, + } + + var result testObject + + // Act + ok := Response(testingObject, result, http.StatusOK, response) + + // Assert + assert.Empty(t, result) + assert.False(t, ok) +} + +func TestSuccessResponse_FailsOnUnmarshall(t *testing.T) { + t.Parallel() + // Arrange + testingObject := new(mockT) + response := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBuffer([]byte("test"))), + } + + var result testObject + + // Act + ok := Response(testingObject, result, http.StatusOK, response) + + // Assert + assert.Empty(t, result) + assert.False(t, ok) +} diff --git a/examples_test.go b/examples_test.go new file mode 100644 index 0000000..af5644d --- /dev/null +++ b/examples_test.go @@ -0,0 +1,126 @@ +package gintestutil + +import ( + "fmt" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "net/http" + "net/http/httptest" + "testing" +) + +type MyObject struct { + ID int +} + +type MyController struct{} + +func (m *MyController) Get(e *gin.Context) { + // [...] +} + +func (m *MyController) Post(e *gin.Context) { + // [...] +} + +func ExamplePrepareRequest() { + // Arrange + t := new(mockT) + myController := &MyController{} + + context, writer := PrepareRequest(t, + WithMethod(http.MethodGet), + WithUrl("https://ing.net"), + WithJsonBody(t, MyObject{ID: 5}), + WithUrlParams(map[string]any{"one": "two", "three": []string{"four", "five"}}), + WithQueryParams(map[string]any{"id": map[string]any{"a": "b"}}), + ) + + // Act + myController.Post(context) + + // Assert + assert.Equal(t, "...", writer.Body.String()) +} + +func ExampleResponse() { + // Arrange + t := new(mockT) + expected := []MyObject{{ID: 5}} + myController := &MyController{} + + context, writer := PrepareRequest(t) + + // Act + myController.Get(context) + + var result []MyObject + + // Assert + ok := Response(t, &result, http.StatusOK, writer.Result()) + + if assert.True(t, ok) { + assert.Equal(t, expected[0].ID, result[0].ID) + } +} + +// without arguments expect called assumes that the endpoint is only called once and +// creates a new expectation +func ExampleExpectCalled_withoutVarargs() { + t := new(testing.T) + + // create gin context + ginContext := gin.Default() + + // create expectation + expectation := ExpectCalled(t, ginContext, "/hello-world") + + // create endpoints on ginContext + ginContext.GET("/hello-world", func(context *gin.Context) { + context.Status(http.StatusOK) + }) + + // create webserver + ts := httptest.NewServer(ginContext) + + // Send request to webserver path + _, _ = http.Get(fmt.Sprintf("%s/hello-world", ts.URL)) + + // Wait for expectation in bounded time + if ok := EnsureCompletion(t, expectation); !ok { + // do something + } +} + +// arguments can configure the expected amount of times and endpoint is called or +// re-use an existing expectation +func ExampleExpectCalled_withVarargs() { + t := new(testing.T) + + // create gin context + ginContext := gin.Default() + + // create expectation + expectation := ExpectCalled(t, ginContext, "/hello-world", TimesCalled(2)) + expectation = ExpectCalled(t, ginContext, "/other-path", Expectation(expectation)) + + // create endpoints on ginContext + for _, endpoint := range []string{"/hello-world", "/other-path"} { + ginContext.GET(endpoint, func(context *gin.Context) { + context.Status(http.StatusOK) + }) + } + + // create webserver + ts := httptest.NewServer(ginContext) + + // Send request to webserver path + _, _ = http.Get(fmt.Sprintf("%s/hello-world", ts.URL)) + _, _ = http.Get(fmt.Sprintf("%s/hello-world", ts.URL)) + _, _ = http.Get(fmt.Sprintf("%s/other-path", ts.URL)) + + // Wait for expectation in bounded time + if ok := EnsureCompletion(t, expectation); !ok { + // do something + } +} diff --git a/expect.go b/expect.go new file mode 100644 index 0000000..5434cd6 --- /dev/null +++ b/expect.go @@ -0,0 +1,70 @@ +package gintestutil + +import ( + "github.com/gin-gonic/gin" + "sync" +) + +// ExpectOption allows various options to be supplied to Expect* functions +type ExpectOption func(*calledConfig) + +// TimesCalled is used to expect an invocation an X amount of times +func TimesCalled(times int) ExpectOption { + return func(config *calledConfig) { + config.Times = times + } +} + +// Expectation is used to have a global wait group to wait for +// when asserting multiple calls made +func Expectation(expectation *sync.WaitGroup) ExpectOption { + return func(config *calledConfig) { + config.Expectation = expectation + } +} + +type calledConfig struct { + Times int + Expectation *sync.WaitGroup +} + +// ExpectCalled can be used on a gin endpoint to express an expectation that the endpoint will +// be called some time in the future. In combination with a test +// can wait for this expectation to be true or fail after some predetermined amount of time +func ExpectCalled(t TestingT, context *gin.Engine, path string, options ...ExpectOption) *sync.WaitGroup { + t.Helper() + + if context == nil { + t.Errorf("context cannot be nil") + return nil + } + + config := &calledConfig{ + Times: 1, + Expectation: &sync.WaitGroup{}, + } + + for _, option := range options { + option(config) + } + + // Set waitgroup for amount of times + config.Expectation.Add(config.Times) + + // Add middleware for provided route + var timesCalled int + context.Use(func(c *gin.Context) { + c.Next() + if c.FullPath() == path { + timesCalled++ + if timesCalled <= config.Times { + config.Expectation.Done() + } else { + t.Errorf("%s hook asserts called %d times but called at least %d times\n", path, config.Times, timesCalled) + return + } + } + }) + + return config.Expectation +} diff --git a/expect_test.go b/expect_test.go new file mode 100644 index 0000000..4f5e3ee --- /dev/null +++ b/expect_test.go @@ -0,0 +1,178 @@ +package gintestutil + +import ( + "fmt" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" +) + +// setup prepares the tests +func setup(endpoint string, varargs ...ExpectOption) (*testing.T, chan struct{}, *gin.Engine, *sync.WaitGroup, *httptest.Server) { + testObject := new(testing.T) + + // create gin context + ginContext := gin.Default() + + // create expectation + expectation := ExpectCalled(testObject, ginContext, endpoint, varargs...) + + // create endpoints on ginContext + ginContext.GET(endpoint, func(context *gin.Context) { + context.Status(http.StatusOK) + }) + + // channel for go-routine to signal completion + c := make(chan struct{}) + + // create webserver + ts := httptest.NewServer(ginContext) + + return testObject, c, ginContext, expectation, ts +} + +func TestExpectCalled_SingleCallWithDefaultArgumentsReturnsSuccess(t *testing.T) { + t.Parallel() + + // arrange + path := "/hello-world" + testObject, c, _, expectation, ts := setup(path) + + // Act + _, err := http.Get(fmt.Sprintf("%s%s", ts.URL, path)) + + // Assert + assert.Nil(t, err) + + go func() { + defer close(c) + expectation.Wait() + }() + + select { + case <-c: + assert.False(t, testObject.Failed()) + case <-time.After(15 * time.Second): + t.FailNow() + } +} + +func TestExpectCalled_ZeroCallsWithDefaultArgumentsTimesOutAndFails(t *testing.T) { + t.Parallel() + + // arrange + path := "/hello-world" + _, c, _, expectation, ts := setup(path) + + // Act + // Make a call to and endpoint which is _NOT_ path such that path is never called + _, err := http.Get(fmt.Sprintf("%s%s", ts.URL, "/something-other-than-path")) + + // Assert + assert.Nil(t, err) + + go func() { + defer close(c) + expectation.Wait() + }() + + select { + case <-c: + t.FailNow() + case <-time.After(15 * time.Second): + // test is bounded to accept after 15 seconds + } +} + +func TestExpectCalled_NilGinContextReturnsError(t *testing.T) { + t.Parallel() + + // arrange + testObject := new(testing.T) + var ginContext *gin.Engine + + // Act + expectation := ExpectCalled(testObject, ginContext, "") + + // Assert + assert.True(t, testObject.Failed()) + assert.Nil(t, expectation) +} + +func TestExpectCalled_CalledToOftenReturnsError(t *testing.T) { + t.Parallel() + + // arrange + path := "/hello-world" + testObject, c, _, expectation, ts := setup(path) + + // Act + _, _ = http.Get(fmt.Sprintf("%s%s", ts.URL, path)) + _, _ = http.Get(fmt.Sprintf("%s%s", ts.URL, path)) + + // Assert + go func() { + defer close(c) + expectation.Wait() + }() + + select { + case <-c: + assert.True(t, testObject.Failed()) + case <-time.After(15 * time.Second): + t.FailNow() + } +} + +func TestExpectCalled_TwoTimesWithTwoEndpointsSucceeds(t *testing.T) { + t.Parallel() + + // arrange + testObject := new(testing.T) + + // create gin context + ginContext := gin.Default() + + // endpoints + endpointCalledTwice := "/hello-world" + endpointCalledOnce := "/other-path" + + // create expectation + expectation := ExpectCalled(testObject, ginContext, endpointCalledTwice, TimesCalled(2)) + expectation = ExpectCalled(testObject, ginContext, endpointCalledOnce, Expectation(expectation)) + + // create endpoints on ginContext + for _, endpoint := range []string{endpointCalledTwice, endpointCalledOnce} { + ginContext.GET(endpoint, func(context *gin.Context) { + context.Status(http.StatusOK) + }) + } + + // channel for go-routine to signal completion + c := make(chan struct{}) + + // create webserver + ts := httptest.NewServer(ginContext) + + // act + _, _ = http.Get(fmt.Sprintf("%s%s", ts.URL, endpointCalledTwice)) + _, _ = http.Get(fmt.Sprintf("%s%s", ts.URL, endpointCalledTwice)) + _, _ = http.Get(fmt.Sprintf("%s%s", ts.URL, endpointCalledOnce)) + + // Assert + go func() { + defer close(c) + expectation.Wait() + }() + + select { + case <-c: + assert.False(t, testObject.Failed()) + case <-time.After(15 * time.Second): + t.FailNow() + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..613dc8c --- /dev/null +++ b/go.mod @@ -0,0 +1,32 @@ +module github.com/ing-bank/gintestutil + +go 1.20 + +require ( + github.com/gin-gonic/gin v1.8.2 + github.com/stretchr/testify v1.8.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.0 // indirect + github.com/go-playground/universal-translator v0.18.0 // indirect + github.com/go-playground/validator/v10 v10.11.1 // indirect + github.com/goccy/go-json v0.9.11 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/leodido/go-urn v1.2.1 // indirect + github.com/mattn/go-isatty v0.0.16 // indirect + github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.0.6 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/ugorji/go/codec v1.2.7 // indirect + golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 // indirect + golang.org/x/net v0.4.0 // indirect + golang.org/x/sys v0.3.0 // indirect + golang.org/x/text v0.5.0 // indirect + google.golang.org/protobuf v1.28.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7aee98d --- /dev/null +++ b/go.sum @@ -0,0 +1,95 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.8.2 h1:UzKToD9/PoFj/V4rvlKqTRKnQYyz8Sc1MJlv4JHPtvY= +github.com/gin-gonic/gin v1.8.2/go.mod h1:qw5AYuDrzRTnhvusDsrov+fDIxp9Dleuu12h8nfB398= +github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A= +github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU= +github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs= +github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho= +github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA= +github.com/go-playground/validator/v10 v10.11.1 h1:prmOlTVv+YjZjmRmNSF3VmspqJIxJWXmqUsHwfTRRkQ= +github.com/go-playground/validator/v10 v10.11.1/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4dMGDBiPU55YFDl0WbKdWU= +github.com/goccy/go-json v0.9.11 h1:/pAaQDLHEoCq/5FFmSKBswWmK6H0e8g4159Kc/X/nqk= +github.com/goccy/go-json v0.9.11/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= +github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= +github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.0.6 h1:nrzqCb7j9cDFj2coyLNLaZuJTLjWjlaz6nvTvIwycIU= +github.com/pelletier/go-toml/v2 v2.0.6/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M= +github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0= +github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= +golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 h1:0es+/5331RGQPcXlMfP+WrnIIS6dNnNRe0WB02W0F4M= +golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.4.0 h1:Q5QPcMlvfxFTAPV0+07Xz/MpK9NTXu2VDUuy0FeMfaU= +golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.5.0 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM= +golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= +google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/testing.go b/testing.go new file mode 100644 index 0000000..fc34f8e --- /dev/null +++ b/testing.go @@ -0,0 +1,37 @@ +package gintestutil + +import ( + "fmt" + "testing" +) + +// Compile-time interface checks +var _ TestingT = new(testing.T) +var _ TestingT = new(mockT) + +// TestingT is an interface representing testing.T in our tests, allows for verifying Errorf calls. It's perfectly +// compatible with the normal testing.T, but we use an interface for mocking. +type TestingT interface { + Helper() + Error(...any) + Errorf(string, ...any) +} + +// mockT is the mock version of the TestingT interface, used to verify Errorf calls +type mockT struct { + ErrorCalls []any + ErrorfCalls []string +} + +// Helper does nothing +func (m *mockT) Helper() {} + +// Errorf saves Errorf calls in an error for verification +func (m *mockT) Errorf(format string, args ...any) { + m.ErrorfCalls = append(m.ErrorfCalls, fmt.Sprintf(format, args...)) +} + +// Error saves Error calls in an error for verification +func (m *mockT) Error(args ...any) { + m.ErrorCalls = append(m.ErrorCalls, args...) +}