GORM — Testing#

Test metrics#

  • Test files: 93
  • Source files (non-test): 69
  • Ratio (test files / source files): ~1.35 — unusually high; more test files than source files
  • Test frameworks: stdlib testing (dominant); github.com/stretchr/testify/assert used in exactly 2 files (tests/migrate_test.go, tests/joins_test.go); no gomock, ginkgo, or goconvey

Test organization#

Placement#

Tests live in two distinct tiers:

  1. Package-adjacent unit tests — co-located with their packages in clause/, schema/, callbacks/, logger/, utils/, plus a handful in the root package (statement_test.go, generics_withresult_test.go). These use the external _test package convention (e.g., package clause_test) to test from the outside.
  2. Integration tests — isolated in tests/ which has its own separate go.mod. This module pulls in real dialect drivers (gorm.io/driver/mysql, gorm.io/driver/postgres, etc.) to run against live databases. Source files in the core gorm.io/gorm module do not transitively depend on any dialect.

Helper packages#

  • utils/tests/ (exported): The central test support library, a public package (package tests) within the main module. Contains:
    • DummyDialector — a hand-crafted fake Dialector that implements the full gorm.Dialector interface (including ErrorTranslator) without a real database. Used by all unit tests in clause/ and schema/.
    • AssertEqual(t, got, expect) — a deep-equality checker that handles time.Time rounding, driver.Valuer unwrapping, pointer dereferencing, slice/struct recursion, and type convertibility. Significantly more capable than a simple reflect.DeepEqual call.
    • AssertObjEqual(t, r, e, names...) — field-by-field named comparison via reflection; wraps each field check in a t.Run(name, ...) subtest for granular failure output.
    • models.go — canonical model definitions (User, Account, Pet, Toy, Language, Company, etc.) shared across all tests.
    • Now() *time.Time — trivial helper returning a *time.Time pointer.
  • tests/helper_test.go: Integration-layer helpers (GetUser(name, Config) builder, CheckUser, CheckPet, dialect-skip predicates isTiDB(), isMysql(), isSqlite(), mysqlVersionAtLeast()). These are in the tests_test package so they’re only compiled for test runs but not exported.

Fixtures#

  • No testdata/ directories, no embedded fixtures, no golden files.
  • Test data is constructed in-process: GetUser("name", Config{Pets: 2, Company: true, ...}) creates fully populated model graphs with configurable association counts.
  • Integration tests insert and read real rows; cleanup is per-test (records are left in the DB but tests use unique names or IDs to isolate results).

Test patterns#

Table-driven tests#

  • Prevalence: Heavy — 88 occurrences of t.Run across test files.
  • Style: Two distinct styles:
    1. Anonymous struct slice (used in clause/ tests): inline table of {Clauses, Result, Vars} triplets; the test iterates and calls checkBuildClauses. Example: clause/where_test.go:10TestWhere declares a results := []struct { Clauses ...; Result string; Vars ... } slice with ~15 cases covering SQL expression combinations.
    2. Named sub-tests without a table (used in tests/): sequential t.Run("First", ...), t.Run("Last", ...) blocks with individual assertions. Closer to BDD grouping than a data-driven table.
  • Example: clause/where_test.go:11 — every WHERE clause combination (AND, OR, NOT, nested groups) is a table row; iterates 15 cases, each checking exact SQL output and bind vars.

Mocking approach#

  • Strategy: Hand-crafted fakes implementing the relevant interface — no code generation.
  • Primary fake: DummyDialector in utils/tests/dummy_dialecter.go implements the full gorm.Dialector interface (10 methods: Name, Initialize, DefaultValueOf, Migrator, BindVarTo, QuoteTo, Explain, DataTypeOf, plus Translate for ErrorTranslator). It registers the real default callbacks so callback ordering logic is exercised without a live database.
  • ConnPool fake: tests/connpool_test.go defines wrapperConnPool and wrapperTx — manual wrapper types that intercept ExecContext/QueryContext/PrepareContext calls to record issued SQL. Used to verify that PreparedStmtDB caches and reuses prepared statements correctly.
  • No gomock-generated mocks anywhere in the codebase.

Integration tests#

  • Present: Yes — the tests/ subdirectory is an integration test module.
  • How: GitHub Actions spins up real database containers via Docker service definitions. The test binary selects the active dialect via the GORM_DIALECT environment variable. The tests/ go.mod imports the appropriate driver:
    • SQLite (via gorm.io/driver/sqlite) — in-process, no Docker needed
    • MySQL 5.7, 8, 9 — Docker container on port 9910
    • MariaDB latest — same MySQL port mapping
    • PostgreSQL 13, 14, 15, latest — Docker on port 9920
    • SQL Server 2022 — Docker on port 9930
  • Separation: Entirely by module boundary — the tests/ module has its own go.mod and is never imported by the core module. The CI shell script tests/tests_all.sh is the entrypoint.
  • Dialect-skip predicates: Individual tests call t.Skip(...) via tidbSkip(t, reason) or inline if name := DB.Dialector.Name(); name == "sqlserver" { return } guards to skip dialect-unsupported features (e.g., RETURNING clause for MySQL).

Test quality observations#

What’s done well#

  • Test-to-source ratio above 1.0 — exceptional coverage breadth for an ORM library. Every major SQL clause type has a dedicated *_test.go in clause/.
  • DummyDialector pattern — unit tests for clause/ and schema/ run completely without a database. SQL generation logic is tested in isolation by building Statement objects directly and checking stmt.SQL.String() output. This is fast, deterministic, and highly valuable for regression catching.
  • Shared model corpusutils/tests/models.go defines a canonical User with all association types (HasOne, HasMany, BelongsTo, ManyToMany, polymorphic, self-referential). Every integration test exercises the same model, so complex association interactions are covered consistently.
  • Granular failure messagesAssertObjEqual wraps each field in a t.Run(fieldName, ...) subtest, so a failure in a nested association field like User.Pets[1].Toy.Name appears as a clearly named subtest path rather than a generic diff.
  • Multi-dialect CI matrix — the project CI tests against 9+ database combinations (MySQL 3 versions × 2 Go versions, PostgreSQL 4 versions × 2 Go versions, etc.). This is essential for an ORM claiming cross-database portability.
  • Benchmarksclause/benchmarks_test.go and tests/benchmark_test.go benchmark SQL building and full query execution. Clause building is benchmarked both as simple (BenchmarkSelect) and complex (BenchmarkComplexSelect) to track performance regressions.
  • Platform-specific testsutils/utils_unix_test.go and utils/utils_windows_test.go for OS-specific utility behavior.

What could improve#

  • Minimal use of testify — inconsistent; 2 files import testify while the rest use hand-rolled assertions. The custom AssertEqual in utils/tests/ is quite complex (~120 lines) and handles edge cases (time rounding, driver.Valuer) that testify/assert.Equal doesn’t address natively — so this is a deliberate choice, but newcomers may find the dual style confusing.
  • No golden file tests — the SQL output tests in clause/ compare against hardcoded string literals. As clauses evolve, these strings need manual updating; golden files would make diffs more visible.
  • Integration test cleanup — tests insert rows but rely on unique names (not truncation or transactions) for isolation. This can cause test interference if the same test name is reused or if a test fails mid-run, leaving partial data.
  • No race detector annotation — the CI workflow does not use -race flag. Given the sync.Map-heavy concurrency in schema/ and the statement clone mechanism, race detection in CI would add confidence.

Patterns worth emulating#

  • Module boundary as test-tier separator — using a separate go.mod for integration tests is a clean way to keep dialect drivers out of the core library’s dependency graph. Downstream users importing gorm.io/gorm never transitively pull in gorm.io/driver/sqlite.
  • DummyDialector for interface-boundary unit tests — building a minimal fake that satisfies the plugin interface allows fast, database-free unit testing of all SQL generation logic. Any project with a pluggable backend can apply this pattern.
  • AssertObjEqual with field names as varargsAssertObjEqual(t, got, expect, "ID", "Name", "CreatedAt") reads like a specification of which fields matter, making test intent explicit and avoiding noisy false failures on irrelevant fields like auto-managed timestamps.