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/assertused 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:
- 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_testpackage convention (e.g.,package clause_test) to test from the outside. - Integration tests — isolated in
tests/which has its own separatego.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 coregorm.io/gormmodule 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 fakeDialectorthat implements the fullgorm.Dialectorinterface (includingErrorTranslator) without a real database. Used by all unit tests inclause/andschema/.AssertEqual(t, got, expect)— a deep-equality checker that handlestime.Timerounding,driver.Valuerunwrapping, pointer dereferencing, slice/struct recursion, and type convertibility. Significantly more capable than a simplereflect.DeepEqualcall.AssertObjEqual(t, r, e, names...)— field-by-field named comparison via reflection; wraps each field check in at.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.Timepointer.
tests/helper_test.go: Integration-layer helpers (GetUser(name, Config)builder,CheckUser,CheckPet, dialect-skip predicatesisTiDB(),isMysql(),isSqlite(),mysqlVersionAtLeast()). These are in thetests_testpackage 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.Runacross test files. - Style: Two distinct styles:
- Anonymous struct slice (used in
clause/tests): inline table of{Clauses, Result, Vars}triplets; the test iterates and callscheckBuildClauses. Example:clause/where_test.go:10—TestWheredeclares aresults := []struct { Clauses ...; Result string; Vars ... }slice with ~15 cases covering SQL expression combinations. - Named sub-tests without a table (used in
tests/): sequentialt.Run("First", ...),t.Run("Last", ...)blocks with individual assertions. Closer to BDD grouping than a data-driven table.
- Anonymous struct slice (used in
- 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:
DummyDialectorinutils/tests/dummy_dialecter.goimplements the fullgorm.Dialectorinterface (10 methods:Name,Initialize,DefaultValueOf,Migrator,BindVarTo,QuoteTo,Explain,DataTypeOf, plusTranslateforErrorTranslator). It registers the real default callbacks so callback ordering logic is exercised without a live database. - ConnPool fake:
tests/connpool_test.godefineswrapperConnPoolandwrapperTx— manual wrapper types that interceptExecContext/QueryContext/PrepareContextcalls to record issued SQL. Used to verify thatPreparedStmtDBcaches 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_DIALECTenvironment variable. Thetests/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
- SQLite (via
- Separation: Entirely by module boundary — the
tests/module has its owngo.modand is never imported by the core module. The CI shell scripttests/tests_all.shis the entrypoint. - Dialect-skip predicates: Individual tests call
t.Skip(...)viatidbSkip(t, reason)or inlineif name := DB.Dialector.Name(); name == "sqlserver" { return }guards to skip dialect-unsupported features (e.g.,RETURNINGclause 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.goinclause/. DummyDialectorpattern — unit tests forclause/andschema/run completely without a database. SQL generation logic is tested in isolation by buildingStatementobjects directly and checkingstmt.SQL.String()output. This is fast, deterministic, and highly valuable for regression catching.- Shared model corpus —
utils/tests/models.godefines a canonicalUserwith 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 messages —
AssertObjEqualwraps each field in at.Run(fieldName, ...)subtest, so a failure in a nested association field likeUser.Pets[1].Toy.Nameappears 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.
- Benchmarks —
clause/benchmarks_test.goandtests/benchmark_test.gobenchmark SQL building and full query execution. Clause building is benchmarked both as simple (BenchmarkSelect) and complex (BenchmarkComplexSelect) to track performance regressions. - Platform-specific tests —
utils/utils_unix_test.goandutils/utils_windows_test.gofor OS-specific utility behavior.
What could improve#
- Minimal use of
testify— inconsistent; 2 files import testify while the rest use hand-rolled assertions. The customAssertEqualinutils/tests/is quite complex (~120 lines) and handles edge cases (time rounding,driver.Valuer) thattestify/assert.Equaldoesn’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
-raceflag. Given thesync.Map-heavy concurrency inschema/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.modfor integration tests is a clean way to keep dialect drivers out of the core library’s dependency graph. Downstream users importinggorm.io/gormnever transitively pull ingorm.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.
AssertObjEqualwith field names as varargs —AssertObjEqual(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.