sqltest

package
v0.0.0-...-6f40dae Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 22, 2026 License: MIT Imports: 18 Imported by: 0

Documentation

Overview

Package sqltest is sqlkit's toolkit for testing SQL code. It brings together two complementary halves: helpers that seed and manage a real database, and a spy-and-mock pair built on querytrace for asserting on the queries code issues. Import the one package whether a test needs a seeded database, a query budget, or both.

Seeding a database

The seeding helpers cut the boilerplate of managing the schema and inserting fixtures, leaving the connection (and any test container) to the caller. Every helper takes *testing.T and fails the test with t.Fatalf on error, so callers write linear setup code with no error handling.

A DB is the entry point. New wraps a *sqlkit.Database the caller opened against a disposable test database, returning a handle whose methods reset, isolate, and seed it. New itself changes nothing; an explicit SetupSchema applies the schema and drops it again on cleanup:

tdb := sqltest.New(database, md)
tdb.SetupSchema(t) // schema applied now, dropped on t.Cleanup
id := tdb.InsertID(t, appdb.Users, UserDTO{Email: "a@example.com"})
tdb.InsertRows(t, appdb.Posts, PostDTO{UserID: id, Title: "hello"})

Constructing a DB is the safety bar: the destructive operations (applying or dropping the schema, truncating tables) live only as DB methods, so a database the test did not deliberately wrap as throwaway is never dropped.

  • Schema lifecycle: DB.SetupSchema (apply now, drop on t.Cleanup), and the methods DB.ApplySchema (apply, no teardown) and DB.Truncate for resetting within a test. Their drop and truncate steps are foreign-key-safe regardless of how the tables were declared — children before or after parents, cross-schema, or in a cycle.
  • Isolation: DB.Tx hands each case its own transaction, rolled back on t.Cleanup, so cases that share one schema stay independent and may run in parallel. Snapshot (and Fixture.LoadSnapshot) instead capture the seeded state of a single session as a SAVEPOINT and return a restore function, so many serial cases reuse one seed by rolling back between them.
  • Data insertion: the DB.InsertRows / DB.InsertID / DB.Load methods seed through auto-commit; the InsertRows, InsertID, InsertReturning, and Fixture.Load functions take any sqlkit.Querier, so they seed into a Tx session (isolated, rolled back with the case) just as well.
  • Fixtures: Fixture, a named, ordered set of rows across tables, with Load.

The insert helpers smooth over the main cross-dialect wrinkle in seeding: reading back a database-generated key. InsertID uses RETURNING on PostgreSQL and Result.LastInsertId on MySQL, so fixture code that wires up foreign keys works on both without branching. Foreign-key ordering of the rows is the caller's responsibility: insert parent rows before the children that reference them, exactly as in hand-written seed code.

Asserting on queries

The spy turns a querytrace.Trace into a record of the SQL one operation actually issued, and asserts on it after the fact. Where a mock library makes you declare the queries up front and fails if they do not run, the spy works the other way around — run the code under a Trace, then ask what it did: a query budget, no writes, no N+1, a SELECT on a given table that ran exactly once, an INSERT before the COMMIT.

The entry point is Expect, a fluent assertion bound to a testing.TB:

trace := querytrace.New("GetUserPage")
ctx := querytrace.WithTrace(context.Background(), trace)
service.RenderUserPage(ctx, id)

sqltest.Expect(t, trace).
	MaxQueries(5).
	NoWrites().
	NoNPlusOne().
	Query(sqltest.Select(), sqltest.Table("users")).Times(1)

Match values (Select, Table, Fingerprint, SqlContains, Caller, HasTopLevelWhere, …) select the statements an assertion applies to; pass several and they must all hold. All, Any, and Not compose them. Count and Events expose the same matching without a testing.TB, for inspecting a Trace directly.

Mocking a database

For unit tests that should not touch a database at all, NewDB returns a *sql.DB backed by a programmable Mock and traced by querytrace, so the mock provides the inputs (canned rows, results, or errors registered with ExpectQuery and ExpectExec) and the spy assertions verify the calls.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func AnyColumn

func AnyColumn() sqlpkg.Selection

AnyColumn is a Select selection that matches any column or expression:

mock.ExpectQuery(sql.Select(sqltest.AnyColumn()).From(sql.Tbl("users")))

func AnyExpr

func AnyExpr() sqlpkg.Expression

AnyExpr is an expression that matches any expression, for Where and other clauses:

...Where(sqltest.AnyExpr())

func AnyTable

func AnyTable() sqlpkg.Source

AnyTable is a From source that matches any table in a sql.Query expectation:

mock.ExpectQuery(sql.Select(sql.Col("id")).From(sqltest.AnyTable()))

func Count

func Count(trace *querytrace.Trace, ms ...Match) int

Count returns how many statements in trace match every Match in ms. It is the spy query underneath Query(...).Times, usable without a testing.TB.

func Events

func Events(trace *querytrace.Trace, ms ...Match) []querytrace.Event

Events returns the statements in trace matching every Match in ms, in record order.

func InsertID

func InsertID(t *testing.T, ex sqlkit.Querier, table sqlkit.Table, row any) int64

InsertID inserts a single row and returns its database-generated primary key, smoothing over the dialect difference: on dialects with RETURNING (PostgreSQL) it reads the key back with RETURNING <pk>; otherwise (MySQL) it falls back to Result.LastInsertId. It assumes a single auto-generated integer primary-key column, which is the common test fixture; tables with a composite or non-integer key are not supported.

func InsertReturning

func InsertReturning[Row any](t *testing.T, ex sqlkit.Querier, table sqlkit.Table, rows ...Row) []Row

InsertReturning inserts rows and scans the inserted rows — with database-generated columns filled in — back into a slice of Row. It relies on RETURNING, so it is PostgreSQL-only; on a dialect without RETURNING (MySQL) it skips the test rather than silently returning unpopulated rows. Use InsertRows or InsertID on those dialects.

func InsertRows

func InsertRows(t *testing.T, ex sqlkit.Querier, table sqlkit.Table, rows ...any)

InsertRows inserts rows into table and fails the test on error. Each row may be a struct, a pointer to a struct, or a slice of either — the same shapes InsertQuery.Values accepts — so generated row structs and caller-defined DTOs both work. It does not read anything back; use InsertID when you need a generated key, or InsertReturning to scan the inserted rows.

ex is any sqlkit.Querier, satisfied by both *sqlkit.Database (auto-commit) and *sqlkit.Session (inside a transaction).

func Snapshot

func Snapshot(t *testing.T, s *sqlkit.Session) (restore func(t *testing.T))

Snapshot records the session's current state as a reusable restore point and returns a function that rolls back to it. Seed your fixtures into the session once, snapshot, then run each case against the same session and call the returned restore between cases to discard that case's writes and return to the seeded state — far cheaper than reloading the fixtures every time:

tdb := sqltest.New(db, md)
tdb.SetupSchema(t)
session := tdb.Tx(t)             // one session, rolled back at the end
blog.Load(t, session)            // seed once
restore := sqltest.Snapshot(t, session)

t.Run("case1", func(t *testing.T) {
	defer restore(t)         // undo case1's writes
	// ... mutate and assert against session ...
})
t.Run("case2", func(t *testing.T) {
	defer restore(t)         // case2 sees the seeded state again
	// ...
})

It uses a SAVEPOINT (Session.Savepoint / RollbackTo), so it works on PostgreSQL and MySQL alike and the restore point survives repeated restores. The catch is that every case must run its statements through the same session and the session must stay open and uncommitted: committing or closing it ends the transaction and invalidates the restore point. Cases therefore cannot run in parallel against the snapshot.

Types

type ArgMatcher

type ArgMatcher interface {
	// MatchArg reports whether the executed bind value matches.
	MatchArg(v any) bool
	// String describes the matcher.
	String() string
}

ArgMatcher decides whether the bind argument at a placeholder position matches. Place one in a sql.Query expectation with Arg, AnyArg, or ArgMatch — for example Where(Eq(Col("id"), sqltest.Arg(5))) — and the mock checks the executed statement's argument there. Placeholders without an ArgMatcher (plain values) are left unconstrained, so a query matches any value unless you say otherwise.

func AnyArg

func AnyArg() ArgMatcher

AnyArg matches any bind argument. It is explicit documentation that a value is deliberately unconstrained.

func Arg

func Arg(v any) ArgMatcher

Arg matches a bind argument equal to v, comparing by database/sql driver value (so an int and the int64 the driver sees compare equal).

func ArgMatch

func ArgMatch(fn func(v any) bool) ArgMatcher

ArgMatch matches a bind argument for which fn returns true.

type Assertion

type Assertion struct {
	// contains filtered or unexported fields
}

Assertion is a fluent set of checks against a single Trace. Each method reports a failure through the testing.TB and returns the Assertion so checks chain. Build one with Expect.

func Expect

func Expect(t testing.TB, trace *querytrace.Trace) *Assertion

Expect snapshots trace and returns an Assertion over it. Call it once the code under test has run; the snapshot is stable, so later checks see the same statements and warnings even if the Trace keeps recording.

func (*Assertion) AllRowsClosed

func (a *Assertion) AllRowsClosed() *Assertion

AllRowsClosed asserts no read left its row cursor open (no rows_not_closed warning).

func (*Assertion) Committed

func (a *Assertion) Committed() *Assertion

Committed asserts at least one transaction committed.

func (*Assertion) InOrder

func (a *Assertion) InOrder(ms ...Match) *Assertion

InOrder asserts that statements matching ms appear in the trace in the given relative order: an event matching ms[0] before one matching ms[1], and so on. The matched events need not be adjacent, and matchers may be transaction matchers (Commit, Rollback, Begin), so Insert() before Commit() is expressible. Compose multi-condition steps with All.

func (*Assertion) MaxQueries

func (a *Assertion) MaxQueries(n int) *Assertion

MaxQueries asserts the trace recorded at most n statements, a query budget.

func (*Assertion) MinQueries

func (a *Assertion) MinQueries(n int) *Assertion

MinQueries asserts the trace recorded at least n statements.

func (*Assertion) NoNPlusOne

func (a *Assertion) NoNPlusOne() *Assertion

NoNPlusOne asserts the trace produced neither a possible-N+1 nor a query-in-loop warning.

func (*Assertion) NoQueries

func (a *Assertion) NoQueries() *Assertion

NoQueries asserts the trace touched the database not at all, the check for a path that should be served without a query (a cache hit).

func (*Assertion) NoTransaction

func (a *Assertion) NoTransaction() *Assertion

NoTransaction asserts no transaction began within the trace.

func (*Assertion) NoWarn

func (a *Assertion) NoWarn(code querytrace.WarningCode) *Assertion

NoWarn asserts the trace produced no warning with code.

func (*Assertion) NoWarnings

func (a *Assertion) NoWarnings() *Assertion

NoWarnings asserts the trace produced no warning at all.

func (*Assertion) NoWrites

func (a *Assertion) NoWrites() *Assertion

NoWrites asserts the trace recorded no write statement, the check for a read-only code path.

func (*Assertion) Queries

func (a *Assertion) Queries(n int) *Assertion

Queries asserts the trace recorded exactly n read and write statements.

func (*Assertion) Query

func (a *Assertion) Query(ms ...Match) *QueryAssertion

Query narrows the assertion to the statements matching every Match in ms and returns a QueryAssertion to check how many ran. With no matchers it selects every statement.

func (*Assertion) Reads

func (a *Assertion) Reads(n int) *Assertion

Reads asserts the trace recorded exactly n read statements.

func (*Assertion) RolledBack

func (a *Assertion) RolledBack() *Assertion

RolledBack asserts at least one transaction rolled back.

func (*Assertion) Transactions

func (a *Assertion) Transactions(n int) *Assertion

Transactions asserts exactly n transactions began within the trace.

func (*Assertion) UniqueQueries

func (a *Assertion) UniqueQueries(n int) *Assertion

UniqueQueries asserts the trace recorded exactly n distinct query shapes (fingerprints).

func (*Assertion) Warns

func (a *Assertion) Warns(code querytrace.WarningCode) *Assertion

Warns asserts the trace produced a warning with code.

func (*Assertion) Writes

func (a *Assertion) Writes(n int) *Assertion

Writes asserts the trace recorded exactly n write statements.

type DB

type DB struct {
	// contains filtered or unexported fields
}

DB is a throwaway test database: a thin handle over a *sqlkit.Database that owns the destructive test operations — applying and dropping the schema, truncating tables, and opening isolated per-test transactions — alongside auto-commit seeding sugar.

Constructing a DB is itself the safety bar. The drop and truncate operations live only as methods, reachable only through a DB a test deliberately built over a disposable database, so a *sqlkit.Database wired to a staging or production DSN — a stray environment variable, the wrong config profile — can never be wiped by a test that did not wrap it here. Construction touches nothing: SetupSchema and ApplySchema are the explicit steps that change the database, never New.

Do not confuse DB with NewDB: NewDB builds a programmable mock *sql.DB for unit tests that must not touch a database at all, whereas a DB drives a real one. The two halves of the package are independent.

func New

func New(db *sqlkit.Database, md meta.Metadata) *DB

New wraps db as a throwaway test database. It only builds the handle — it issues no SQL and changes nothing — so the schema is applied by an explicit SetupSchema (or ApplySchema), never as a side effect of construction:

tdb := sqltest.New(database, md)
tdb.SetupSchema(t) // drop + create now, dropped again on t.Cleanup
id := tdb.InsertID(t, appdb.Users, UserDTO{Email: "a@example.com"})
tdb.InsertRows(t, appdb.Posts, PostDTO{UserID: id, Title: "hello"})

md is the meta.Metadata a schema catalog produces (decl.Catalog.Build). db is a *sqlkit.Database the caller opened against a disposable test database — a container, a CI service database, or a scratch schema — never production.

func (*DB) ApplySchema

func (d *DB) ApplySchema(t *testing.T)

ApplySchema applies md's schema — a foreign-key-safe drop of the existing tables followed by the CREATE DDL — registering no teardown. It is SetupSchema without the automatic drop on cleanup: reach for it to reset the schema again within a test, or when something else owns the teardown.

func (*DB) Database

func (d *DB) Database() *sqlkit.Database

Database returns the underlying *sqlkit.Database, for running queries, seeding through a sqlkit.Querier helper (InsertRows, Fixture.Load, …), or any sqlkit operation the DB does not wrap.

func (*DB) InsertID

func (d *DB) InsertID(t *testing.T, table sqlkit.Table, row any) int64

InsertID inserts a single row through auto-commit and returns its database-generated primary key. It is shorthand for InsertID(t, d.Database(), table, row); see that function for the cross-dialect key read-back.

func (*DB) InsertRows

func (d *DB) InsertRows(t *testing.T, table sqlkit.Table, rows ...any)

InsertRows seeds rows into table through auto-commit (the underlying database), failing the test on error. It is shorthand for InsertRows(t, d.Database(), table, rows...); seed into a Tx session instead when cases run in parallel, so the rows roll back with the case rather than persisting.

func (*DB) Load

func (d *DB) Load(t *testing.T, f *Fixture)

Load seeds a fixture through auto-commit. It is shorthand for f.Load(t, d.Database()); load into a Tx session instead when cases run in parallel.

func (*DB) SetupSchema

func (d *DB) SetupSchema(t *testing.T)

SetupSchema gives the test a clean schema and tears it down automatically: it applies md's schema now — a foreign-key-safe drop of any existing tables followed by the CREATE DDL md renders for the dialect — then registers a t.Cleanup that drops the tables again, leaving the database empty. Call it once at the top of a test, or of a parent test whose subtests share the schema.

func (*DB) Truncate

func (d *DB) Truncate(t *testing.T, tables ...sqlkit.Table)

Truncate deletes every row from the given tables foreign-key-safely, whatever order they were declared in. Called with no tables it clears every table in the schema. It is a faster between-cases reset than re-applying the schema when the tables already exist and only their data needs clearing.

func (*DB) Tx

func (d *DB) Tx(t *testing.T, opts ...sqlkit.SessionOption) *sqlkit.Session

Tx gives the test its own transaction and rolls it back automatically when the test finishes. Each call opens a separate sqlkit.Session — a transaction on its own pooled connection — so cases that share the one schema stay isolated from each other's writes and can run in parallel: nothing a case inserts, updates, or deletes is visible to another, and the rollback leaves the database exactly as the next case (or the next run) finds it.

Apply the schema once for the group, then hand each case its own transaction:

func TestBlog(t *testing.T) {
	tdb := sqltest.New(database, md)
	tdb.SetupSchema(t) // schema created once, dropped after all subtests
	t.Run("publish", func(t *testing.T) {
		t.Parallel()
		s := tdb.Tx(t)  // isolated transaction, rolled back on return
		blog.Load(t, s) // seed inside the transaction
		// ... mutate and assert against s ...
	})
	t.Run("archive", func(t *testing.T) {
		t.Parallel()
		s := tdb.Tx(t)  // sees none of "publish"'s writes
		// ...
	})
}

SetupSchema's teardown runs on the parent test's cleanup, which Go schedules after every parallel subtest has finished, so the shared schema outlives the cases.

The returned *sqlkit.Session is a sqlkit.Querier, so it drops into the seeding helpers (InsertRows, InsertID, Fixture.Load, …) and starts query chains (s.Select()…). The transaction begins lazily on the first statement, so a case that runs nothing costs nothing. Pass sqlkit.SessionOption values (WithTxOptions, …) to control isolation level or read-only intent.

Tx isolates through a real transaction per connection, which scales to parallel cases; Snapshot isolates through savepoints on one shared, serial session. Reach for Tx when cases run in parallel or each wants a clean slate, and for Snapshot when many serial cases reuse one expensive seed.

type Fixture

type Fixture struct {
	// contains filtered or unexported fields
}

Fixture is a named, ordered set of rows spanning one or more tables. Declare it once and Load it into a database in any test that needs the same starting data:

var blog = sqltest.NewFixture("blog").
	Add(blogdb.Users, userRow{Email: "alice@example.com"}, userRow{Email: "bob@example.com"}).
	Add(blogdb.Posts, postRow{Title: "Hello"})

blog.Load(t, db)

Groups load in declaration order, so add parent tables before the children that reference them — foreign-key ordering is the caller's responsibility, the same as hand-written seed code. Fixture is for static rows whose values are known up front; when later rows must reference a generated key (e.g. a post needs its author's id), capture it with InsertID in a plain seed function instead.

func NewFixture

func NewFixture(name string) *Fixture

NewFixture creates an empty fixture. name appears in failure messages.

func (*Fixture) Add

func (f *Fixture) Add(table sqlkit.Table, rows ...any) *Fixture

Add appends a group of rows for table, preserving call order. It returns the fixture so calls chain. Each row follows the same shapes InsertRows accepts (struct, pointer to struct, or slice of either).

func (*Fixture) Load

func (f *Fixture) Load(t *testing.T, ex sqlkit.Querier)

Load inserts every group in declaration order, failing the test on the first error. ex is any sqlkit.Querier, so a fixture loads through a *sqlkit.Database or within a *sqlkit.Session transaction alike.

func (*Fixture) LoadSnapshot

func (f *Fixture) LoadSnapshot(t *testing.T, s *sqlkit.Session) (restore func(t *testing.T))

LoadSnapshot loads the fixture into the session and snapshots immediately, returning the restore function. It is Fixture.Load followed by Snapshot, for the common case of seeding a fixture and reusing it across cases.

type Match

type Match struct {
	// contains filtered or unexported fields
}

Match is a predicate over a recorded Event. Matchers select the statements an assertion or a spy query applies to; pass several to Query, Count, or Events and they must all hold (logical AND). All, Any, and Not compose them explicitly.

Most matchers look at the statement: Select/Insert/Update/Delete, Table, Fingerprint, HasTopLevelWhere, Params, SqlContains. A few look at the surrounding Event (Named, Caller, Label, InTransaction) or the transaction timeline (Begin, Commit, Rollback). Matchers that depend on structural analysis (Table, HasTopLevelWhere, Fingerprint) need a parser wired into the Config; without one they do not match, while operation and SQL-text matchers still do.

func All

func All(ms ...Match) Match

All matches an Event only when every Match in ms does.

func Any

func Any(ms ...Match) Match

Any matches an Event when any Match in ms does.

func Begin

func Begin() Match

Begin matches a transaction begin on the timeline.

func Caller

func Caller(sub string) Match

Caller matches a statement issued from application code whose function name or file path contains sub. It needs caller capture (WithCaller).

func Commit

func Commit() Match

Commit matches a transaction commit on the timeline.

func Ddl

func Ddl() Match

Ddl matches a schema statement.

func Delete

func Delete() Match

Delete matches a DELETE.

func Fingerprint

func Fingerprint(fp string) Match

Fingerprint matches a statement with the given normalized fingerprint.

func HasTopLevelJoin

func HasTopLevelJoin() Match

HasTopLevelJoin matches a statement whose top-level node carries a JOIN clause (from analysis). A JOIN inside a subquery, CTE body, or UNION arm does not count.

func HasTopLevelWhere

func HasTopLevelWhere() Match

HasTopLevelWhere matches a statement whose top-level node carries a WHERE clause (from analysis). A WHERE inside a subquery, CTE body, or UNION arm does not count.

func Insert

func Insert() Match

Insert matches an INSERT.

func Label

func Label(key, value string) Match

Label matches a statement carrying the label key=value (WithLabel).

func Named

func Named(name string) Match

Named matches a statement tagged with the given query name (WithQueryName or Named).

func Not

func Not(m Match) Match

Not inverts a Match.

func Op

Op matches a statement of the given kind.

func Params

func Params(n int) Match

Params matches a statement carrying exactly n bind arguments.

func ParamsAtLeast

func ParamsAtLeast(n int) Match

ParamsAtLeast matches a statement carrying at least n bind arguments, the signature of a large IN list or batch.

func Read

func Read() Match

Read matches any read statement.

func Rollback

func Rollback() Match

Rollback matches a transaction rollback on the timeline.

func Select

func Select() Match

Select matches a SELECT (or other read).

func Sql

func Sql(text string) Match

Sql matches a statement whose raw SQL equals text, ignoring surrounding whitespace. It needs raw SQL capture (WithRawSQL).

func SqlContains

func SqlContains(sub string) Match

SqlContains matches a statement whose raw SQL contains sub, case-insensitively. It needs raw SQL capture (WithRawSQL).

func SqlMatches

func SqlMatches(re string) Match

SqlMatches matches a statement whose raw SQL matches the regular expression re. It panics if re does not compile. It needs raw SQL capture (WithRawSQL).

func Table

func Table(name string) Match

Table matches a statement that references table, by name and case-insensitively. It needs structural analysis (a parser in the Config).

func Update

func Update() Match

Update matches an UPDATE.

func Write

func Write() Match

Write matches any write statement (INSERT/UPDATE/DELETE).

func (Match) String

func (m Match) String() string

String renders the matcher for failure messages.

type Matcher

type Matcher interface {
	// Matches reports whether a statement with the given SQL matches.
	Matches(sql string) bool
	// String describes the matcher for Verify failure messages.
	String() string
}

Matcher decides whether a mock stub answers a statement, by its SQL text. The built-in matchers are Contains (a partial match), Regexp, Equals, and Anything; ExpectQuery and ExpectExec also accept a plain string or a sql.Query for convenience. Implement Matcher for custom matching.

It is distinct from the spy-side Match, which selects recorded statements by their analyzed shape; a Matcher sees only the raw SQL, at the point the mock must answer it.

func Anything

func Anything() Matcher

Anything matches every statement.

func Contains

func Contains(sub string) Matcher

Contains matches a statement whose SQL contains sub, case-insensitively.

func Equals

func Equals(s string) Matcher

Equals matches a statement whose SQL equals s, ignoring case and runs of whitespace.

func Regexp

func Regexp(expr string) Matcher

Regexp matches a statement whose SQL matches the regular expression expr. It panics if expr does not compile.

type Mock

type Mock struct {
	// contains filtered or unexported fields
}

Mock is a programmable database/sql test double. It backs the *sql.DB returned by NewDB, answering each statement with a response you registered — canned rows, a result, or an error — so code that talks to a database can run in a unit test without one. Because the same DB is traced by querytrace, the spy assertions (Expect, Count, Events) see every statement the code issued, so the mock provides the inputs and the spy verifies the calls.

Register responses with ExpectQuery and ExpectExec. Each takes a Matcher that decides which statements the stub answers — Contains for a partial match, Regexp, or Equals — a plain string (matched with Equals), or a sql.Query built with the sqlkit builder. A sql.Query matches its compiled shape, and you may leave holes in it: AnyTable, AnyColumn, and AnyExpr match any table, column, or expression, while Arg, AnyArg, and ArgMatch constrain (or wildcard) a bind argument. Stubs are tried in registration order; the first applicable one answers. A statement matching no stub gets a lenient default — empty rows or a zero result — so a mock need only program the statements a test cares about. Verify reports any stub whose MinTimes (or Required) was not met.

func NewDB

func NewDB(t testing.TB, opts ...Option) (*sql.DB, *Mock)

NewDB returns a *sql.DB backed by a programmable Mock and traced by querytrace, so statements run under a context carrying a Trace are recorded for the spy assertions. It registers a t.Cleanup that verifies the mock and closes the DB when the test ends, so a missed Required stub — or, by default, any statement no stub answered — fails the test without a manual Verify call. Pass Lenient to allow unmatched statements.

db, mock := sqltest.NewDB(t)
mock.ExpectQuery(sqltest.Contains("FROM users")).
	Return(sqltest.NewRows("id", "name").AddRow(1, "alice"))

trace := querytrace.New("LoadUser")
ctx := querytrace.WithTrace(context.Background(), trace)
// ... use db under ctx ...
sqltest.Expect(t, trace).Query(sqltest.Select(), sqltest.Table("users")).Times(1)

func (*Mock) ExpectExec

func (m *Mock) ExpectExec(expect any) *Stub

ExpectExec registers a response for ExecContext statements (db.Exec). See ExpectQuery for how the expectation is matched.

func (*Mock) ExpectQuery

func (m *Mock) ExpectQuery(expect any) *Stub

ExpectQuery registers a response for QueryContext statements (db.Query, db.QueryRow) whose SQL the matcher accepts. The matcher is a Matcher (Contains, Regexp, Equals, Anything), a plain string (matched with Equals), or a sql.Query (compiled with the mock's dialect, then Equals).

func (*Mock) Verify

func (m *Mock) Verify(t testing.TB)

Verify reports any stub whose MinTimes (or Required) was not met, and — unless the mock is Lenient — any statement that matched no stub. NewDB registers it as a t.Cleanup, so calling it by hand is optional; it runs its checks only once.

type Option

type Option func(*config)

Option configures NewDB.

func Lenient

func Lenient() Option

Lenient turns off strict verification: a statement that matches no stub is allowed (it gets the empty-rows / zero-result default) instead of failing the test at cleanup. Use it for code paths that deliberately issue statements the mock does not program.

func WithConfig

func WithConfig(cfg querytrace.Config) Option

WithConfig sets the querytrace.Config the mock DB records with. The default is NewConfig(WithVerboseCapture()): the PostgreSQL dialect, no parser, raw SQL and callers captured so every matcher works; supply your own (for example with a parser wired in) to enable the structural matchers Table, HasTopLevelWhere, and Fingerprint.

type QueryAssertion

type QueryAssertion struct {
	// contains filtered or unexported fields
}

QueryAssertion narrows an Assertion to the statements matching a set of matchers, ready to check how many of them ran. Build one with Assertion.Query; its terminal methods (Times, MinTimes, …) report the result and return the parent Assertion so checks keep chaining. The vocabulary mirrors the mock's Stub call-count methods.

func (*QueryAssertion) MaxTimes

func (q *QueryAssertion) MaxTimes(n int) *Assertion

MaxTimes asserts at most n matching statements ran.

func (*QueryAssertion) MinTimes

func (q *QueryAssertion) MinTimes(n int) *Assertion

MinTimes asserts at least n matching statements ran.

func (*QueryAssertion) Never

func (q *QueryAssertion) Never() *Assertion

Never asserts no matching statement ran. It is shorthand for Times(0).

func (*QueryAssertion) Times

func (q *QueryAssertion) Times(n int) *Assertion

Times asserts exactly n matching statements ran.

type RowSet

type RowSet struct {
	// contains filtered or unexported fields
}

RowSet is a set of canned rows a query stub returns. Build it with NewRows and add rows with AddRow.

func NewRows

func NewRows(columns ...string) *RowSet

NewRows starts a RowSet with the given column names.

func (*RowSet) AddRow

func (r *RowSet) AddRow(values ...any) *RowSet

AddRow appends a row. The values are coerced to the database/sql driver value types, so plain ints, strings, and the like are accepted.

func (*RowSet) RowError

func (r *RowSet) RowError(row int, err error) *RowSet

RowError makes iteration fail with err when the cursor reaches row (0-based), for testing a read that breaks mid-stream — the rows lifecycle querytrace observes.

type Stub

type Stub struct {
	// contains filtered or unexported fields
}

Stub is one programmed response. Build it with ExpectQuery or ExpectExec and set its reply with Return, ReturnResult, or ReturnError. The setters chain.

func (*Stub) AnyTimes

func (s *Stub) AnyTimes() *Stub

AnyTimes lets the stub answer any number of statements, zero included, and imposes no Verify floor. It clears an earlier Times/MinTimes/MaxTimes/Required.

func (*Stub) MaxTimes

func (s *Stub) MaxTimes(n int) *Stub

MaxTimes caps how many statements the stub answers; further matching statements fall through to later stubs or the lenient default. Zero (the default) is unlimited.

func (*Stub) MinTimes

func (s *Stub) MinTimes(n int) *Stub

MinTimes sets the fewest matching statements Verify requires. Zero (the default) requires none.

func (*Stub) Required

func (s *Stub) Required() *Stub

Required marks the stub as one Verify must see matched at least once. It is shorthand for MinTimes(1).

func (*Stub) Return

func (s *Stub) Return(rows *RowSet) *Stub

Return sets the rows a query stub answers with.

func (*Stub) ReturnError

func (s *Stub) ReturnError(err error) *Stub

ReturnError makes the stub answer with err.

func (*Stub) ReturnResult

func (s *Stub) ReturnResult(lastInsertID, rowsAffected int64) *Stub

ReturnResult sets the result an exec stub answers with.

func (*Stub) Times

func (s *Stub) Times(n int) *Stub

Times sets the exact number of statements the stub answers and that Verify requires: shorthand for MinTimes(n) and MaxTimes(n).

func (*Stub) WillDelayFor

func (s *Stub) WillDelayFor(d time.Duration) *Stub

WillDelayFor makes the stub wait d before answering, returning the context's error if it is canceled or its deadline passes first. It exercises slow-query and cancellation handling — querytrace records a mid-iteration cancellation as a warning.

func (*Stub) WithArgs

func (s *Stub) WithArgs(args ...any) *Stub

WithArgs constrains the stub to statements whose bind arguments match, by position: an ArgMatcher (Arg, AnyArg, ArgMatch) is used as given, any other value becomes an exact Arg. It is the string-matcher counterpart to embedding Arg in a sql.Query.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL