(Feat): Initial Commit
This commit is contained in:
40
internal/test/README.md
Normal file
40
internal/test/README.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# `/test`
|
||||
|
||||
General global test utilities.
|
||||
|
||||
### Regarding `test/test_*.go` and its `test.With*` functions
|
||||
|
||||
Test helpers like `test.WithTestDatabase` and `test.WithTestServer` require usage of a closure, as these functions automatically manage the setup **and** teardown (e.g. server shutdown, db connection drop) for your testcase.
|
||||
|
||||
Other pkgs don't have this requirement (e.g. the initialization code for `test.NewTestMailer` which covers the setup for the `mailer` mock), thus, please use this `With*` convention incl. closure **only** when it makes sense.
|
||||
|
||||
### Regarding `test/fixtures.go`
|
||||
|
||||
This are your global db test fixtures, that are only available while testing. However, feel free to setup specialized fixtures per package if required (e.g. just initialize an additional IntegreSQL template).
|
||||
|
||||
### Regarding `test/helper_*.go`
|
||||
|
||||
Please use this convention to specify test only utility functions.
|
||||
|
||||
### Regarding snapshot testing
|
||||
|
||||
Golden files can be created by using the `Snapshoter.Save(t TestingT, data ...interface{})` method. The snapshot can be configured to force an update, use a different replacer function or to set a different file location and suffix for the snaphot.
|
||||
|
||||
A snapshot can be updated by either calling the `Update(true)` method, or by using the global override by setting the environment variable `TEST_UPDATE_GOLDEN` to `true`. To update all snapshots the call might look like this: `TEST_UPDATE_GOLDEN=true make test`.
|
||||
|
||||
### `testdata` and `.` or `_` prefixed files
|
||||
|
||||
Note that Go will ignore directories or files that begin with "." or "_", so you have more flexibility in terms of how you name your test data directory.
|
||||
|
||||
> Go build ignores directory named testdata.
|
||||
> The Go tool will ignore any directory in your $GOPATH that starts with a period, an underscore, or matches the word testdata
|
||||
> When go test runs, it sets current directory as package directory
|
||||
|
||||
* https://github.com/golang-standards/project-layout/blob/master/test/README.md
|
||||
* https://medium.com/@povilasve/go-advanced-tips-tricks-a872503ac859
|
||||
* https://dave.cheney.net/2016/05/10/test-fixtures-in-go
|
||||
|
||||
Examples:
|
||||
* https://github.com/openshift/origin/tree/master/test (test data is in the `/testdata` subdirectory)
|
||||
|
||||
https://github.com/golang-standards/project-layout/tree/master/test
|
||||
172
internal/test/fixtures/fixtures.go
vendored
Normal file
172
internal/test/fixtures/fixtures.go
vendored
Normal file
@@ -0,0 +1,172 @@
|
||||
package fixtures
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/models"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/aarondl/null/v8"
|
||||
"github.com/aarondl/sqlboiler/v4/boil"
|
||||
)
|
||||
|
||||
const (
|
||||
PlainTestUserPassword = "password"
|
||||
HashedTestUserPassword = "$argon2id$v=19$m=65536,t=1,p=4$RFO8ulg2c2zloG0029pAUQ$2Po6NUIhVCMm9vivVDuzo7k5KVWfZzJJfeXzC+n+row" //nolint:gosec
|
||||
)
|
||||
|
||||
// Insertable represents a common interface for all model instances so they may be inserted via the Inserts() func
|
||||
type Insertable interface {
|
||||
Insert(ctx context.Context, exec boil.ContextExecutor, columns boil.Columns) error
|
||||
}
|
||||
|
||||
// The main definition which fixtures are available through Fixtures().
|
||||
// Mind the declaration order! The fields get inserted exactly in the order they are declared.
|
||||
type FixtureMap struct {
|
||||
User1 *models.User
|
||||
User1AppUserProfile *models.AppUserProfile
|
||||
User1AccessToken1 *models.AccessToken
|
||||
User1RefreshToken1 *models.RefreshToken
|
||||
User2 *models.User
|
||||
User2AppUserProfile *models.AppUserProfile
|
||||
User2AccessToken1 *models.AccessToken
|
||||
User2RefreshToken1 *models.RefreshToken
|
||||
UserDeactivated *models.User
|
||||
UserDeactivatedAppUserProfile *models.AppUserProfile
|
||||
UserDeactivatedAccessToken1 *models.AccessToken
|
||||
UserDeactivatedRefreshToken1 *models.RefreshToken
|
||||
User1PushToken *models.PushToken
|
||||
User1PushTokenAPN *models.PushToken
|
||||
UserRequiresConfirmation *models.User
|
||||
UserRequiresConfirmationAppUserProfile *models.AppUserProfile
|
||||
UserRequiresConfirmationConfirmationToken *models.ConfirmationToken
|
||||
}
|
||||
|
||||
// Fixtures returns a function wrapping our fixtures, which tests are allowed to manipulate.
|
||||
// Each test (which may run concurrently) receives a fresh copy, preventing side effects between test runs.
|
||||
func Fixtures() FixtureMap {
|
||||
now := time.Now()
|
||||
f := FixtureMap{}
|
||||
|
||||
f.User1 = &models.User{
|
||||
ID: "f6ede5d8-e22a-4ca5-aa12-67821865a3e5",
|
||||
IsActive: true,
|
||||
Username: null.StringFrom("user1@example.com"),
|
||||
Password: null.StringFrom(HashedTestUserPassword),
|
||||
Scopes: []string{"app"},
|
||||
}
|
||||
|
||||
f.User1AppUserProfile = &models.AppUserProfile{
|
||||
UserID: f.User1.ID,
|
||||
LegalAcceptedAt: null.TimeFrom(now.Add(time.Minute * -10)),
|
||||
}
|
||||
|
||||
f.User1AccessToken1 = &models.AccessToken{
|
||||
Token: "1cfc27d7-a178-4051-802b-f3ff3967c95c",
|
||||
ValidUntil: now.Add(10 * 365 * 24 * time.Hour),
|
||||
UserID: f.User1.ID,
|
||||
}
|
||||
|
||||
f.User1RefreshToken1 = &models.RefreshToken{
|
||||
Token: "66412eaf-2b89-404d-bbb5-46c3b8bf1a53",
|
||||
UserID: f.User1.ID,
|
||||
}
|
||||
|
||||
f.User2 = &models.User{
|
||||
ID: "76a79a2b-fbd8-45a0-b35b-671a28a87acf",
|
||||
IsActive: true,
|
||||
Username: null.StringFrom("user2@example.com"),
|
||||
Password: null.StringFrom(HashedTestUserPassword),
|
||||
Scopes: []string{"app"},
|
||||
}
|
||||
|
||||
f.User2AppUserProfile = &models.AppUserProfile{
|
||||
UserID: f.User2.ID,
|
||||
LegalAcceptedAt: null.TimeFrom(now.Add(time.Minute * -10)),
|
||||
}
|
||||
|
||||
f.User2AccessToken1 = &models.AccessToken{
|
||||
Token: "115d28c5-f585-4fb5-9656-fb321739fee5",
|
||||
ValidUntil: now.Add(10 * 365 * 24 * time.Hour),
|
||||
UserID: f.User2.ID,
|
||||
}
|
||||
|
||||
f.User2RefreshToken1 = &models.RefreshToken{
|
||||
Token: "ea909c75-63d1-4348-a63c-4bcf8ab334a2",
|
||||
UserID: f.User2.ID,
|
||||
}
|
||||
|
||||
f.UserRequiresConfirmation = &models.User{
|
||||
ID: "402e1669-fff7-43ca-8f08-071c06409479",
|
||||
IsActive: false,
|
||||
RequiresConfirmation: true,
|
||||
Username: null.StringFrom("userrequiresconfirmation@example.com"),
|
||||
Password: null.StringFrom(HashedTestUserPassword),
|
||||
Scopes: []string{"app"},
|
||||
}
|
||||
|
||||
f.UserRequiresConfirmationAppUserProfile = &models.AppUserProfile{
|
||||
UserID: f.UserRequiresConfirmation.ID,
|
||||
LegalAcceptedAt: null.TimeFrom(now.Add(time.Minute * -10)),
|
||||
}
|
||||
|
||||
f.UserRequiresConfirmationConfirmationToken = &models.ConfirmationToken{
|
||||
Token: "c9182e0b-4a46-4825-9f10-56f04a8b1665",
|
||||
ValidUntil: now.Add(time.Hour),
|
||||
UserID: f.UserRequiresConfirmation.ID,
|
||||
}
|
||||
|
||||
f.UserDeactivated = &models.User{
|
||||
ID: "d9c0dee9-239e-4323-979a-a5354d289627",
|
||||
IsActive: false,
|
||||
Username: null.StringFrom("userdeactivated@example.com"),
|
||||
Password: null.StringFrom(HashedTestUserPassword),
|
||||
Scopes: []string{"app"},
|
||||
}
|
||||
|
||||
f.UserDeactivatedAppUserProfile = &models.AppUserProfile{
|
||||
UserID: f.UserDeactivated.ID,
|
||||
LegalAcceptedAt: null.Time{},
|
||||
}
|
||||
|
||||
f.UserDeactivatedAccessToken1 = &models.AccessToken{
|
||||
Token: "24d0b38d-387c-400c-80fc-a71d85031d4c",
|
||||
ValidUntil: now.Add(10 * 365 * 24 * time.Hour),
|
||||
UserID: f.UserDeactivated.ID,
|
||||
}
|
||||
|
||||
f.UserDeactivatedRefreshToken1 = &models.RefreshToken{
|
||||
Token: "b6e13a88-7b18-4f17-b819-71b196be2444",
|
||||
UserID: f.UserDeactivated.ID,
|
||||
}
|
||||
|
||||
f.User1PushToken = &models.PushToken{
|
||||
ID: "98ad176b-af90-44b7-b991-d9ebfc5dd9a0",
|
||||
Token: "cQ_Qk3ZCCZelUZ_K_Yn2BV:APA91bG4jst5srGYZqBAn_wRfiJUzAOQ4k8tV0sDcV4uas2ln5wNwkE_ebneR5Fqk7GvndZ-h3mWnjWaI8yZ4sVwo8qu_Aztotqup4mlEPNYgFGqTlJ5ltQrJG5oKp4RoYQ_0CeFaymn",
|
||||
UserID: f.User1.ID,
|
||||
Provider: models.ProviderTypeFCM,
|
||||
}
|
||||
|
||||
f.User1PushTokenAPN = &models.PushToken{
|
||||
ID: "5909b472-86f8-4d15-bb63-d49f4fad41a3",
|
||||
Token: "0a863a72-d391-4217-9f26-388801684744",
|
||||
UserID: f.User1.ID,
|
||||
Provider: models.ProviderTypeApn,
|
||||
}
|
||||
|
||||
return f
|
||||
}
|
||||
|
||||
// Inserts defines the order in which the fixtures will be inserted
|
||||
// into the test database
|
||||
func Inserts() []Insertable {
|
||||
fix := Fixtures()
|
||||
insertableIfc := (*Insertable)(nil)
|
||||
inserts, err := util.GetFieldsImplementing(&fix, insertableIfc)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to get insertable fixture fields: %w", err))
|
||||
}
|
||||
|
||||
return inserts
|
||||
}
|
||||
97
internal/test/fixtures/fixtures_test.go
vendored
Normal file
97
internal/test/fixtures/fixtures_test.go
vendored
Normal file
@@ -0,0 +1,97 @@
|
||||
package fixtures_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/models"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
|
||||
"github.com/aarondl/null/v8"
|
||||
"github.com/aarondl/sqlboiler/v4/boil"
|
||||
_ "github.com/lib/pq"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFixturesReload(t *testing.T) {
|
||||
test.WithTestDatabase(t, func(db *sql.DB) {
|
||||
err := fixtures.Fixtures().User1.Reload(t.Context(), db)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestInsert(t *testing.T) {
|
||||
test.WithTestDatabase(t, func(db *sql.DB) {
|
||||
userNew := models.User{
|
||||
ID: "6d00d09b-fab3-43d8-a163-279fe7ba533e",
|
||||
IsActive: true,
|
||||
Username: null.StringFrom("userNew@example.com"),
|
||||
Password: null.StringFrom("$argon2id$v=19$m=65536,t=1,p=4$RFO8ulg2c2zloG0029pAUQ$2Po6NUIhVCMm9vivVDuzo7k5KVWfZzJJfeXzC+n+row"),
|
||||
Scopes: []string{"app"},
|
||||
}
|
||||
|
||||
err := userNew.Insert(t.Context(), db, boil.Infer())
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdate(t *testing.T) {
|
||||
test.WithTestDatabase(t, func(db *sql.DB) {
|
||||
originalUser1 := fixtures.Fixtures().User1
|
||||
|
||||
updatedUser1 := *originalUser1
|
||||
|
||||
updatedUser1.Username = null.StringFrom("user_updated@example.com")
|
||||
|
||||
if updatedUser1.Username == originalUser1.Username {
|
||||
t.Fatalf("names match!")
|
||||
}
|
||||
|
||||
_, err := updatedUser1.Update(t.Context(), db, boil.Infer())
|
||||
|
||||
if err != nil {
|
||||
t.Error("failed to update")
|
||||
}
|
||||
|
||||
// Attention, this actually mutates our user1 fixture!!!
|
||||
err = originalUser1.Reload(t.Context(), db)
|
||||
|
||||
if err != nil {
|
||||
t.Error("failed to reload")
|
||||
}
|
||||
|
||||
if updatedUser1.Username != originalUser1.Username {
|
||||
t.Fatalf("names don't match!")
|
||||
}
|
||||
})
|
||||
|
||||
// with another testdatabase:
|
||||
test.WithTestDatabase(t, func(db *sql.DB) {
|
||||
originalUser1 := fixtures.Fixtures().User1
|
||||
|
||||
// ensure our fixture is the same again!
|
||||
if originalUser1.Username != null.StringFrom("user1@example.com") {
|
||||
err := originalUser1.Reload(t.Context(), db)
|
||||
|
||||
if err != nil {
|
||||
t.Error("failed to reload")
|
||||
}
|
||||
|
||||
if originalUser1.Username != null.StringFrom("user1@example.com") {
|
||||
t.Fatalf("fixture even not the same after reload!")
|
||||
}
|
||||
|
||||
t.Fatalf("fixture was modified!")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestInsertableInterface(t *testing.T) {
|
||||
var user any = &models.AppUserProfile{
|
||||
UserID: "62b13d29-5c4e-420e-b991-a631d3938776",
|
||||
}
|
||||
|
||||
_, ok := user.(fixtures.Insertable)
|
||||
assert.True(t, ok, "AppUserProfile should implement the Insertable interface")
|
||||
}
|
||||
88
internal/test/helper_compare.go
Normal file
88
internal/test/helper_compare.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func CompareFileHashes(t *testing.T, expectedFilePath string, actualFilePath string) {
|
||||
t.Helper()
|
||||
|
||||
expectedFile, err := os.Open(expectedFilePath)
|
||||
require.NoError(t, err)
|
||||
defer expectedFile.Close()
|
||||
|
||||
expectedHash := sha256.New()
|
||||
_, err = io.Copy(expectedHash, expectedFile)
|
||||
require.NoError(t, err)
|
||||
|
||||
actualFile, err := os.Open(actualFilePath)
|
||||
require.NoError(t, err)
|
||||
defer actualFile.Close()
|
||||
|
||||
actualhash := sha256.New()
|
||||
_, err = io.Copy(actualhash, actualFile)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, expectedHash.Sum(nil), actualhash.Sum(nil))
|
||||
}
|
||||
|
||||
func CompareAllPayload(t *testing.T, base map[string]interface{}, toCheck map[string]string, skipKeys map[string]bool, keyConvertFunc ...func(string) string) {
|
||||
t.Helper()
|
||||
|
||||
var keyFunc func(string) string
|
||||
if len(keyConvertFunc) > 0 {
|
||||
keyFunc = keyConvertFunc[0]
|
||||
} else {
|
||||
keyFunc = func(s string) string {
|
||||
return s
|
||||
}
|
||||
}
|
||||
for key, val := range base {
|
||||
if skipKeys[key] {
|
||||
continue
|
||||
}
|
||||
|
||||
// checks with contains because of dateTime and null.String struct
|
||||
contains := strings.Contains(toCheck[keyFunc(key)], fmt.Sprintf("%v", val))
|
||||
assert.Truef(t, contains, "Expected for %s: '%s'. Got: '%s'", key, val, toCheck[keyFunc(key)])
|
||||
}
|
||||
}
|
||||
|
||||
func CompareAll(t *testing.T, base map[string]string, toCheck map[string]string, skipKeys map[string]bool) {
|
||||
t.Helper()
|
||||
|
||||
for key, val := range base {
|
||||
if skipKeys[key] {
|
||||
continue
|
||||
}
|
||||
|
||||
// checks with contains because of dateTime and null.String struct
|
||||
contains := strings.Contains(toCheck[key], val)
|
||||
assert.Truef(t, contains, "Expected for %s: '%s'. Got: '%s'", key, val, toCheck[key])
|
||||
}
|
||||
}
|
||||
|
||||
func RequireHTTPError(t *testing.T, res *httptest.ResponseRecorder, httpError *httperrors.HTTPError) httperrors.HTTPError {
|
||||
t.Helper()
|
||||
|
||||
if httpError.Code != nil {
|
||||
require.Equal(t, int(*httpError.Code), res.Result().StatusCode)
|
||||
}
|
||||
|
||||
var response httperrors.HTTPError
|
||||
ParseResponseAndValidate(t, res, &response)
|
||||
|
||||
require.Equal(t, httpError, &response)
|
||||
|
||||
return response
|
||||
}
|
||||
97
internal/test/helper_compare_test.go
Normal file
97
internal/test/helper_compare_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package test_test
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/go-openapi/strfmt"
|
||||
"github.com/go-openapi/strfmt/conv"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCompareFileHashes(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
newFilePath := tmpDir + "example2.jpg"
|
||||
filePath := filepath.Join(util.GetProjectRootDir(), "test", "testdata", "example.jpg")
|
||||
|
||||
file1, err := os.Open(filePath)
|
||||
require.NoError(t, err)
|
||||
defer file1.Close()
|
||||
|
||||
file2, err := os.Create(newFilePath)
|
||||
require.NoError(t, err)
|
||||
defer file2.Close()
|
||||
|
||||
_, err = io.Copy(file2, file1)
|
||||
require.NoError(t, err)
|
||||
require.FileExists(t, newFilePath)
|
||||
|
||||
test.CompareFileHashes(t, filePath, newFilePath)
|
||||
}
|
||||
|
||||
func TestCompareAllPayload(t *testing.T) {
|
||||
payload := test.GenericPayload{
|
||||
"A": 1,
|
||||
"B": "b",
|
||||
"C": 2.3,
|
||||
"D": true,
|
||||
"E": "2020-02-01",
|
||||
"F": conv.UUID4(strfmt.UUID4("0862573e-6ccb-4684-847d-276d3364e91e")),
|
||||
"X_Y": "skipped",
|
||||
}
|
||||
response := map[string]string{
|
||||
"A": "1",
|
||||
"B": "b",
|
||||
"C": "2.3",
|
||||
"D": "true",
|
||||
"E": util.Date(2020, 2, 1, time.UTC).String(),
|
||||
"F": "0862573e-6ccb-4684-847d-276d3364e91e",
|
||||
}
|
||||
|
||||
toSkip := map[string]bool{
|
||||
"X_Y": true,
|
||||
}
|
||||
test.CompareAllPayload(t, payload, response, toSkip)
|
||||
|
||||
payload = test.GenericPayload{
|
||||
"a": 1,
|
||||
"B": "b",
|
||||
"C": 2.3,
|
||||
"d": true,
|
||||
"e": "2020-02-01",
|
||||
"F": conv.UUID4(strfmt.UUID4("0862573e-6ccb-4684-847d-276d3364e91e")),
|
||||
"X_Y": "skipped",
|
||||
}
|
||||
test.CompareAllPayload(t, payload, response, toSkip, strings.ToUpper)
|
||||
}
|
||||
|
||||
func TestCompareAll(t *testing.T) {
|
||||
payload := map[string]string{
|
||||
"A": "1",
|
||||
"B": "b",
|
||||
"C": "2.3",
|
||||
"D": "true",
|
||||
"E": "2020-02-01",
|
||||
"F": strfmt.UUID4("0862573e-6ccb-4684-847d-276d3364e91e").String(),
|
||||
"X_Y": "skipped",
|
||||
}
|
||||
response := map[string]string{
|
||||
"A": "1",
|
||||
"B": "b",
|
||||
"C": "2.3",
|
||||
"D": "true",
|
||||
"E": util.Date(2020, 2, 1, time.UTC).String(),
|
||||
"F": "0862573e-6ccb-4684-847d-276d3364e91e",
|
||||
}
|
||||
|
||||
toSkip := map[string]bool{
|
||||
"X_Y": true,
|
||||
}
|
||||
test.CompareAll(t, payload, response, toSkip)
|
||||
}
|
||||
41
internal/test/helper_dot_env.go
Normal file
41
internal/test/helper_dot_env.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/config"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
)
|
||||
|
||||
// DotEnvLoadLocalOrSkipTest tries to load the `.env.local` file in the projectroot
|
||||
// overwriting ENV vars. If the file is not found, the test will be automatically skipped.
|
||||
func DotEnvLoadLocalOrSkipTest(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
absolutePathToEnvFile := filepath.Join(util.GetProjectRootDir(), ".env.local")
|
||||
DotEnvLoadFileOrSkipTest(t, absolutePathToEnvFile)
|
||||
}
|
||||
|
||||
// DotEnvLoadFileOrSkipTest tries to load the overgiven path to the dotenv file overwriting
|
||||
// ENV vars. If the file is not found, the test will be automatically skipped.
|
||||
func DotEnvLoadFileOrSkipTest(t *testing.T, absolutePathToEnvFile string) {
|
||||
t.Helper()
|
||||
|
||||
// this test should be automatically skipped if no default `.env.local` file was found.
|
||||
err := config.DotEnvLoad(
|
||||
absolutePathToEnvFile,
|
||||
func(k string, v string) error { t.Setenv(k, v); return nil })
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
t.Skip(absolutePathToEnvFile, "not found, skipping test.")
|
||||
} else {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
t.Log(absolutePathToEnvFile, "override ENV variables!")
|
||||
}
|
||||
}
|
||||
25
internal/test/helper_dot_env_test.go
Normal file
25
internal/test/helper_dot_env_test.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package test_test
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/config"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// This test will run or skip depending upon if you currently have a `.env.local` in your project directory
|
||||
// Note: We do not activate this test in the go-starter template, as it would always be skipped.
|
||||
// func TestDotEnvLoadLocalOrSkipTest(t *testing.T) {
|
||||
// test.DotEnvLoadLocalOrSkipTest(t)
|
||||
// }
|
||||
|
||||
// This test will always run as the /internal/test/testdata/.env.test.local is checked into git.
|
||||
func TestDotEnvLoadFileOrSkipTest(t *testing.T) {
|
||||
// explicitly load a (test) dotenv file before getting a new config (for a testserver)
|
||||
test.DotEnvLoadFileOrSkipTest(t, filepath.Join(util.GetProjectRootDir(), "/internal/test/testdata/.env.test.local"))
|
||||
c := config.DefaultServiceConfigFromEnv()
|
||||
assert.Equal(t, "http://overwritten.dotenv.frontend.url.tld:3000", c.Frontend.BaseURL)
|
||||
}
|
||||
62
internal/test/helper_files.go
Normal file
62
internal/test/helper_files.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// PrepareTestFile copies a test file by name from test/testdata/<filename> to a folder unique to the test.
|
||||
// Used for tests with document to load reference files by fixtures.
|
||||
func PrepareTestFile(t *testing.T, fileName string, destFileName ...string) {
|
||||
t.Helper()
|
||||
|
||||
src, err := os.Open(filepath.Join(util.GetProjectRootDir(), "test", "testdata", fileName))
|
||||
require.NoError(t, err)
|
||||
defer src.Close()
|
||||
|
||||
dest := fileName
|
||||
if len(destFileName) > 0 {
|
||||
dest = destFileName[0]
|
||||
}
|
||||
|
||||
path := filepath.Join(util.GetProjectRootDir(), "assets", "mnt", strings.ToLower(t.Name()), "documents", dest)
|
||||
err = os.MkdirAll(filepath.Dir(path), 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
dst, err := os.Create(path)
|
||||
require.NoError(t, err)
|
||||
defer dst.Close()
|
||||
|
||||
_, err = io.Copy(dst, src)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// CleanupTestFiles removes folder unique to the test if exists
|
||||
func CleanupTestFiles(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
err := os.RemoveAll(filepath.Join(util.GetProjectRootDir(), "assets", "mnt", strings.ToLower(t.Name())))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// WithTempDir creates a folder unique to the tests and ensures cleanup of the folder will be
|
||||
// performed after the fn got called.
|
||||
func WithTempDir(t *testing.T, testFunc func(localBasePath string, basePath string)) {
|
||||
t.Helper()
|
||||
|
||||
localBasePath := filepath.Join(util.GetProjectRootDir(), "assets", "mnt", strings.ToLower(t.Name()))
|
||||
basePath := "/documents"
|
||||
path := filepath.Join(localBasePath, basePath)
|
||||
err := os.MkdirAll(filepath.Dir(path), 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer CleanupTestFiles(t)
|
||||
|
||||
testFunc(localBasePath, basePath)
|
||||
}
|
||||
31
internal/test/helper_files_test.go
Normal file
31
internal/test/helper_files_test.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package test_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPrepareTestFile(t *testing.T) {
|
||||
var path string
|
||||
test.WithTempDir(t, func(localBasePath, basePath string) {
|
||||
assert.True(t, strings.HasSuffix(localBasePath, strings.ToLower(t.Name())))
|
||||
assert.NotEmpty(t, basePath)
|
||||
|
||||
fileName := "example.jpg"
|
||||
test.PrepareTestFile(t, fileName)
|
||||
|
||||
path = filepath.Join(localBasePath, basePath, fileName)
|
||||
_, err := os.Stat(path)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
_, err := os.Stat(path)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, os.ErrNotExist)
|
||||
}
|
||||
64
internal/test/helper_mappers.go
Normal file
64
internal/test/helper_mappers.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GetMapFromStructByTag returns a map of a given struct using a tag name as key and
|
||||
// the string of the property value as value.
|
||||
// inspired by: https://stackoverflow.com/questions/55879028/golang-get-structs-field-name-by-json-tag
|
||||
func GetMapFromStructByTag(tag string, input any) map[string]string {
|
||||
res := make(map[string]string)
|
||||
|
||||
inputType := reflect.TypeOf(input)
|
||||
if inputType.Kind() != reflect.Struct {
|
||||
return res
|
||||
}
|
||||
|
||||
val := reflect.ValueOf(input)
|
||||
|
||||
for i := 0; i < inputType.NumField(); i++ {
|
||||
f := inputType.Field(i)
|
||||
tagvalue := strings.Split(f.Tag.Get(tag), ",")[0] // use split to ignore tag "options" like omitempty, etc.
|
||||
if tagvalue == "" {
|
||||
continue
|
||||
}
|
||||
field := val.Field(i)
|
||||
if field.Kind() == reflect.Ptr {
|
||||
if !field.IsNil() {
|
||||
res[tagvalue] = fmt.Sprintf("%v", field.Elem().Interface())
|
||||
}
|
||||
} else {
|
||||
res[tagvalue] = fmt.Sprintf("%v", field.Interface())
|
||||
}
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
func GetMapFromStruct(input any) map[string]string {
|
||||
res := make(map[string]string)
|
||||
|
||||
inputType := reflect.TypeOf(input)
|
||||
if inputType.Kind() != reflect.Struct {
|
||||
return res
|
||||
}
|
||||
|
||||
val := reflect.ValueOf(input)
|
||||
|
||||
for i := 0; i < inputType.NumField(); i++ {
|
||||
field := inputType.Field(i)
|
||||
fieldValue := val.Field(i)
|
||||
if fieldValue.Kind() == reflect.Ptr {
|
||||
if !fieldValue.IsNil() {
|
||||
res[field.Name] = fmt.Sprintf("%v", fieldValue.Elem().Interface())
|
||||
}
|
||||
} else {
|
||||
res[field.Name] = fmt.Sprintf("%v", fieldValue.Interface())
|
||||
}
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
81
internal/test/helper_mappers_test.go
Normal file
81
internal/test/helper_mappers_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package test_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"github.com/go-openapi/swag"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGetMapFromStruct(t *testing.T) {
|
||||
type tmp struct {
|
||||
A string
|
||||
B int
|
||||
C interface{}
|
||||
D float32
|
||||
E bool
|
||||
F *string
|
||||
G *int
|
||||
}
|
||||
|
||||
data := tmp{
|
||||
A: "string",
|
||||
B: 1,
|
||||
C: tmp{
|
||||
A: "string2",
|
||||
},
|
||||
D: 2.3,
|
||||
E: true,
|
||||
F: swag.String("stringPtr"),
|
||||
G: swag.Int(5),
|
||||
}
|
||||
|
||||
xMap := test.GetMapFromStruct(data)
|
||||
assert.Len(t, xMap, 7)
|
||||
assert.Equal(t, "string", xMap["A"])
|
||||
assert.Equal(t, "1", xMap["B"])
|
||||
assert.Contains(t, xMap["C"], "string2")
|
||||
assert.Equal(t, "2.3", xMap["D"])
|
||||
assert.Equal(t, "true", xMap["E"])
|
||||
assert.Equal(t, "stringPtr", xMap["F"])
|
||||
assert.Equal(t, "5", xMap["G"])
|
||||
}
|
||||
|
||||
func TestGetMapFromStructByTag(t *testing.T) {
|
||||
type tmp struct {
|
||||
A string `x:"1,omitempty" y:"2"`
|
||||
B int `x:"3"`
|
||||
C interface{} `x:"12"`
|
||||
D float32 `x:"2"`
|
||||
E bool `x:"5"`
|
||||
F *string `x:"4"`
|
||||
G *int `x:"6"`
|
||||
}
|
||||
|
||||
data := tmp{
|
||||
A: "string",
|
||||
B: 1,
|
||||
C: tmp{
|
||||
A: "string2",
|
||||
},
|
||||
D: 2.3,
|
||||
E: true,
|
||||
F: swag.String("stringPtr"),
|
||||
G: swag.Int(5),
|
||||
}
|
||||
|
||||
xMap := test.GetMapFromStructByTag("x", data)
|
||||
assert.Len(t, xMap, 7)
|
||||
assert.Equal(t, "string", xMap["1"])
|
||||
assert.Equal(t, "1", xMap["3"])
|
||||
assert.Contains(t, xMap["12"], "string2")
|
||||
assert.Equal(t, "2.3", xMap["2"])
|
||||
assert.Equal(t, "true", xMap["5"])
|
||||
assert.Equal(t, "stringPtr", xMap["4"])
|
||||
assert.Equal(t, "5", xMap["6"])
|
||||
|
||||
yMap := test.GetMapFromStructByTag("y", data)
|
||||
assert.Len(t, yMap, 1)
|
||||
assert.Equal(t, "string", yMap["2"])
|
||||
}
|
||||
139
internal/test/helper_request.go
Normal file
139
internal/test/helper_request.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"github.com/go-openapi/runtime"
|
||||
"github.com/go-openapi/strfmt"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
type GenericPayload map[string]interface{}
|
||||
type GenericArrayPayload []interface{}
|
||||
|
||||
func (g GenericPayload) Reader(t TestingT) *bytes.Reader {
|
||||
t.Helper()
|
||||
|
||||
b, err := json.Marshal(g)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to serialize payload: %v", err)
|
||||
}
|
||||
|
||||
return bytes.NewReader(b)
|
||||
}
|
||||
|
||||
func (g GenericArrayPayload) Reader(t TestingT) *bytes.Reader {
|
||||
t.Helper()
|
||||
|
||||
b, err := json.Marshal(g)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to serialize payload: %v", err)
|
||||
}
|
||||
|
||||
return bytes.NewReader(b)
|
||||
}
|
||||
|
||||
func PerformRequestWithParams(t TestingT, s *api.Server, method string, path string, body GenericPayload, headers http.Header, queryParams map[string]string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
if body == nil {
|
||||
return PerformRequestWithRawBody(t, s, method, path, nil, headers, queryParams)
|
||||
}
|
||||
|
||||
return PerformRequestWithRawBody(t, s, method, path, body.Reader(t), headers, queryParams)
|
||||
}
|
||||
|
||||
func PerformRequestWithArrayAndParams(t TestingT, s *api.Server, method string, path string, body GenericArrayPayload, headers http.Header, queryParams map[string]string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
if body == nil {
|
||||
return PerformRequestWithRawBody(t, s, method, path, nil, headers, queryParams)
|
||||
}
|
||||
|
||||
return PerformRequestWithRawBody(t, s, method, path, body.Reader(t), headers, queryParams)
|
||||
}
|
||||
|
||||
func PerformRequestWithRawBody(t TestingT, s *api.Server, method string, path string, body io.Reader, headers http.Header, queryParams map[string]string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequest(method, path, body)
|
||||
|
||||
if headers != nil {
|
||||
req.Header = headers
|
||||
}
|
||||
if body != nil && len(req.Header.Get(echo.HeaderContentType)) == 0 {
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
}
|
||||
|
||||
if queryParams != nil {
|
||||
q := req.URL.Query()
|
||||
for k, v := range queryParams {
|
||||
q.Add(k, v)
|
||||
}
|
||||
|
||||
req.URL.RawQuery = q.Encode()
|
||||
}
|
||||
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
s.Echo.ServeHTTP(res, req)
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
func PerformRequest(t TestingT, s *api.Server, method string, path string, body GenericPayload, headers http.Header) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
return PerformRequestWithParams(t, s, method, path, body, headers, nil)
|
||||
}
|
||||
|
||||
func PerformRequestWithArray(t TestingT, s *api.Server, method string, path string, body GenericArrayPayload, headers http.Header) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
return PerformRequestWithArrayAndParams(t, s, method, path, body, headers, nil)
|
||||
}
|
||||
|
||||
func ParseResponseBody(t TestingT, res *httptest.ResponseRecorder, v interface{}) {
|
||||
t.Helper()
|
||||
|
||||
if err := json.NewDecoder(res.Result().Body).Decode(&v); err != nil {
|
||||
t.Fatalf("Failed to parse response body: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func ParseResponseAndValidate(t TestingT, res *httptest.ResponseRecorder, v runtime.Validatable) {
|
||||
t.Helper()
|
||||
|
||||
ParseResponseBody(t, res, &v)
|
||||
|
||||
if err := v.Validate(strfmt.Default); err != nil {
|
||||
t.Fatalf("Failed to validate response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func HeadersWithAuth(t TestingT, token string) http.Header {
|
||||
t.Helper()
|
||||
|
||||
return HeadersWithConfigurableAuth(t, "Bearer", token)
|
||||
}
|
||||
|
||||
func HeadersWithConfigurableAuth(t TestingT, scheme string, token string) http.Header {
|
||||
t.Helper()
|
||||
|
||||
headers := http.Header{}
|
||||
headers.Set(echo.HeaderAuthorization, fmt.Sprintf("%s %s", scheme, token))
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
func HeadersWithAPIKeyAuth(t TestingT, token string) http.Header {
|
||||
t.Helper()
|
||||
|
||||
return HeadersWithConfigurableAuth(t, "APIKey", token)
|
||||
}
|
||||
354
internal/test/helper_snapshot.go
Normal file
354
internal/test/helper_snapshot.go
Normal file
@@ -0,0 +1,354 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
|
||||
"github.com/davecgh/go-spew/spew"
|
||||
"github.com/go-openapi/runtime"
|
||||
"github.com/pmezard/go-difflib/difflib"
|
||||
)
|
||||
|
||||
var (
|
||||
DefaultSnapshotDirPathAbs = filepath.Join(util.GetProjectRootDir(), "/test/testdata/snapshots")
|
||||
UpdateGoldenGlobal = util.GetEnvAsBool("TEST_UPDATE_GOLDEN", false)
|
||||
)
|
||||
|
||||
var defaultReplacer = func(s string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
var spewConfig = spew.ConfigState{
|
||||
Indent: " ",
|
||||
SortKeys: true, // maps should be spewed in a deterministic order
|
||||
DisablePointerAddresses: true, // don't spew the addresses of pointers
|
||||
DisableCapacities: true, // don't spew capacities of collections
|
||||
SpewKeys: true, // if unable to sort map keys then spew keys to strings and sort those
|
||||
}
|
||||
|
||||
type snapshoter struct {
|
||||
update bool
|
||||
label string
|
||||
replacer func(s string) string
|
||||
location string
|
||||
skips []string
|
||||
}
|
||||
|
||||
var Snapshoter = snapshoter{
|
||||
update: false,
|
||||
label: "",
|
||||
replacer: defaultReplacer,
|
||||
location: DefaultSnapshotDirPathAbs,
|
||||
}
|
||||
|
||||
// Save creates a formatted dump of the given data.
|
||||
// It will fail the test if the dump is different from the saved dump.
|
||||
// It will also fail if it is the creation or an update of the snapshot.
|
||||
// vastly inspired by https://github.com/bradleyjkemp/cupaloy
|
||||
// main reason for self implementation is the replacer function and general flexibility
|
||||
func (s snapshoter) Save(t TestingT, data ...interface{}) {
|
||||
t.Helper()
|
||||
err := os.MkdirAll(s.location, os.ModePerm)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dump := s.replacer(spewConfig.Sdump(data...))
|
||||
|
||||
s.save(t, dump)
|
||||
}
|
||||
|
||||
// Save creates a dump of the given data.
|
||||
// It will fail the test if the dump is different from the saved dump.
|
||||
// It will also fail if it is the creation or an update of the snapshot.
|
||||
// vastly inspired by https://github.com/bradleyjkemp/cupaloy
|
||||
// main reason for self implementation is the replacer function and general flexibility
|
||||
func (s snapshoter) SaveBytes(t TestingT, data []byte, fileExtensionOverride ...string) {
|
||||
t.Helper()
|
||||
err := os.MkdirAll(s.location, os.ModePerm)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s.saveBytes(t, data, fileExtensionOverride...)
|
||||
}
|
||||
|
||||
// SaveJSON creates a dump of the given data as JSON.
|
||||
// It will fail the test if the dump is different from the saved dump.
|
||||
// It will also fail if it is the creation or an update of the snapshot.
|
||||
// vastly inspired by https://github.com/bradleyjkemp/cupaloy
|
||||
// main reason for self implementation is the replacer function and general flexibility
|
||||
func (s snapshoter) SaveJSON(t TestingT, data any) {
|
||||
t.Helper()
|
||||
|
||||
// marshal data
|
||||
marshaled, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// indent data
|
||||
var prettyJSON bytes.Buffer
|
||||
if err := json.Indent(&prettyJSON, marshaled, "", "\t"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
jsonS := s
|
||||
// set custom replacer for JSON compared to dumps
|
||||
jsonS.replacer = func(target string) string {
|
||||
skipString := strings.Join(jsonS.skips, "|")
|
||||
re, err := regexp.Compile(fmt.Sprintf(`"(?i)(%s)": .*`, skipString))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// replace lines with property name + <redacted>
|
||||
return re.ReplaceAllString(target, `"$1": <redacted>,`)
|
||||
}
|
||||
|
||||
jsonS.label += "JSON"
|
||||
jsonS.SaveString(t, prettyJSON.String())
|
||||
}
|
||||
|
||||
// SaveUJSON is a short version for .Update(true).SaveJSON(...)
|
||||
func (s snapshoter) SaveUJSON(t TestingT, data any) {
|
||||
t.Helper()
|
||||
s.Update(true).SaveJSON(t, data)
|
||||
}
|
||||
|
||||
// SaveString creates a snapshot of the raw string.
|
||||
// Used to snapshot payloads or mails as formatted data.
|
||||
// It will fail the test if the dump is different from the saved dump.
|
||||
// It will also fail if it is the creation or an update of the snapshot.
|
||||
// vastly inspired by https://github.com/bradleyjkemp/cupaloy
|
||||
// main reason for self implementation is the replacer function and general flexibility
|
||||
func (s snapshoter) SaveString(t TestingT, data string) {
|
||||
t.Helper()
|
||||
err := os.MkdirAll(s.location, os.ModePerm)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data = s.replacer(data)
|
||||
|
||||
s.save(t, data)
|
||||
}
|
||||
|
||||
func (s snapshoter) SaveUString(t TestingT, data string) {
|
||||
t.Helper()
|
||||
s.Update(true).save(t, data)
|
||||
}
|
||||
|
||||
// SaveResponseAndValidate is used to create 2 snapshots for endpoint tests.
|
||||
// One snapshot will save the raw JSON response as indented JSON.
|
||||
// For the second snapshot the response will be parsed and validated using request helpers (helper_request.go)
|
||||
// Afterwards a dump of the response will be saved.
|
||||
// It will fail the test if the dump is different from the saved dump.
|
||||
// It will also fail if it is the creation or an update of the snapshot.
|
||||
func (s snapshoter) SaveResponseAndValidate(t TestingT, res *httptest.ResponseRecorder, v runtime.Validatable) {
|
||||
t.Helper()
|
||||
|
||||
// snapshot prettyfied json first
|
||||
var prettyJSON bytes.Buffer
|
||||
if err := json.Indent(&prettyJSON, res.Body.Bytes(), "", "\t"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
jsonS := s
|
||||
// set custom replacer for JSON compared to dumps
|
||||
jsonS.replacer = func(target string) string {
|
||||
skipString := strings.Join(jsonS.skips, "|")
|
||||
re, err := regexp.Compile(fmt.Sprintf(`"(?i)(%s)": .*`, skipString))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// replace lines with property name + <redacted>
|
||||
return re.ReplaceAllString(target, `"$1": <redacted>,`)
|
||||
}
|
||||
|
||||
jsonS.label += "JSON"
|
||||
jsonS.SaveString(t, prettyJSON.String())
|
||||
|
||||
// bind and snapshot response type struct
|
||||
ParseResponseAndValidate(t, res, v)
|
||||
s.Save(t, v)
|
||||
}
|
||||
|
||||
func (s snapshoter) SaveUResponseAndValidate(t TestingT, res *httptest.ResponseRecorder, v runtime.Validatable) {
|
||||
t.Helper()
|
||||
s.Update(true).SaveResponseAndValidate(t, res, v)
|
||||
}
|
||||
|
||||
// SaveU is a short version for .Update(true).Save(...)
|
||||
func (s snapshoter) SaveU(t TestingT, data ...interface{}) {
|
||||
t.Helper()
|
||||
s.Update(true).Save(t, data...)
|
||||
}
|
||||
|
||||
// Skip creates a custom replace function using a regex, this will replace any
|
||||
// replacer function set in the Snapshoter.
|
||||
// Each line of the formatted dump is matched against the property name defined in skip and
|
||||
// the value will be replaced to deal with generated values that change each test.
|
||||
func (s snapshoter) Skip(skip []string) snapshoter {
|
||||
s.skips = skip
|
||||
s.replacer = func(target string) string {
|
||||
skipString := fmt.Sprintf("\\s+%s", strings.Join(skip, "|\\s+"))
|
||||
|
||||
re, err := regexp.Compile(fmt.Sprintf("(?m)(%s): .*[^{]$", skipString))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
reStruct, err := regexp.Compile(fmt.Sprintf("((%s): .*){\n([^}]|\n)*}", skipString))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// replace lines with property name + <redacted>
|
||||
return reStruct.ReplaceAllString(re.ReplaceAllString(target, "$1: <redacted>,"), "$1 { <redacted> }")
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// Redact is a wrapper for Skip for easier usage with a variadic.
|
||||
func (s snapshoter) Redact(skip ...string) snapshoter {
|
||||
return s.Skip(skip)
|
||||
}
|
||||
|
||||
// Upadte is used to force an update for the snapshot. Will fail the test.
|
||||
func (s snapshoter) Update(update bool) snapshoter {
|
||||
s.update = update
|
||||
return s
|
||||
}
|
||||
|
||||
// Label is used to add a suffix to the snapshots golden file.
|
||||
func (s snapshoter) Label(label string) snapshoter {
|
||||
s.label = label
|
||||
return s
|
||||
}
|
||||
|
||||
// Replacer is used to define a custom replace function in order to replace
|
||||
// generated values (e.g. IDs).
|
||||
func (s snapshoter) Replacer(replacer func(s string) string) snapshoter {
|
||||
s.replacer = replacer
|
||||
return s
|
||||
}
|
||||
|
||||
// Location is used to save the golden file to a different location.
|
||||
func (s snapshoter) Location(location string) snapshoter {
|
||||
s.location = location
|
||||
return s
|
||||
}
|
||||
|
||||
func (s snapshoter) save(t TestingT, dump string) {
|
||||
t.Helper()
|
||||
snapshotName := fmt.Sprintf("%s%s", strings.ReplaceAll(t.Name(), "/", "-"), s.label)
|
||||
snapshotAbsPath := filepath.Join(s.location, fmt.Sprintf("%s.golden", snapshotName))
|
||||
|
||||
if s.update || UpdateGoldenGlobal {
|
||||
err := writeSnapshotString(snapshotAbsPath, dump)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Errorf("Updating snapshot: '%s'", snapshotName)
|
||||
return
|
||||
}
|
||||
|
||||
prevSnapBytes, err := os.ReadFile(snapshotAbsPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
err = writeSnapshotString(snapshotAbsPath, dump)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Errorf("No snapshot exists for name: '%s'. Creating new snapshot", snapshotName)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
prevSnap := string(prevSnapBytes)
|
||||
if prevSnap != dump {
|
||||
diff, err := difflib.GetUnifiedDiffString(difflib.UnifiedDiff{
|
||||
A: difflib.SplitLines(prevSnap),
|
||||
B: difflib.SplitLines(dump),
|
||||
FromFile: "Previous",
|
||||
ToFile: "Current",
|
||||
Context: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Error(fmt.Sprintf("%s: %s", snapshotName, diff))
|
||||
}
|
||||
}
|
||||
|
||||
func (s snapshoter) saveBytes(t TestingT, dump []byte, fileExtensionOverride ...string) {
|
||||
t.Helper()
|
||||
snapshotName := fmt.Sprintf("%s%s", strings.ReplaceAll(t.Name(), "/", "-"), s.label)
|
||||
|
||||
fileExtension := "golden"
|
||||
if len(fileExtensionOverride) > 0 {
|
||||
fileExtension = fileExtensionOverride[0]
|
||||
}
|
||||
|
||||
snapshotAbsPath := filepath.Join(s.location, fmt.Sprintf("%s.%s", snapshotName, fileExtension))
|
||||
|
||||
if s.update || UpdateGoldenGlobal {
|
||||
err := writeSnapshot(snapshotAbsPath, dump)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Errorf("Updating snapshot: '%s'", snapshotName)
|
||||
return
|
||||
}
|
||||
|
||||
prevSnapBytes, err := os.ReadFile(snapshotAbsPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
err = writeSnapshot(snapshotAbsPath, dump)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Errorf("No snapshot exists for name: '%s'. Creating new snapshot", snapshotName)
|
||||
return
|
||||
}
|
||||
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(prevSnapBytes, dump) {
|
||||
t.Error(fmt.Sprintf("%s: Byte Snapshot Diff", snapshotName))
|
||||
}
|
||||
}
|
||||
|
||||
func writeSnapshotString(absPath string, dump string) error {
|
||||
return writeSnapshot(absPath, []byte(dump))
|
||||
}
|
||||
|
||||
func writeSnapshot(absPath string, dump []byte) error {
|
||||
err := os.WriteFile(absPath, dump, os.FileMode(0644))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write snapshot: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
358
internal/test/helper_snapshot_test.go
Normal file
358
internal/test/helper_snapshot_test.go
Normal file
@@ -0,0 +1,358 @@
|
||||
package test_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test/mocks"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
|
||||
apitypes "allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"github.com/aarondl/sqlboiler/v4/types"
|
||||
"github.com/go-openapi/swag"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
helloWorld = "Hello World!"
|
||||
)
|
||||
|
||||
func TestSnapshot(t *testing.T) {
|
||||
if test.UpdateGoldenGlobal {
|
||||
t.Skip()
|
||||
}
|
||||
data := struct {
|
||||
A string
|
||||
B int
|
||||
C bool
|
||||
D *string
|
||||
}{
|
||||
A: "foo",
|
||||
B: 1,
|
||||
C: true,
|
||||
D: swag.String("bar"),
|
||||
}
|
||||
|
||||
test.Snapshoter.Save(t, data, helloWorld)
|
||||
}
|
||||
|
||||
func TestSnapshotWithReplacer(t *testing.T) {
|
||||
if test.UpdateGoldenGlobal {
|
||||
t.Skip()
|
||||
}
|
||||
randID, err := util.GenerateRandomBase64String(20)
|
||||
require.NoError(t, err)
|
||||
data := struct {
|
||||
ID string
|
||||
A string
|
||||
B int
|
||||
C bool
|
||||
D *string
|
||||
}{
|
||||
ID: randID,
|
||||
A: "foo",
|
||||
B: 1,
|
||||
C: true,
|
||||
D: swag.String("bar"),
|
||||
}
|
||||
|
||||
replacer := func(s string) string {
|
||||
re, err := regexp.Compile(`ID:.*"(.*)",`)
|
||||
require.NoError(t, err)
|
||||
return re.ReplaceAllString(s, "ID: <redacted>,")
|
||||
}
|
||||
test.Snapshoter.Replacer(replacer).Save(t, data)
|
||||
}
|
||||
|
||||
func TestSnapshotShouldFail(t *testing.T) {
|
||||
if test.UpdateGoldenGlobal {
|
||||
t.Skip()
|
||||
}
|
||||
data := struct {
|
||||
A string
|
||||
B int
|
||||
C bool
|
||||
D *string
|
||||
}{
|
||||
A: "fo",
|
||||
B: 1,
|
||||
C: true,
|
||||
D: swag.String("bar"),
|
||||
}
|
||||
|
||||
tMock := new(mocks.TestingT)
|
||||
tMock.On("Helper").Return()
|
||||
tMock.On("Name").Return("TestSnapshotShouldFail")
|
||||
tMock.On("Error", mock.Anything).Return()
|
||||
test.Snapshoter.Save(tMock, data, helloWorld)
|
||||
tMock.AssertNotCalled(t, "Fatal")
|
||||
tMock.AssertNotCalled(t, "Fatalf")
|
||||
tMock.AssertCalled(t, "Error", mock.Anything)
|
||||
}
|
||||
|
||||
func TestSnapshotWithUpdate(t *testing.T) {
|
||||
if test.UpdateGoldenGlobal {
|
||||
t.Skip()
|
||||
}
|
||||
data := struct {
|
||||
A string
|
||||
B int
|
||||
C bool
|
||||
D *string
|
||||
}{
|
||||
A: "fo",
|
||||
B: 1,
|
||||
C: true,
|
||||
D: swag.String("bar"),
|
||||
}
|
||||
|
||||
tMock := new(mocks.TestingT)
|
||||
tMock.On("Helper").Return()
|
||||
tMock.On("Name").Return("TestSnapshotWithUpdate")
|
||||
tMock.On("Errorf", mock.Anything, mock.Anything).Return()
|
||||
test.Snapshoter.Update(true).Save(tMock, data, helloWorld)
|
||||
tMock.AssertNotCalled(t, "Error")
|
||||
tMock.AssertNotCalled(t, "Fatal")
|
||||
tMock.AssertCalled(t, "Errorf", mock.Anything, mock.Anything)
|
||||
}
|
||||
|
||||
func TestSnapshotNotExists(t *testing.T) {
|
||||
if test.UpdateGoldenGlobal {
|
||||
t.Skip()
|
||||
}
|
||||
data := struct {
|
||||
A string
|
||||
B int
|
||||
C bool
|
||||
D *string
|
||||
}{
|
||||
A: "foo",
|
||||
B: 1,
|
||||
C: true,
|
||||
D: swag.String("bar"),
|
||||
}
|
||||
|
||||
defer func() {
|
||||
os.Remove(filepath.Join(test.DefaultSnapshotDirPathAbs, "TestSnapshotNotExists.golden"))
|
||||
}()
|
||||
|
||||
tMock := new(mocks.TestingT)
|
||||
tMock.On("Helper").Return()
|
||||
tMock.On("Name").Return("TestSnapshotNotExists")
|
||||
tMock.On("Fatalf", mock.Anything, mock.Anything).Return()
|
||||
tMock.On("Fatal", mock.Anything).Return()
|
||||
tMock.On("Error", mock.Anything).Return()
|
||||
tMock.On("Errorf", mock.Anything, mock.Anything).Return()
|
||||
test.Snapshoter.Save(tMock, data, helloWorld)
|
||||
tMock.AssertNotCalled(t, "Error")
|
||||
tMock.AssertNotCalled(t, "Fatal")
|
||||
tMock.AssertCalled(t, "Errorf", mock.Anything, mock.Anything)
|
||||
}
|
||||
|
||||
func TestSnapshotSkipFields(t *testing.T) {
|
||||
if test.UpdateGoldenGlobal {
|
||||
t.Skip()
|
||||
}
|
||||
randID, err := util.GenerateRandomBase64String(20)
|
||||
require.NoError(t, err)
|
||||
data := struct {
|
||||
ID string
|
||||
A string
|
||||
B int
|
||||
C bool
|
||||
D *string
|
||||
}{
|
||||
ID: randID,
|
||||
A: "foo",
|
||||
B: 1,
|
||||
C: true,
|
||||
D: swag.String("bar"),
|
||||
}
|
||||
|
||||
test.Snapshoter.Skip([]string{"ID"}).Save(t, data)
|
||||
}
|
||||
|
||||
func TestSnapshotSkipPrefixedFields(t *testing.T) {
|
||||
if test.UpdateGoldenGlobal {
|
||||
t.Skip()
|
||||
}
|
||||
|
||||
data := struct {
|
||||
ID string
|
||||
OtherIDStr string
|
||||
OtherIDInt int
|
||||
OtherIDBool bool
|
||||
OtherIDPTR *string
|
||||
OtherIDStruct struct {
|
||||
ID string
|
||||
}
|
||||
}{
|
||||
ID: "foo",
|
||||
OtherIDStr: "id str",
|
||||
OtherIDInt: 4,
|
||||
OtherIDBool: true,
|
||||
OtherIDPTR: swag.String("ID str ptr"),
|
||||
OtherIDStruct: struct{ ID string }{
|
||||
ID: "foo",
|
||||
},
|
||||
}
|
||||
|
||||
test.Snapshoter.Skip([]string{"ID"}).Save(t, data)
|
||||
}
|
||||
|
||||
func TestSnapshotSkipMultilineFields(t *testing.T) {
|
||||
if test.UpdateGoldenGlobal {
|
||||
t.Skip()
|
||||
}
|
||||
randID, err := util.GenerateRandomBase64String(20)
|
||||
require.NoError(t, err)
|
||||
data := struct {
|
||||
ID string
|
||||
A string
|
||||
B int
|
||||
C bool
|
||||
D interface{}
|
||||
E []string
|
||||
F map[string]int
|
||||
}{
|
||||
ID: randID,
|
||||
A: "foo",
|
||||
B: 1,
|
||||
C: true,
|
||||
D: struct {
|
||||
Foo string
|
||||
Bar int
|
||||
}{
|
||||
Foo: "skip me",
|
||||
Bar: 3,
|
||||
},
|
||||
E: []string{"skip me", "skip me too"},
|
||||
F: map[string]int{
|
||||
"skip me": 1,
|
||||
"skip me too": 2,
|
||||
"skip me three": 3,
|
||||
},
|
||||
}
|
||||
|
||||
test.Snapshoter.Skip([]string{"ID", "D", "E", "F"}).Save(t, data)
|
||||
}
|
||||
|
||||
func TestSnapshotWithLabel(t *testing.T) {
|
||||
if test.UpdateGoldenGlobal {
|
||||
t.Skip()
|
||||
}
|
||||
data := struct {
|
||||
A string
|
||||
B int
|
||||
C bool
|
||||
D *string
|
||||
}{
|
||||
A: "foo",
|
||||
B: 1,
|
||||
C: true,
|
||||
D: swag.String("bar"),
|
||||
}
|
||||
|
||||
test.Snapshoter.Label("_A").Save(t, data)
|
||||
test.Snapshoter.Label("_B").Save(t, helloWorld)
|
||||
}
|
||||
|
||||
func TestSnapshotWithLocation(t *testing.T) {
|
||||
if test.UpdateGoldenGlobal {
|
||||
t.Skip()
|
||||
}
|
||||
data := struct {
|
||||
A string
|
||||
B int
|
||||
C bool
|
||||
D *string
|
||||
}{
|
||||
A: "foo",
|
||||
B: 1,
|
||||
C: true,
|
||||
D: swag.String("bar"),
|
||||
}
|
||||
|
||||
location := filepath.Join(util.GetProjectRootDir(), "/internal/test/testdata")
|
||||
test.Snapshoter.Location(location).Save(t, data)
|
||||
}
|
||||
|
||||
func TestSaveResponseAndValidate(t *testing.T) {
|
||||
if test.UpdateGoldenGlobal {
|
||||
t.Skip()
|
||||
}
|
||||
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
res := test.PerformRequest(t, s, "GET", "/api/v1/auth/userinfo", nil, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
|
||||
require.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
|
||||
var response apitypes.GetUserInfoResponse
|
||||
test.Snapshoter.Redact("Email", "UpdatedAt", "updated_at").SaveResponseAndValidate(t, res, &response)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSnapshotJSON(t *testing.T) {
|
||||
if test.UpdateGoldenGlobal {
|
||||
t.Skip()
|
||||
}
|
||||
|
||||
randID, err := util.GenerateRandomBase64String(20)
|
||||
require.NoError(t, err)
|
||||
|
||||
details := struct {
|
||||
ID string
|
||||
A string
|
||||
B int
|
||||
C bool
|
||||
D interface{}
|
||||
E []string
|
||||
F map[string]int
|
||||
}{
|
||||
ID: randID,
|
||||
A: "foo",
|
||||
B: 1,
|
||||
C: true,
|
||||
D: struct {
|
||||
Foo string
|
||||
Bar int
|
||||
}{
|
||||
Foo: "skip me",
|
||||
Bar: 3,
|
||||
},
|
||||
E: []string{"skip me", "skip me too"},
|
||||
F: map[string]int{
|
||||
"skip me": 1,
|
||||
"skip me too": 2,
|
||||
"skip me three": 3,
|
||||
},
|
||||
}
|
||||
|
||||
marshaled, err := json.Marshal(details)
|
||||
require.NoError(t, err)
|
||||
|
||||
test.Snapshoter.Redact("ID").SaveJSON(t, types.JSON(json.RawMessage(marshaled)))
|
||||
}
|
||||
|
||||
func TestSnapshotSaveBytesImage(t *testing.T) {
|
||||
if test.UpdateGoldenGlobal {
|
||||
t.Skip()
|
||||
}
|
||||
|
||||
filepath := filepath.Join(util.GetProjectRootDir(), "/test/testdata", "example.jpg")
|
||||
|
||||
// read file and save bytes
|
||||
content, err := os.ReadFile(filepath)
|
||||
require.NoError(t, err)
|
||||
|
||||
test.Snapshoter.SaveBytes(t, content, "jpg")
|
||||
}
|
||||
154
internal/test/mocks/TestingT.go
Normal file
154
internal/test/mocks/TestingT.go
Normal file
@@ -0,0 +1,154 @@
|
||||
// Code generated by mockery. DO NOT EDIT.
|
||||
// Initial code generated by mockery using a local installation.
|
||||
// The methods for the testing.T interface will not change that often so no
|
||||
// automatic generation of the mock is required.
|
||||
|
||||
package mocks
|
||||
|
||||
import mock "github.com/stretchr/testify/mock"
|
||||
|
||||
// TestingT is an autogenerated mock type for the TestingT type
|
||||
type TestingT struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// Cleanup provides a mock function with given fields: _a0
|
||||
func (_m *TestingT) Cleanup(_a0 func()) {
|
||||
_m.Called(_a0)
|
||||
}
|
||||
|
||||
// Error provides a mock function with given fields: args
|
||||
func (_m *TestingT) Error(args ...interface{}) {
|
||||
var _ca []interface{}
|
||||
_ca = append(_ca, args...)
|
||||
_m.Called(_ca...)
|
||||
}
|
||||
|
||||
// Errorf provides a mock function with given fields: format, args
|
||||
func (_m *TestingT) Errorf(format string, args ...interface{}) {
|
||||
var _ca []interface{}
|
||||
_ca = append(_ca, format)
|
||||
_ca = append(_ca, args...)
|
||||
_m.Called(_ca...)
|
||||
}
|
||||
|
||||
// Fail provides a mock function with given fields:
|
||||
func (_m *TestingT) Fail() {
|
||||
_m.Called()
|
||||
}
|
||||
|
||||
// FailNow provides a mock function with given fields:
|
||||
func (_m *TestingT) FailNow() {
|
||||
_m.Called()
|
||||
}
|
||||
|
||||
// Failed provides a mock function with given fields:
|
||||
func (_m *TestingT) Failed() bool {
|
||||
ret := _m.Called()
|
||||
|
||||
var r0 bool
|
||||
if rf, ok := ret.Get(0).(func() bool); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
r0 = ret.Get(0).(bool)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// Fatal provides a mock function with given fields: args
|
||||
func (_m *TestingT) Fatal(args ...interface{}) {
|
||||
var _ca []interface{}
|
||||
_ca = append(_ca, args...)
|
||||
_m.Called(_ca...)
|
||||
}
|
||||
|
||||
// Fatalf provides a mock function with given fields: format, args
|
||||
func (_m *TestingT) Fatalf(format string, args ...interface{}) {
|
||||
var _ca []interface{}
|
||||
_ca = append(_ca, format)
|
||||
_ca = append(_ca, args...)
|
||||
_m.Called(_ca...)
|
||||
}
|
||||
|
||||
// Helper provides a mock function with given fields:
|
||||
func (_m *TestingT) Helper() {
|
||||
_m.Called()
|
||||
}
|
||||
|
||||
// Log provides a mock function with given fields: args
|
||||
func (_m *TestingT) Log(args ...interface{}) {
|
||||
var _ca []interface{}
|
||||
_ca = append(_ca, args...)
|
||||
_m.Called(_ca...)
|
||||
}
|
||||
|
||||
// Logf provides a mock function with given fields: format, args
|
||||
func (_m *TestingT) Logf(format string, args ...interface{}) {
|
||||
var _ca []interface{}
|
||||
_ca = append(_ca, format)
|
||||
_ca = append(_ca, args...)
|
||||
_m.Called(_ca...)
|
||||
}
|
||||
|
||||
// Name provides a mock function with given fields:
|
||||
func (_m *TestingT) Name() string {
|
||||
ret := _m.Called()
|
||||
|
||||
var r0 string
|
||||
if rf, ok := ret.Get(0).(func() string); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
r0 = ret.Get(0).(string)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// Skip provides a mock function with given fields: args
|
||||
func (_m *TestingT) Skip(args ...interface{}) {
|
||||
var _ca []interface{}
|
||||
_ca = append(_ca, args...)
|
||||
_m.Called(_ca...)
|
||||
}
|
||||
|
||||
// SkipNow provides a mock function with given fields:
|
||||
func (_m *TestingT) SkipNow() {
|
||||
_m.Called()
|
||||
}
|
||||
|
||||
// Skipf provides a mock function with given fields: format, args
|
||||
func (_m *TestingT) Skipf(format string, args ...interface{}) {
|
||||
var _ca []interface{}
|
||||
_ca = append(_ca, format)
|
||||
_ca = append(_ca, args...)
|
||||
_m.Called(_ca...)
|
||||
}
|
||||
|
||||
// Skipped provides a mock function with given fields:
|
||||
func (_m *TestingT) Skipped() bool {
|
||||
ret := _m.Called()
|
||||
|
||||
var r0 bool
|
||||
if rf, ok := ret.Get(0).(func() bool); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
r0 = ret.Get(0).(bool)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// TempDir provides a mock function with given fields:
|
||||
func (_m *TestingT) TempDir() string {
|
||||
ret := _m.Called()
|
||||
|
||||
var r0 string
|
||||
if rf, ok := ret.Get(0).(func() string); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
r0 = ret.Get(0).(string)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
27
internal/test/test_clock.go
Normal file
27
internal/test/test_clock.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"github.com/dropbox/godropbox/time2"
|
||||
)
|
||||
|
||||
func GetMockClock(t *testing.T, clock time2.Clock) *time2.MockClock {
|
||||
t.Helper()
|
||||
mc, ok := clock.(*time2.MockClock)
|
||||
if !ok {
|
||||
t.Fatalf("invalid clock type, got %T, want *time2.MockClock", clock)
|
||||
}
|
||||
|
||||
return mc
|
||||
}
|
||||
|
||||
func SetMockClock(t *testing.T, s *api.Server, time time.Time) {
|
||||
t.Helper()
|
||||
|
||||
mockClock := GetMockClock(t, s.Clock)
|
||||
|
||||
mockClock.Set(time)
|
||||
}
|
||||
26
internal/test/test_clock_test.go
Normal file
26
internal/test/test_clock_test.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package test_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTestClock(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
now := time.Date(2025, 2, 5, 11, 42, 30, 0, time.UTC)
|
||||
|
||||
test.SetMockClock(t, s, now)
|
||||
|
||||
assert.Equal(t, now, s.Clock.Now())
|
||||
|
||||
clock := test.GetMockClock(t, s.Clock)
|
||||
require.NotNil(t, clock)
|
||||
|
||||
assert.Equal(t, now, clock.Now())
|
||||
})
|
||||
}
|
||||
382
internal/test/test_database.go
Normal file
382
internal/test/test_database.go
Normal file
@@ -0,0 +1,382 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5" //nolint:gosec
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/config"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
|
||||
pUtil "allaboutapps.dev/aw/go-starter/internal/util"
|
||||
dbutil "allaboutapps.dev/aw/go-starter/internal/util/db"
|
||||
"github.com/aarondl/sqlboiler/v4/boil"
|
||||
"github.com/allaboutapps/integresql-client-go"
|
||||
"github.com/allaboutapps/integresql-client-go/pkg/util"
|
||||
"github.com/pkg/errors"
|
||||
migrate "github.com/rubenv/sql-migrate"
|
||||
)
|
||||
|
||||
var (
|
||||
client *integresql.Client
|
||||
|
||||
// tracks IntegreSQL template initialization and hash relookup (to reidentify the pool from a precomputed poolID)
|
||||
poolInitMap = &sync.Map{} // "poolID" -> *sync.Once
|
||||
poolHashMap = &sync.Map{} // "poolID" -> "poolHash"
|
||||
|
||||
// we will compute a db template hash over the following dirs/files
|
||||
migDir = config.DatabaseMigrationFolder
|
||||
fixFile = filepath.Join(pUtil.GetProjectRootDir(), "/internal/test/fixtures/fixtures.go")
|
||||
selfFile = filepath.Join(pUtil.GetProjectRootDir(), "/internal/test/test_database.go")
|
||||
defaultPoolPaths = []string{migDir, fixFile, selfFile}
|
||||
)
|
||||
|
||||
//nolint:gochecknoinits
|
||||
func init() {
|
||||
// autoinitialize IntegreSQL client
|
||||
c, err := integresql.DefaultClientFromEnv()
|
||||
if err != nil {
|
||||
panic(errors.Wrap(err, "Failed to create new integresql-client"))
|
||||
}
|
||||
client = c
|
||||
|
||||
// pin migrate to use the globally defined `migrations` table identifier
|
||||
migrate.SetTable(config.DatabaseMigrationTable)
|
||||
}
|
||||
|
||||
// WithTestDatabase returns an isolated test database based on the current migrations and fixtures.
|
||||
func WithTestDatabase(t *testing.T, closure func(db *sql.DB)) {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
WithTestDatabaseContext(ctx, t, closure)
|
||||
}
|
||||
|
||||
// WithTestDatabaseContext returns an isolated test database based on the current migrations and fixtures.
|
||||
// The provided context will be used during setup (instead of the default background context).
|
||||
func WithTestDatabaseContext(ctx context.Context, t *testing.T, closure func(db *sql.DB)) {
|
||||
t.Helper()
|
||||
|
||||
poolID := strings.Join(defaultPoolPaths, ",")
|
||||
|
||||
// Get a hold of the &sync.Once{} for this integresql pool in this pkg scope...
|
||||
initOnce, _ := poolInitMap.LoadOrStore(poolID, &sync.Once{})
|
||||
doOnce, ok := initOnce.(*sync.Once)
|
||||
if !ok {
|
||||
t.Fatalf("Expected doOnce to be of type *sync.Once, but got %T", initOnce)
|
||||
}
|
||||
|
||||
doOnce.Do(func() {
|
||||
t.Helper()
|
||||
|
||||
// compute and store poolID -> poolHash map (computes hash of all files/dirs specified)
|
||||
poolHash := storePoolHash(t, poolID, defaultPoolPaths)
|
||||
|
||||
// properly build up the template database once
|
||||
execClosureNewIntegresTemplate(ctx, t, poolHash, func(db *sql.DB) error {
|
||||
t.Helper()
|
||||
|
||||
countMigrations, err := ApplyMigrations(t, db)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to apply migrations for %q: %v\n", poolHash, err)
|
||||
return err
|
||||
}
|
||||
t.Logf("Applied %d migrations for hash %q", countMigrations, poolHash)
|
||||
|
||||
countFixtures, err := ApplyTestFixtures(ctx, t, db)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to apply test fixtures for %q: %v\n", poolHash, err)
|
||||
return err
|
||||
}
|
||||
t.Logf("Applied %d test fixtures for hash %q", countFixtures, poolHash)
|
||||
|
||||
return nil
|
||||
})
|
||||
})
|
||||
|
||||
// execute closure in a new IntegreSQL database build from above template
|
||||
execClosureNewIntegresDatabase(ctx, t, getPoolHash(t, poolID), "WithTestDatabase", closure)
|
||||
}
|
||||
|
||||
type DatabaseDumpConfig struct {
|
||||
DumpFile string // required, absolute path to dump file
|
||||
ApplyMigrations bool // optional, default false
|
||||
ApplyTestFixtures bool // optional, default false
|
||||
}
|
||||
|
||||
// WithTestDatabaseFromDump returns an isolated test database based on a dump file.
|
||||
func WithTestDatabaseFromDump(t *testing.T, config DatabaseDumpConfig, closure func(db *sql.DB)) {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
WithTestDatabaseFromDumpContext(ctx, t, config, closure)
|
||||
}
|
||||
|
||||
// WithTestDatabaseFromDumpContext returns an isolated test database based on a dump file.
|
||||
// The provided context will be used during setup (instead of the default background context).
|
||||
func WithTestDatabaseFromDumpContext(ctx context.Context, t *testing.T, config DatabaseDumpConfig, closure func(db *sql.DB)) {
|
||||
t.Helper()
|
||||
|
||||
// DumpFile is mandadory.
|
||||
if config.DumpFile == "" {
|
||||
t.Fatal("DatabaseDumpConfig.DumpFile is mandadory and cannot be ''")
|
||||
}
|
||||
|
||||
// poolID must incorporate additional config args in the final hash
|
||||
fragments := fmt.Sprintf("?migrations=%v&fixtures=%v", config.ApplyMigrations, config.ApplyTestFixtures)
|
||||
poolID := strings.Join([]string{config.DumpFile, selfFile}, ",") + fragments
|
||||
|
||||
// Get a hold of the &sync.Once{} for this integresql pool in this pkg scope...
|
||||
initOnce, _ := poolInitMap.LoadOrStore(poolID, &sync.Once{})
|
||||
doOnce, ok := initOnce.(*sync.Once)
|
||||
if !ok {
|
||||
t.Fatalf("Expected doOnce to be of type *sync.Once, but got %T", initOnce)
|
||||
}
|
||||
|
||||
doOnce.Do(func() {
|
||||
t.Helper()
|
||||
|
||||
// compute and store poolID -> poolHash map (computes hash of all files/dirs specified)
|
||||
poolHash := storePoolHash(t, poolID, []string{config.DumpFile, selfFile}, fragments)
|
||||
|
||||
// properly build up the template database once
|
||||
execClosureNewIntegresTemplate(ctx, t, poolHash, func(db *sql.DB) error {
|
||||
t.Helper()
|
||||
|
||||
if err := ApplyDump(ctx, t, db, config.DumpFile); err != nil {
|
||||
t.Fatalf("Failed to apply dumps for %q: %v\n", poolHash, err)
|
||||
return err
|
||||
}
|
||||
t.Logf("Applied dump for hash %q", poolHash)
|
||||
|
||||
if config.ApplyMigrations {
|
||||
countMigrations, err := ApplyMigrations(t, db)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to apply migrations for %q: %v\n", poolHash, err)
|
||||
return err
|
||||
}
|
||||
t.Logf("Applied %d migrations for hash %q", countMigrations, poolHash)
|
||||
}
|
||||
|
||||
if config.ApplyTestFixtures {
|
||||
countFixtures, err := ApplyTestFixtures(ctx, t, db)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to apply test fixtures for %q: %v\n", poolHash, err)
|
||||
return err
|
||||
}
|
||||
t.Logf("Applied %d test fixtures for hash %q", countFixtures, poolHash)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
})
|
||||
|
||||
// execute closure in a new IntegreSQL database build from above template
|
||||
execClosureNewIntegresDatabase(ctx, t, getPoolHash(t, poolID), "WithTestDatabaseFromDump", closure)
|
||||
}
|
||||
|
||||
// WithTestDatabaseEmpty returns an isolated test database with no migrations applied or fixtures inserted.
|
||||
func WithTestDatabaseEmpty(t *testing.T, closure func(db *sql.DB)) {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
WithTestDatabaseEmptyContext(ctx, t, closure)
|
||||
}
|
||||
|
||||
// WithTestDatabaseEmptyContext returns an isolated test database with no migrations applied or fixtures inserted.
|
||||
// The provided context will be used during setup (instead of the default background context).
|
||||
func WithTestDatabaseEmptyContext(ctx context.Context, t *testing.T, closure func(db *sql.DB)) {
|
||||
t.Helper()
|
||||
|
||||
poolID := selfFile
|
||||
|
||||
// Get a hold of the &sync.Once{} for this integresql pool in this pkg scope...
|
||||
initOnce, _ := poolInitMap.LoadOrStore(poolID, &sync.Once{})
|
||||
doOnce, ok := initOnce.(*sync.Once)
|
||||
if !ok {
|
||||
t.Fatalf("Expected doOnce to be of type *sync.Once, but got %T", initOnce)
|
||||
}
|
||||
|
||||
doOnce.Do(func() {
|
||||
t.Helper()
|
||||
|
||||
// compute and store poolID -> poolHash map (computes hash of all files/dirs specified)
|
||||
poolHash := storePoolHash(t, poolID, []string{selfFile})
|
||||
|
||||
// properly build up the template database once (noop)
|
||||
execClosureNewIntegresTemplate(ctx, t, poolHash, func(_ *sql.DB) error {
|
||||
t.Helper()
|
||||
return nil
|
||||
})
|
||||
})
|
||||
|
||||
// execute closure in a new IntegreSQL database build from above template
|
||||
execClosureNewIntegresDatabase(ctx, t, getPoolHash(t, poolID), "WithTestDatabaseEmpty", closure)
|
||||
}
|
||||
|
||||
// Adds poolID to poolHashMap pointing to the final integresql hash
|
||||
// Expects hashPaths to be absolute paths to actual files or directories (its contents will be md5 hashed)
|
||||
// Optional fragments can be used to further enhance the computed md5
|
||||
func storePoolHash(t *testing.T, poolID string, hashPaths []string, fragments ...string) string {
|
||||
t.Helper()
|
||||
|
||||
// compute a new integreSQL pool hash
|
||||
poolHash, err := util.GetTemplateHash(hashPaths...)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create template hash for %v: %#v", poolID, err)
|
||||
}
|
||||
|
||||
// update the hash with optional provided fragments
|
||||
if len(fragments) > 0 {
|
||||
poolHash = fmt.Sprintf("%x", md5.Sum([]byte(poolHash+strings.Join(fragments, ",")))) //nolint:gosec
|
||||
}
|
||||
|
||||
// and point poolID to it (sideffect synchronized store!)
|
||||
poolHashMap.Store(poolID, poolHash) // save it for all runners
|
||||
|
||||
return poolHash
|
||||
}
|
||||
|
||||
// Gets precomputed integresql hash via poolID identifier from our synchronized map (see storePoolHash)
|
||||
func getPoolHash(t *testing.T, poolID string) string {
|
||||
t.Helper()
|
||||
|
||||
poolHash, ok := poolHashMap.Load(poolID)
|
||||
if !ok {
|
||||
t.Fatalf("Failed to get poolHash from poolID '%v'. Is poolHashMap initialized yet?", poolID)
|
||||
return ""
|
||||
}
|
||||
|
||||
hash, ok := poolHash.(string)
|
||||
if !ok {
|
||||
t.Fatalf("Expected poolHash to be of type string, but got %T", poolHash)
|
||||
return ""
|
||||
}
|
||||
|
||||
return hash
|
||||
}
|
||||
|
||||
// Executes closure on an integresql **template** database
|
||||
func execClosureNewIntegresTemplate(ctx context.Context, t *testing.T, poolHash string, closure func(db *sql.DB) error) {
|
||||
t.Helper()
|
||||
|
||||
if err := client.SetupTemplateWithDBClient(ctx, poolHash, closure); err != nil {
|
||||
// This error is exceptionally fatal as it hinders ANY future other
|
||||
// test execution with this hash as the template was *never* properly
|
||||
// setuped successfully. All GetTestDatabase will wait unti timeout
|
||||
// unless we interrupt them by discarding the base template...
|
||||
discardError := client.DiscardTemplate(ctx, poolHash)
|
||||
|
||||
if discardError != nil {
|
||||
t.Fatalf("Failed to setup template database, also discarding failed for poolHash %q: %v, %v", poolHash, err, discardError)
|
||||
}
|
||||
|
||||
t.Fatalf("Failed to setup template database (discarded) for poolHash %q: %v", poolHash, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Executes closure on an integresql **test** database (scaffolded from a template)
|
||||
func execClosureNewIntegresDatabase(ctx context.Context, t *testing.T, poolHash string, callee string, closure func(db *sql.DB)) {
|
||||
t.Helper()
|
||||
|
||||
testDatabase, err := client.GetTestDatabase(ctx, poolHash)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to obtain test database: %v", err)
|
||||
}
|
||||
|
||||
connectionString := testDatabase.Config.ConnectionString()
|
||||
t.Logf("%v: %q", callee, testDatabase.Config.Database)
|
||||
|
||||
db, err := sql.Open("postgres", connectionString)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to setup test database for connectionString %q: %v", connectionString, err)
|
||||
}
|
||||
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
t.Fatalf("Failed to ping test database for connectionString %q: %v", connectionString, err)
|
||||
}
|
||||
|
||||
closure(db)
|
||||
|
||||
// this database object is managed and should close automatically after running the test
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatalf("Failed to close db %q: %v", connectionString, err)
|
||||
}
|
||||
|
||||
// disallow any further refs to managed object after running the test
|
||||
//nolint: wastedassign
|
||||
db = nil
|
||||
}
|
||||
|
||||
// ApplyMigrations applies all current database migrations to db
|
||||
func ApplyMigrations(t *testing.T, db *sql.DB) (int, error) {
|
||||
t.Helper()
|
||||
|
||||
// In case an old default sql-migrate migration table (named "gorp_migrations") still exists we rename it to the new name equivalent
|
||||
// in sync with the settings in dbconfig.yml and config.DatabaseMigrationTable.
|
||||
if _, err := db.ExecContext(t.Context(), fmt.Sprintf("ALTER TABLE IF EXISTS gorp_migrations RENAME TO %s;", config.DatabaseMigrationTable)); err != nil {
|
||||
return 0, fmt.Errorf("failed to rename migrations table: %w", err)
|
||||
}
|
||||
|
||||
migrations := &migrate.FileMigrationSource{Dir: migDir}
|
||||
countMigrations, err := migrate.Exec(db, "postgres", migrations, migrate.Up)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to apply migrations: %w", err)
|
||||
}
|
||||
|
||||
return countMigrations, nil
|
||||
}
|
||||
|
||||
// ApplyTestFixtures applies all current test fixtures (insert) to db
|
||||
func ApplyTestFixtures(ctx context.Context, t *testing.T, db *sql.DB) (int, error) {
|
||||
t.Helper()
|
||||
|
||||
inserts := fixtures.Inserts()
|
||||
|
||||
// insert test fixtures in an auto-managed db transaction
|
||||
err := dbutil.WithTransaction(ctx, db, func(tx boil.ContextExecutor) error {
|
||||
t.Helper()
|
||||
for _, fixture := range inserts {
|
||||
if err := fixture.Insert(ctx, tx, boil.Infer()); err != nil {
|
||||
return fmt.Errorf("failed to insert fixture: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to apply test fixtures: %w", err)
|
||||
}
|
||||
|
||||
return len(inserts), nil
|
||||
}
|
||||
|
||||
// ApplyDump applies dumpFile (absolute path to .sql file) to db
|
||||
func ApplyDump(ctx context.Context, t *testing.T, db *sql.DB, dumpFile string) error {
|
||||
t.Helper()
|
||||
|
||||
// ensure file exists
|
||||
if _, err := os.Stat(dumpFile); err != nil {
|
||||
return fmt.Errorf("failed to stat dump file: %w", err)
|
||||
}
|
||||
|
||||
// we need to get the db name before being able to do anything.
|
||||
var targetDB string
|
||||
if err := db.QueryRowContext(ctx, "SELECT current_database();").Scan(&targetDB); err != nil {
|
||||
return fmt.Errorf("failed to get current database: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, "bash", "-c", fmt.Sprintf("cat %q | psql %q", dumpFile, targetDB)) //nolint:gosec
|
||||
combinedOutput, err := cmd.CombinedOutput()
|
||||
|
||||
if err != nil {
|
||||
return errors.Wrap(err, string(combinedOutput))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
229
internal/test/test_database_test.go
Normal file
229
internal/test/test_database_test.go
Normal file
@@ -0,0 +1,229 @@
|
||||
package test_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/config"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
pUtil "allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestWithTestDatabaseConcurrentUsage(t *testing.T) {
|
||||
wg := sync.WaitGroup{}
|
||||
wg.Add(4)
|
||||
|
||||
go func() {
|
||||
test.WithTestDatabase(t, func(db1 *sql.DB) {
|
||||
assert.NotNil(t, db1)
|
||||
wg.Done()
|
||||
})
|
||||
}()
|
||||
|
||||
go func() {
|
||||
test.WithTestDatabaseEmpty(t, func(db2 *sql.DB) {
|
||||
assert.NotNil(t, db2)
|
||||
wg.Done()
|
||||
})
|
||||
}()
|
||||
|
||||
go func() {
|
||||
test.WithTestDatabaseFromDump(t, test.DatabaseDumpConfig{DumpFile: filepath.Join(pUtil.GetProjectRootDir(), "/test/testdata/plain.sql")}, func(db3 *sql.DB) {
|
||||
assert.NotNil(t, db3)
|
||||
wg.Done()
|
||||
})
|
||||
}()
|
||||
|
||||
go func() {
|
||||
test.WithTestDatabaseFromDump(t, test.DatabaseDumpConfig{DumpFile: filepath.Join(pUtil.GetProjectRootDir(), "/test/testdata/users.sql")}, func(db4 *sql.DB) {
|
||||
assert.NotNil(t, db4)
|
||||
wg.Done()
|
||||
})
|
||||
}()
|
||||
|
||||
// the above will concurrently write to the database pool maps,
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestWithTestDatabase(t *testing.T) {
|
||||
test.WithTestDatabase(t, func(db1 *sql.DB) {
|
||||
test.WithTestDatabase(t, func(db2 *sql.DB) {
|
||||
var db1Name string
|
||||
err := db1.QueryRowContext(t.Context(), "SELECT current_database();").Scan(&db1Name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var db2Name string
|
||||
err = db2.QueryRowContext(t.Context(), "SELECT current_database();").Scan(&db2Name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
require.NotEqual(t, db1Name, db2Name)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestWithTestDatabaseFromDump(t *testing.T) {
|
||||
dumpFile := filepath.Join(pUtil.GetProjectRootDir(), "/test/testdata/users.sql")
|
||||
|
||||
test.WithTestDatabaseFromDump(t, test.DatabaseDumpConfig{DumpFile: dumpFile}, func(db1 *sql.DB) {
|
||||
test.WithTestDatabaseFromDump(t, test.DatabaseDumpConfig{DumpFile: dumpFile}, func(db2 *sql.DB) {
|
||||
var db1Name string
|
||||
if err := db1.QueryRowContext(t.Context(), "SELECT current_database();").Scan(&db1Name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var db2Name string
|
||||
if err := db2.QueryRowContext(t.Context(), "SELECT current_database();").Scan(&db2Name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
require.NotEqual(t, db1Name, db2Name)
|
||||
|
||||
if _, err := db2.ExecContext(t.Context(), "DELETE FROM users WHERE true;"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var userCount1 int
|
||||
if err := db1.QueryRowContext(t.Context(), "SELECT count(id) FROM users;").Scan(&userCount1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
require.Equal(t, 3, userCount1)
|
||||
|
||||
var userCount2 int
|
||||
if err := db2.QueryRowContext(t.Context(), "SELECT count(id) FROM users;").Scan(&userCount2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
require.Equal(t, 0, userCount2)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestWithTestDatabaseFromDumpAutoMigrateAndTestFixtures(t *testing.T) {
|
||||
dumpFile := filepath.Join(pUtil.GetProjectRootDir(), "/test/testdata/plain.sql")
|
||||
|
||||
test.WithTestDatabaseFromDump(t, test.DatabaseDumpConfig{DumpFile: dumpFile}, func(db0 *sql.DB) {
|
||||
test.WithTestDatabaseFromDump(t, test.DatabaseDumpConfig{DumpFile: dumpFile, ApplyMigrations: true}, func(db1 *sql.DB) {
|
||||
test.WithTestDatabaseFromDump(t, test.DatabaseDumpConfig{DumpFile: dumpFile, ApplyMigrations: true, ApplyTestFixtures: true}, func(db2 *sql.DB) {
|
||||
// db0: has only a plain dump
|
||||
// db1: has migrations
|
||||
// db2: has migrations and testFixtures
|
||||
|
||||
var db0Name string
|
||||
if err := db0.QueryRowContext(t.Context(), "SELECT current_database();").Scan(&db0Name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var db1Name string
|
||||
if err := db1.QueryRowContext(t.Context(), "SELECT current_database();").Scan(&db1Name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var db2Name string
|
||||
if err := db2.QueryRowContext(t.Context(), "SELECT current_database();").Scan(&db2Name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
require.NotEqual(t, db0Name, db1Name)
|
||||
require.NotEqual(t, db1Name, db2Name)
|
||||
require.NotEqual(t, db2Name, db0Name)
|
||||
|
||||
// expect hash to be different for all 3 databases!
|
||||
db0Hash := strings.Split(strings.Join(strings.Split(db0Name, "integresql_test_"), ""), "_")[0]
|
||||
db1Hash := strings.Split(strings.Join(strings.Split(db1Name, "integresql_test_"), ""), "_")[0]
|
||||
db2Hash := strings.Split(strings.Join(strings.Split(db2Name, "integresql_test_"), ""), "_")[0]
|
||||
|
||||
require.NotEqual(t, db0Hash, db1Hash)
|
||||
require.NotEqual(t, db1Hash, db2Hash)
|
||||
require.NotEqual(t, db2Hash, db0Hash)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestWithTestDatabaseFromDumpGorp(t *testing.T) {
|
||||
dumpFile := filepath.Join(pUtil.GetProjectRootDir(), "/test/testdata/uuid_extension_only.sql")
|
||||
|
||||
// migrate transforms gorp_migratons to migrations
|
||||
test.WithTestDatabaseFromDump(t, test.DatabaseDumpConfig{DumpFile: dumpFile, ApplyMigrations: true}, func(db *sql.DB) {
|
||||
// check that we properly renamed the "gorp_migrations" migration tracking table to config.DatabaseMigrationTable
|
||||
var migrationsTableName string
|
||||
if err := db.QueryRowContext(t.Context(), fmt.Sprintf("SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = '%s';", config.DatabaseMigrationTable)).Scan(&migrationsTableName); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
require.Equal(t, config.DatabaseMigrationTable, migrationsTableName)
|
||||
|
||||
// check that gorp_migrations does not exist!
|
||||
var gorp string
|
||||
err := db.QueryRowContext(t.Context(), "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'gorp_migrations';").Scan(&gorp)
|
||||
|
||||
require.Error(t, err)
|
||||
|
||||
// we can expect that the '20200428064736-install-extension-uuid.sql' migration within the uuid_extension_only.sql is a very stable migrations.
|
||||
// let's check if other migrations were applied...
|
||||
var migrationsCount int
|
||||
err = db.QueryRowContext(t.Context(), "SELECT COUNT(table_name) FROM information_schema.tables WHERE table_schema = 'public';").Scan(&migrationsCount)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Greater(t, migrationsCount, 1)
|
||||
})
|
||||
|
||||
// with no migrate, gorp_migrations still exists:
|
||||
test.WithTestDatabaseFromDump(t, test.DatabaseDumpConfig{DumpFile: dumpFile, ApplyMigrations: false}, func(db *sql.DB) {
|
||||
// check that "gorp_migrations" migration tracking table still exists (as we have not set ApplyMigrations to true!)
|
||||
var migrationsTableName string
|
||||
if err := db.QueryRowContext(t.Context(), "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'gorp_migrations';").Scan(&migrationsTableName); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
require.Equal(t, "gorp_migrations", migrationsTableName)
|
||||
})
|
||||
}
|
||||
|
||||
func TestWithTestDatabaseEmpty(t *testing.T) {
|
||||
test.WithTestDatabaseEmpty(t, func(db1 *sql.DB) {
|
||||
test.WithTestDatabaseEmpty(t, func(db2 *sql.DB) {
|
||||
var db1Name string
|
||||
err := db1.QueryRowContext(t.Context(), "SELECT current_database();").Scan(&db1Name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var db2Name string
|
||||
err = db2.QueryRowContext(t.Context(), "SELECT current_database();").Scan(&db2Name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
require.NotEqual(t, db1Name, db2Name)
|
||||
|
||||
// test apply migrations + fixtures to a empty database 1
|
||||
_, err = test.ApplyMigrations(t, db1)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = test.ApplyTestFixtures(t.Context(), t, db1)
|
||||
require.NoError(t, err)
|
||||
|
||||
// test apply dump to a empty database 2
|
||||
dumpFile := filepath.Join(pUtil.GetProjectRootDir(), "/test/testdata/users.sql")
|
||||
err = test.ApplyDump(t.Context(), t, db2, dumpFile)
|
||||
require.NoError(t, err)
|
||||
|
||||
// check user count
|
||||
var usrCount int
|
||||
if err := db1.QueryRowContext(t.Context(), "SELECT count(id) FROM users;").Scan(&usrCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
require.Equal(t, 4, usrCount)
|
||||
})
|
||||
})
|
||||
}
|
||||
66
internal/test/test_mailer.go
Normal file
66
internal/test/test_mailer.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/config"
|
||||
"allaboutapps.dev/aw/go-starter/internal/mailer"
|
||||
"allaboutapps.dev/aw/go-starter/internal/mailer/transport"
|
||||
"github.com/jordan-wright/email"
|
||||
)
|
||||
|
||||
const (
|
||||
TestMailerDefaultSender = "test@example.com"
|
||||
)
|
||||
|
||||
func NewTestMailer(t *testing.T) *mailer.Mailer {
|
||||
t.Helper()
|
||||
|
||||
return newMailerWithTransporter(t, transport.NewMock())
|
||||
}
|
||||
|
||||
func NewSMTPMailerFromDefaultEnv(t *testing.T) *mailer.Mailer {
|
||||
t.Helper()
|
||||
|
||||
config := config.DefaultServiceConfigFromEnv().SMTP
|
||||
return newMailerWithTransporter(t, transport.NewSMTP(config))
|
||||
}
|
||||
|
||||
func GetTestMailerMockTransport(t *testing.T, m *mailer.Mailer) *transport.MockMailTransport {
|
||||
t.Helper()
|
||||
mt, ok := m.Transport.(*transport.MockMailTransport)
|
||||
if !ok {
|
||||
t.Fatalf("invalid mailer transport type, got %T, want *transport.MockMailTransport", m.Transport)
|
||||
}
|
||||
|
||||
return mt
|
||||
}
|
||||
|
||||
func newMailerWithTransporter(t *testing.T, transporter transport.MailTransporter) *mailer.Mailer {
|
||||
t.Helper()
|
||||
|
||||
config := config.DefaultServiceConfigFromEnv().Mailer
|
||||
config.DefaultSender = TestMailerDefaultSender
|
||||
|
||||
mailer := mailer.New(config, transporter)
|
||||
|
||||
if err := mailer.ParseTemplates(); err != nil {
|
||||
t.Fatal("Failed to parse mailer templates", err)
|
||||
}
|
||||
|
||||
return mailer
|
||||
}
|
||||
|
||||
func GetLastSentMail(t *testing.T, m *mailer.Mailer) *email.Email {
|
||||
t.Helper()
|
||||
|
||||
mt := GetTestMailerMockTransport(t, m)
|
||||
return mt.GetLastSentMail()
|
||||
}
|
||||
|
||||
func GetSentMails(t *testing.T, m *mailer.Mailer) []*email.Email {
|
||||
t.Helper()
|
||||
|
||||
mt := GetTestMailerMockTransport(t, m)
|
||||
return mt.GetSentMails()
|
||||
}
|
||||
67
internal/test/test_mailer_test.go
Normal file
67
internal/test/test_mailer_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package test_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/mailer/transport"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestWithTestMailer(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
//nolint:gosec
|
||||
passwordResetLink := "http://localhost/password/reset/12345"
|
||||
|
||||
mailer1 := test.NewTestMailer(t)
|
||||
mailer2 := test.NewTestMailer(t)
|
||||
err := mailer1.SendPasswordReset(ctx, fix.User1.Username.String, passwordResetLink)
|
||||
require.NoError(t, err)
|
||||
|
||||
sender2 := "test2@example.com"
|
||||
mailer2.Config.DefaultSender = sender2
|
||||
err = mailer2.SendPasswordReset(ctx, fix.User1.Username.String, passwordResetLink)
|
||||
require.NoError(t, err)
|
||||
|
||||
mt1 := test.GetTestMailerMockTransport(t, mailer1)
|
||||
mail := mt1.GetLastSentMail()
|
||||
mails := mt1.GetSentMails()
|
||||
|
||||
assert.Equal(t, mail, test.GetLastSentMail(t, mailer1))
|
||||
assert.Equal(t, mails, test.GetSentMails(t, mailer1))
|
||||
|
||||
require.NotNil(t, mail)
|
||||
require.Len(t, mails, 1)
|
||||
assert.Equal(t, mailer1.Config.DefaultSender, mail.From)
|
||||
assert.Len(t, mail.To, 1)
|
||||
assert.Equal(t, fix.User1.Username.String, mail.To[0])
|
||||
assert.Equal(t, test.TestMailerDefaultSender, mail.From)
|
||||
assert.Equal(t, "Password reset", mail.Subject)
|
||||
assert.Contains(t, string(mail.HTML), passwordResetLink)
|
||||
|
||||
mt2 := test.GetTestMailerMockTransport(t, mailer2)
|
||||
mail = mt2.GetLastSentMail()
|
||||
mails = mt2.GetSentMails()
|
||||
|
||||
assert.Equal(t, mail, test.GetLastSentMail(t, mailer2))
|
||||
assert.Equal(t, mails, test.GetSentMails(t, mailer2))
|
||||
|
||||
require.NotNil(t, mail)
|
||||
require.Len(t, mails, 1)
|
||||
assert.Equal(t, mailer2.Config.DefaultSender, mail.From)
|
||||
assert.Len(t, mail.To, 1)
|
||||
assert.Equal(t, fix.User1.Username.String, mail.To[0])
|
||||
assert.Equal(t, sender2, mail.From)
|
||||
assert.Equal(t, "Password reset", mail.Subject)
|
||||
assert.Contains(t, string(mail.HTML), passwordResetLink)
|
||||
}
|
||||
|
||||
func TestWithSMTPMailerFromDefaultEnv(t *testing.T) {
|
||||
m := test.NewSMTPMailerFromDefaultEnv(t)
|
||||
require.NotNil(t, m)
|
||||
require.NotEmpty(t, m.Transport)
|
||||
assert.IsType(t, &transport.SMTPMailTransport{}, m.Transport)
|
||||
}
|
||||
28
internal/test/test_pusher.go
Normal file
28
internal/test/test_pusher.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/push"
|
||||
"allaboutapps.dev/aw/go-starter/internal/push/provider"
|
||||
)
|
||||
|
||||
func WithTestPusher(t *testing.T, closure func(p *push.Service, db *sql.DB)) {
|
||||
t.Helper()
|
||||
|
||||
WithTestDatabase(t, func(db *sql.DB) {
|
||||
t.Helper()
|
||||
closure(NewTestPusher(t, db), db)
|
||||
})
|
||||
}
|
||||
|
||||
func NewTestPusher(t *testing.T, db *sql.DB) *push.Service {
|
||||
t.Helper()
|
||||
|
||||
pushService := push.New(db)
|
||||
mockProvider := provider.NewMock(push.ProviderTypeFCM)
|
||||
pushService.RegisterProvider(mockProvider)
|
||||
|
||||
return pushService
|
||||
}
|
||||
93
internal/test/test_server.go
Normal file
93
internal/test/test_server.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/router"
|
||||
"allaboutapps.dev/aw/go-starter/internal/config"
|
||||
)
|
||||
|
||||
// WithTestServer returns a fully configured server (using the default server config).
|
||||
func WithTestServer(t *testing.T, closure func(s *api.Server)) {
|
||||
t.Helper()
|
||||
defaultConfig := config.DefaultServiceConfigFromEnv()
|
||||
WithTestServerConfigurable(t, defaultConfig, closure)
|
||||
}
|
||||
|
||||
// WithTestServerFromDump returns a fully configured server (using the default server config) and allows for a database dump to be injected.
|
||||
func WithTestServerFromDump(t *testing.T, dumpConfig DatabaseDumpConfig, closure func(s *api.Server)) {
|
||||
t.Helper()
|
||||
defaultConfig := config.DefaultServiceConfigFromEnv()
|
||||
WithTestServerConfigurableFromDump(t, defaultConfig, dumpConfig, closure)
|
||||
}
|
||||
|
||||
// WithTestServerConfigurable returns a fully configured server, allowing for configuration using the provided server config.
|
||||
func WithTestServerConfigurable(t *testing.T, config config.Server, closure func(s *api.Server)) {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
WithTestServerConfigurableContext(ctx, t, config, closure)
|
||||
}
|
||||
|
||||
// WithTestServerConfigurableContext returns a fully configured server, allowing for configuration using the provided server config.
|
||||
// The provided context will be used during setup (instead of the default background context).
|
||||
func WithTestServerConfigurableContext(ctx context.Context, t *testing.T, config config.Server, closure func(s *api.Server)) {
|
||||
t.Helper()
|
||||
WithTestDatabaseContext(ctx, t, func(db *sql.DB) {
|
||||
t.Helper()
|
||||
execClosureNewTestServer(ctx, t, config, db, closure)
|
||||
})
|
||||
}
|
||||
|
||||
// WithTestServerConfigurableFromDump returns a fully configured server, allowing for configuration using the provided server config and a database dump to be injected.
|
||||
func WithTestServerConfigurableFromDump(t *testing.T, config config.Server, dumpConfig DatabaseDumpConfig, closure func(s *api.Server)) {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
WithTestServerConfigurableFromDumpContext(ctx, t, config, dumpConfig, closure)
|
||||
}
|
||||
|
||||
// WithTestServerConfigurableFromDumpContext returns a fully configured server, allowing for configuration using the provided server config and a database dump to be injected.
|
||||
// The provided context will be used during setup (instead of the default background context).
|
||||
func WithTestServerConfigurableFromDumpContext(ctx context.Context, t *testing.T, config config.Server, dumpConfig DatabaseDumpConfig, closure func(s *api.Server)) {
|
||||
t.Helper()
|
||||
WithTestDatabaseFromDump(t, dumpConfig, func(db *sql.DB) {
|
||||
t.Helper()
|
||||
execClosureNewTestServer(ctx, t, config, db, closure)
|
||||
})
|
||||
}
|
||||
|
||||
// Executes closure on a new test server with a pre-provided database
|
||||
func execClosureNewTestServer(ctx context.Context, t *testing.T, config config.Server, db *sql.DB, closure func(s *api.Server)) {
|
||||
t.Helper()
|
||||
|
||||
// https://stackoverflow.com/questions/43424787/how-to-use-next-available-port-in-http-listenandserve
|
||||
// You may use port 0 to indicate you're not specifying an exact port but you want a free, available port selected by the system
|
||||
config.Echo.ListenAddress = ":0"
|
||||
|
||||
// always use the mock pusher in tests
|
||||
config.Push.UseFCMProvider = false
|
||||
config.Push.UseMockProvider = true
|
||||
|
||||
s, err := api.InitNewServerWithDB(config, db, t)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to initialize server: %v", err)
|
||||
}
|
||||
|
||||
err = router.Init(s)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to init router: %v", err)
|
||||
}
|
||||
|
||||
closure(s)
|
||||
|
||||
// echo is managed and should close automatically after running the test
|
||||
if err := s.Echo.Shutdown(ctx); err != nil {
|
||||
t.Fatalf("failed to shutdown server: %v", err)
|
||||
}
|
||||
|
||||
// disallow any further refs to managed object after running the test
|
||||
//nolint: wastedassign
|
||||
s = nil
|
||||
}
|
||||
151
internal/test/test_server_test.go
Normal file
151
internal/test/test_server_test.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package test_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/config"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/go-openapi/strfmt"
|
||||
"github.com/go-openapi/swag"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type TestRequestPayload struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type TestResponsePayload struct {
|
||||
Hello string `json:"hello"`
|
||||
}
|
||||
|
||||
// Validate validates this request payload
|
||||
func (m *TestRequestPayload) Validate(_ strfmt.Registry) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalBinary interface implementation
|
||||
func (m *TestRequestPayload) MarshalBinary() ([]byte, error) {
|
||||
if m == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
res, err := swag.WriteJSON(m)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal binary: %w", err)
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// UnmarshalBinary interface implementation
|
||||
func (m *TestRequestPayload) UnmarshalBinary(b []byte) error {
|
||||
var res TestRequestPayload
|
||||
if err := swag.ReadJSON(b, &res); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal binary: %w", err)
|
||||
}
|
||||
*m = res
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate validates this response payload
|
||||
func (m *TestResponsePayload) Validate(_ strfmt.Registry) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalBinary interface implementation
|
||||
func (m *TestResponsePayload) MarshalBinary() ([]byte, error) {
|
||||
if m == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
res, err := swag.WriteJSON(m)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal binary: %w", err)
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// UnmarshalBinary interface implementation
|
||||
func (m *TestResponsePayload) UnmarshalBinary(b []byte) error {
|
||||
var res TestResponsePayload
|
||||
if err := swag.ReadJSON(b, &res); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal binary: %w", err)
|
||||
}
|
||||
*m = res
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestWithTestServer(t *testing.T) {
|
||||
test.WithTestServer(t, func(server1 *api.Server) {
|
||||
test.WithTestServer(t, func(server2 *api.Server) {
|
||||
path := "/testing-f679dbac-62bb-445d-b7e8-9f2c71ca382c"
|
||||
|
||||
// add an new route to s1 for the purpose of this test.
|
||||
server1.Echo.POST(path, func(c echo.Context) error {
|
||||
var body TestRequestPayload
|
||||
if err := util.BindAndValidateBody(c, &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
response := TestResponsePayload{
|
||||
Hello: body.Name,
|
||||
}
|
||||
|
||||
return util.ValidateAndReturn(c, http.StatusOK, &response)
|
||||
})
|
||||
|
||||
payload := test.GenericPayload{
|
||||
"name": "Mario",
|
||||
}
|
||||
|
||||
res1 := test.PerformRequest(t, server1, "POST", path, payload, nil)
|
||||
assert.Equal(t, http.StatusOK, res1.Result().StatusCode)
|
||||
|
||||
var response1 TestResponsePayload
|
||||
test.ParseResponseAndValidate(t, res1, &response1)
|
||||
|
||||
assert.Equal(t, "Mario", response1.Hello)
|
||||
|
||||
res2 := test.PerformRequest(t, server2, "POST", path, payload, nil)
|
||||
assert.Equal(t, http.StatusNotFound, res2.Result().StatusCode)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestWithTestServerFromDump(t *testing.T) {
|
||||
dumpFile := filepath.Join(util.GetProjectRootDir(), "/test/testdata/plain.sql")
|
||||
|
||||
serverConfig := config.DefaultServiceConfigFromEnv()
|
||||
dumpConfig := test.DatabaseDumpConfig{DumpFile: dumpFile, ApplyMigrations: true, ApplyTestFixtures: true}
|
||||
|
||||
test.WithTestServerFromDump(t, dumpConfig, func(server1 *api.Server) {
|
||||
test.WithTestServerConfigurableFromDump(t, serverConfig, dumpConfig, func(server2 *api.Server) {
|
||||
var db1Name string
|
||||
if err := server1.DB.QueryRowContext(t.Context(), "SELECT current_database();").Scan(&db1Name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var db2Name string
|
||||
if err := server2.DB.QueryRowContext(t.Context(), "SELECT current_database();").Scan(&db2Name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
require.NotEqual(t, db1Name, db2Name)
|
||||
|
||||
// same dumpConfig settings - must be same base template hash.
|
||||
db1Hash := strings.Split(strings.Join(strings.Split(db1Name, "integresql_test_"), ""), "_")[0]
|
||||
db2Hash := strings.Split(strings.Join(strings.Split(db2Name, "integresql_test_"), ""), "_")[0]
|
||||
|
||||
require.Equal(t, db1Hash, db2Hash)
|
||||
})
|
||||
})
|
||||
}
|
||||
2
internal/test/testdata/.env.test.local
vendored
Normal file
2
internal/test/testdata/.env.test.local
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# for a simple overwrite test via TestDotEnvLoadFileOrSkipTest
|
||||
SERVER_FRONTEND_BASE_URL=http://overwritten.dotenv.frontend.url.tld:3000
|
||||
6
internal/test/testdata/TestSnapshotWithLocation.golden
vendored
Normal file
6
internal/test/testdata/TestSnapshotWithLocation.golden
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
(struct { A string; B int; C bool; D *string }) {
|
||||
A: (string) (len=3) "foo",
|
||||
B: (int) 1,
|
||||
C: (bool) true,
|
||||
D: (*string)((len=3) "bar")
|
||||
}
|
||||
31
internal/test/testing_mock.go
Normal file
31
internal/test/testing_mock.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package test
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestingT is used to generate a mock of testing.T to enable testing
|
||||
// of helper methods which are using assert/require
|
||||
// Inspired by: https://github.com/uber-go/zap/blob/master/zaptest/testingt_test.go, commit 5b0fd114dcc089875ee61dfad3617c3a43c2e93e
|
||||
//
|
||||
//nolint:interfacebloat
|
||||
type TestingT interface {
|
||||
Cleanup(f func())
|
||||
Error(args ...interface{})
|
||||
Errorf(format string, args ...interface{})
|
||||
Fail()
|
||||
FailNow()
|
||||
Failed() bool
|
||||
Fatal(args ...interface{})
|
||||
Fatalf(format string, args ...interface{})
|
||||
Helper()
|
||||
Log(args ...interface{})
|
||||
Logf(format string, args ...interface{})
|
||||
Name() string
|
||||
Skip(args ...interface{})
|
||||
SkipNow()
|
||||
Skipf(format string, args ...interface{})
|
||||
Skipped() bool
|
||||
TempDir() string
|
||||
}
|
||||
|
||||
// used to ensure compatibility between this interface and testing.TB
|
||||
var _ TestingT = (testing.TB)(nil)
|
||||
Reference in New Issue
Block a user