backend-go/llm/TESTING_GUIDELINE.md
You write tests to constrain behavior, not to hit coverage targets. Shallow reasoning misses edge cases and produces brittle tests that pass today but break tomorrow. Every test you write should answer: "what contract does this code promise, and what breaks if someone violates it?"
Not all code needs tests. Skip tests for:
= works. If the constructor assigns s.timeout = cfg.Timeout, don't test it.oapi-codegen or similar tools is tested by the generator's own test suite.When unit and integration tests overlap: If integration tests cover the same behavior, prefer them and skip redundant unit tests. Integration tests verify real behavior; duplicating coverage with unit tests adds maintenance burden without value.
Keep unit tests for:
name field passed to t.Run.//go:build integration) to separate from unit tests.goleak.VerifyTestMain in TestMain to detect goroutine leaks.-race in CI.Follow the convention Test<FunctionName>_<Scenario>:
func TestGetSecretByName_ReturnsErrWhenNotFound(t *testing.T) { ... }
func TestListSecrets_FiltersByEnvironment(t *testing.T) { ... }
func TestExpandSecrets_HandlesCircularReferences(t *testing.T) { ... }
The function name anchors what's under test. The scenario describes the specific behavior being verified. Together they read as a sentence: "Test GetSecretByName returns err when not found."
Table-driven tests are the default style. They consolidate related scenarios that share the same setup/assertion shape into a single function. If multiple test setups can be organized together, prefer one table-driven test over many standalone functions.
Every entry MUST have a name field, and every entry MUST be run inside t.Run:
func TestResolveSecretPath_Normalization(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "root path stays unchanged",
input: "/",
expected: "/",
},
{
name: "trailing slash is stripped",
input: "/secrets/prod/",
expected: "/secrets/prod",
},
{
name: "empty string defaults to root",
input: "",
expected: "/",
},
{
name: "double slashes are collapsed",
input: "/secrets//prod",
expected: "/secrets/prod",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := ResolveSecretPath(tc.input)
assert.Equal(t, tc.expected, result)
})
}
}
Use them when multiple cases share the same arrange/act/assert structure but differ in inputs and expected outputs. Do NOT force unrelated scenarios into one table — if the setup or assertion logic diverges significantly between cases, write separate test functions.
For cases where each scenario needs slightly different setup or assertions, use function fields:
func TestPermissionChecker_SecretAccess(t *testing.T) {
tests := []struct {
name string
setup func(t *testing.T) *project.SecretAccessChecker
check func(checker *project.SecretAccessChecker) bool
allowed bool
}{
{
name: "read allowed when ability grants read on environment",
setup: func(t *testing.T) *project.SecretAccessChecker {
ability := buildAbility(t, project.SecretActionReadValue, "production", "/")
return project.NewSecretAccessChecker(ability)
},
check: func(c *project.SecretAccessChecker) bool {
return c.CanReadSecretValue("production", "/", "DB_HOST", nil)
},
allowed: true,
},
{
name: "read denied when ability lacks environment",
setup: func(t *testing.T) *project.SecretAccessChecker {
ability := buildAbility(t, project.SecretActionReadValue, "staging", "/")
return project.NewSecretAccessChecker(ability)
},
check: func(c *project.SecretAccessChecker) bool {
return c.CanReadSecretValue("production", "/", "DB_HOST", nil)
},
allowed: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
checker := tc.setup(t)
got := tc.check(checker)
assert.Equal(t, tc.allowed, got)
})
}
}
Mock interfaces, never concrete types. This aligns with the codebase rule that interfaces are consumer-defined.
Define mock structs in the test file, next to the test that uses them. Only stub the methods the test actually calls:
type mockSecretsService struct {
listFn func(ctx context.Context, opts secrets.ListOpts) ([]secrets.Secret, error)
}
func (m *mockSecretsService) ListSecrets(ctx context.Context, opts secrets.ListOpts) ([]secrets.Secret, error) {
return m.listFn(ctx, opts)
}
Usage in a test:
func TestListSecretsV4_CallsServiceWithResolvedPath(t *testing.T) {
var capturedOpts secrets.ListOpts
svc := &mockSecretsService{
listFn: func(_ context.Context, opts secrets.ListOpts) ([]secrets.Secret, error) {
capturedOpts = opts
return nil, nil
},
}
handler := secret.NewHandler(&secret.Deps{Secrets: svc})
_, err := handler.ListSecretsV4(ctx, &secret.ListSecretsV4ServiceRequestOptions{
Query: &secret.ListSecretsV4Query{
ProjectID: "proj-123",
Environment: "production",
SecretPath: nil, // should default to "/"
},
})
require.NoError(t, err)
assert.Equal(t, "/", capturedOpts.SecretPath)
}
Do NOT use a mocking framework that generates mocks for every interface in the codebase. Hand-rolled mocks are small, obvious, and live next to the test that needs them.
Integration tests run against real dependencies (Postgres via testcontainers, Redis, etc.) and are separated from unit tests with a build tag.
Every integration test file starts with:
//go:build integration
package mypackage_test
This keeps go test ./... fast by default. Integration tests run explicitly:
make test # runs: go test -race -tags=integration ./...
Use the testutil/infra package to spin up containers. The containers are shared across tests in the same package via TestMain:
//go:build integration
package secrets_test
import (
"fmt"
"os"
"testing"
"go.uber.org/goleak"
"github.com/infisical/api/internal/testutil/infra"
)
var stack *infra.Stack
func TestMain(m *testing.M) {
// Setup MUST come before m.Run() — goleak.VerifyTestMain won't work here
// because it calls m.Run() internally and never returns.
stack = infra.New().
WithPostgres().
WithRedis().
MustStart()
code := m.Run()
stack.Stop()
// Check for goroutine leaks only if tests passed
if code == 0 {
if err := goleak.Find(
goleak.IgnoreTopFunction("github.com/redis/go-redis/v9/internal/pool.(*ConnPool).reaper"),
); err != nil {
fmt.Fprintf(os.Stderr, "goleak: %v\n", err)
os.Exit(1)
}
}
os.Exit(code)
}
Each test gets a clean transaction that rolls back at the end, so tests never pollute each other:
func TestCreateSecret_PersistsToDatabase(t *testing.T) {
db := testutil.AcquireDB(t) // returns a pg.DB scoped to a rolled-back tx
svc := secrets.NewService(context.Background(), testutil.Logger(t), &secrets.Deps{
DB: db,
})
created, err := svc.CreateSecret(context.Background(), secrets.CreateOpts{
FolderID: testutil.SeedFolder(t, db, "production", "/"),
Key: "DB_PASSWORD",
Value: []byte("hunter2"),
})
require.NoError(t, err)
assert.Equal(t, "DB_PASSWORD", created.Key)
// Verify it's readable
fetched, err := svc.GetSecretByName(context.Background(), secrets.GetByNameOpts{
FolderID: created.FolderID,
Key: "DB_PASSWORD",
})
require.NoError(t, err)
assert.Equal(t, created.ID, fetched.ID)
}
Create small helper functions for common test data setup. These belong in testutil/ or as unexported helpers in the test file:
// testutil/seeds.go
func SeedFolder(t *testing.T, db pg.DB, env, path string) uuid.UUID {
t.Helper()
id := uuid.New()
_, err := db.Primary().Exec(context.Background(),
`INSERT INTO secret_folders (id, environment, path) VALUES (@id, @env, @path)`,
pgx.NamedArgs{"id": id, "env": env, "path": path},
)
require.NoError(t, err)
return id
}
Always call t.Helper() so failure messages point to the calling test, not the seed function.
Any package that spawns goroutines (background workers, watchers, connection pools) SHOULD check for leaks.
Warning: goleak.VerifyTestMain(m) calls m.Run() internally and never returns. If you need setup before tests run (e.g., starting containers), use goleak.Find after m.Run() instead:
func TestMain(m *testing.M) {
// Setup MUST come before tests run
teardown := setupInfrastructure()
code := m.Run()
teardown()
// Check for leaks only if tests passed
if code == 0 {
if err := goleak.Find(
goleak.IgnoreTopFunction("..."), // known benign leaks
); err != nil {
fmt.Fprintf(os.Stderr, "goleak: %v\n", err)
os.Exit(1)
}
}
os.Exit(code)
}
If no setup is needed, goleak.VerifyTestMain(m) is simpler:
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
If a known third-party goroutine is benign and cannot be stopped (e.g., a database driver's internal watcher), ignore it explicitly:
goleak.IgnoreTopFunction("github.com/jackc/pgx/v5/pgxpool.(*Pool).backgroundHealthCheck")
All tests — unit and integration — run with -race in CI. This catches data races that only manifest under concurrency. Design your tests accordingly:
t.Parallel(), ensure each subtest captures its loop variable (or use Go 1.22+ loop semantics).for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// tc is safe here in Go 1.22+; for older versions, shadow it:
// tc := tc
result := doSomething(tc.input)
assert.Equal(t, tc.expected, result)
})
}
Use require for preconditions that must hold for the rest of the test to make sense. Use assert for the actual verification:
func TestDecryptSecret_RoundTrip(t *testing.T) {
key, err := kms.GenerateDataKey(ctx)
require.NoError(t, err) // if this fails, nothing below is meaningful
ciphertext, err := kms.Encrypt(ctx, key, []byte("plaintext"))
require.NoError(t, err)
plaintext, err := kms.Decrypt(ctx, key, ciphertext)
assert.NoError(t, err) // the behavior we're actually testing
assert.Equal(t, []byte("plaintext"), plaintext)
}
Do NOT use testify's suite package. Standard Test functions with table-driven subtests are simpler and compose better with Go's tooling (go test -run, -count, -parallel).
Test the sad paths. Services in this codebase return structured errors via errutil. Verify both the error type and the message context:
func TestGetSecret_ReturnsNotFoundForMissingKey(t *testing.T) {
svc := setupService(t)
_, err := svc.GetSecretByName(ctx, secrets.GetByNameOpts{
FolderID: folderID,
Key: "NONEXISTENT",
})
require.Error(t, err)
var appErr *errutil.Error
require.ErrorAs(t, err, &appErr)
assert.Equal(t, errutil.StatusNotFound, appErr.Status)
}
Follow the codebase constructor convention (ctx, logger, deps) in tests too. Use context.Background() for ctx and a test-scoped logger:
func TestSomething(t *testing.T) {
ctx := context.Background()
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))
svc := myservice.NewService(ctx, logger, &myservice.Deps{
DB: testDB,
})
// ...
}
Or use a testutil.Logger(t) helper that ties log output to t.Log so it only appears on failure:
// testutil/logger.go
func Logger(t *testing.T) *slog.Logger {
t.Helper()
return slog.New(slog.NewTextHandler(testWriter{t}, &slog.HandlerOptions{Level: slog.LevelDebug}))
}
type testWriter struct{ t *testing.T }
func (w testWriter) Write(p []byte) (int, error) {
w.t.Helper()
w.t.Log(string(p))
return len(p), nil
}
make test passes (integration tests with -race).make lint passes — no //nolint without a justification comment.name and runs under t.Run.//go:build integration tag.t.Helper().require is used for preconditions, assert for verifications.