On this page

Testing databases

Run database tests against the engine used in production. SQLite can check basic transaction flow, but it cannot establish PostgreSQL or MySQL locking, SQL dialect, constraint, or recovery behavior.

Use an isolated database#

internal/platform/testdb.Open(t) selects PostgreSQL by default, or MySQL when DATABASE_TEST_DRIVER=mysql. It reads the matching DATABASE_TEST_POSTGRES or DATABASE_TEST_MYSQL DSN and skips when that variable is absent.

The helper creates a unique PostgreSQL schema or MySQL database, applies the cache migration templates with Goose, and registers cleanup. It does not apply your module's application migrations. Add those through the migration registry/provider when testing application queries, or create a small explicit test table for a transaction contract as below.

Use only disposable database credentials. The helper creates and drops schemas or databases, so the test account needs those permissions. Application accounts do not need the same privileges. Isolation is within the selected database server; it does not isolate server-wide configuration or resource limits.

Apply the schema your test needs#

testdb.Open installs cache fixture tables, not all business tables. For an Identity, Media, or Notification repository test, register the matching embedded migration sources and apply their combined history in the isolated schema. tests/modules/profile_test.go and the existing Identity repository tests show complete fixtures for those owners.

Avoid creating an approximate table in a repository test when its real constraints are part of the behavior. Small explicit tables are useful for generic transaction manager tests, such as the rollback example below.

Verify a rollback#

Place this complete example in tests/identity/rollback_example_test.go, outside the service and repository packages. The internal fixture is for tests, not an application dependency.

package identity_test

import (
	"context"
	"errors"
	"testing"
	"time"

	"github.com/wisalgroup/labena/internal/platform/testdb"
	"github.com/wisalgroup/labena/pkg/database"
)

func TestRollbackExample(t *testing.T) {
	db := testdb.Open(t)
	ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
	defer cancel()
	if _, err := db.ExecContext(ctx, "CREATE TABLE test_entries (id INTEGER PRIMARY KEY)"); err != nil {
		t.Fatal(err)
	}
	manager, err := database.NewTxManager(db, nil)
	if err != nil {
		t.Fatal(err)
	}
	rejected := errors.New("operation rejected")
	err = manager.Execute(ctx, func(ctx context.Context) error {
		executor, err := database.Executor(ctx, db)
		if err != nil {
			return err
		}
		if _, err := executor.ExecContext(ctx, "INSERT INTO test_entries (id) VALUES (?)", 1); err != nil {
			return err
		}
		return rejected
	})
	if !errors.Is(err, rejected) {
		t.Fatalf("error=%v", err)
	}
	var count int
	if err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM test_entries").Scan(&count); err != nil {
		t.Fatal(err)
	}
	if count != 0 {
		t.Fatalf("rollback left %d rows", count)
	}
}

The insert uses database.Executor(ctx, db), so it participates in the managed transaction. The final query uses the pool after the transaction returns. Calling the raw pool inside the callback would bypass the transaction.

Run PostgreSQL and MySQL suites#

Set DATABASE_TEST_POSTGRES or DATABASE_TEST_MYSQL to a disposable database DSN, then run:

DATABASE_TEST_DRIVER=postgres go test -race -count=1 ./pkg/database ./pkg/database/migrate ./internal/platform/testdb
DATABASE_TEST_DRIVER=mysql go test -race -count=1 ./pkg/database ./pkg/database/migrate ./internal/platform/testdb

Add your module's package path to run its integration tests too. Both DSNs may be set: engine-specific tests use their own DSN; DATABASE_TEST_DRIVER selects the backend for shared transaction fixtures. Without backend settings, some database tests run on SQLite and the external suites skip. Inspect verbose output rather than treating that run as PostgreSQL or MySQL verification.

Cover transaction boundaries#

Case Assert
Successful operation Changes are visible after commit
Callback failure No participating writes remain
Nested operation fails The outer transaction cannot commit even if the inner error is swallowed
Context cancellation No later business work proceeds; rollback is attempted
Different pool in a transaction The operation is rejected
Concurrent uniqueness conflict The database constraint protects the invariant
SQL plus notification intent Verify both commit/rollback together; tests/modules/profile_test.go exercises the existing PostgreSQL ledger
SQL plus arbitrary Redis/GoChannel publication No atomic guarantee is supplied; design and verify an explicit outbox if required

The existing database tests also cover savepoints and transaction ownership. Do not share a managed transaction context concurrently between goroutines. For concurrency tests, coordinate independent transactions and assert the final stored state; sequential check-then-insert calls do not reproduce a race.

Migrations and cleanup#

Use a fresh schema to test migration application and the resulting constraints. For upgrade tests, apply the earlier schema, insert representative data, then apply the new migration. A fresh install alone does not test an upgrade.

Let each fixture own its resources and clean them up after users stop. Do not truncate shared development tables. Do not wrap every test in a rollback transaction when the behavior under test must commit, publish, or use another connection.

See Transactions and Migrations for the production APIs and ownership rules.

Diagnose a skipped or failing integration test#

Symptom Check
Test says SKIP Matching test DSN is absent; run with -v to see the reason
Permission denied creating schema/database Use a disposable test account with fixture privileges
A module table is missing Register and apply that module's actual migrations
Concurrent uniqueness test passes only sequentially Coordinate separate transactions to overlap intentionally
Tests pass on SQLite but fail on PostgreSQL SQL, constraints, isolation, and locking differ; the production engine is authoritative
State leaks between tests Use isolated fixtures and wait for goroutines before cleanup

Keep query deadlines bounded, including failure paths. A database error is not always proof that no write committed; tests for retries should assert final durable state and idempotency. See integration setup.

Documentation

Search guides, sections, and API names.