sqlbtest

package
v0.23.0 Latest Latest
Warning

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

Go to latest
Published: Aug 28, 2026 License: MIT Imports: 16 Imported by: 0

Documentation

Overview

Package sqlbtest is the two doubles an application testing on sqlb needs: a scripted Executor that needs no database, and a scratch database that needs no container.

DB is the first. Fresh is the second, and the split between them is the split every suite ends up making anyway — most tests against the double, where they are fast and answer "which statement did this issue", and a smaller number against Postgres, which is the only thing that can say whether the SQL is valid.

The engine's own suite runs without Docker — `mise run test` compiles and runs in seconds against an in-memory pgx double — and until now consumers got none of that. An application wanting to test a hook, a handler or a hand-written query had to either put Docker in its unit loop or write its own pgx.Rows fake, which is nine methods with subtle semantics and is exactly the work internal/pgfake had already done. That was the single biggest week-one friction in a simulated adoption (issue #77).

So this is that double, with a deliberately small surface. The internal package stays free to move; what is frozen here is only what a consumer's test needs.

What it is not

Not a Postgres. It does not parse SQL, it does not evaluate a predicate, and it does not know that the WHERE clause your hook added would have excluded the row it is about to hand back. It answers whatever the script says, and its value is in what it *records*: the statements your code produced and the values it bound.

That makes it right for the questions unit tests actually ask —

  • did the hook's predicate reach the statement?
  • did the handler bind the tenant id from the request rather than the body?
  • does the generated handler keep the hidden column out of its projection?
  • did the write run inside a transaction, and did a failure roll it back?

— and wrong for "does this query return the right rows", which needs a real database. Both are worth having; sqlb keeps the split by running its own round-trip suite against containers in a separate module, and an application adopting sqlb should expect the same shape.

Using it

db := sqlbtest.New(
    sqlbtest.Reply{Cols: []string{"id", "title"}, Rows: [][]any{{"p1", "Hello"}}},
)
handle := sqlb.New(db).WithHooks(hooks)

if _, err := myHandler(ctx, handle, req); err != nil {
    t.Fatal(err)
}
if !strings.Contains(db.LastStatement(), `"tenant_id" = $1`) {
    t.Errorf("the scoping hook did not reach the statement:\n%s", db.LastStatement())
}

A DB is safe for concurrent use, because the code under test may not be sequential.

And when the double is not enough

Fresh creates a database of its own per test on a server the caller names, applies what the test needs, and drops it afterwards:

db := sqlbtest.Fresh(t,
    sqlbtest.DSN(t, "SQLB_TEST_POSTGRES", "run `docker compose up -d` first"),
    sqlbtest.Declared(schema.DefaultRegistry()),
)
handle := sqlb.New(db).WithHooks(hooks)

It starts nothing. fresh.go says why at length; the short version is that this repository ran the other experiment — a container per package, through testcontainers — and reversed it.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func DSN

func DSN(t testing.TB, env, hint string) string

DSN reads a Postgres URL out of the environment, failing the test when it is not there.

hint is what the caller should do about it — `mise run pg-up`, `docker compose up`, whatever provides the database in this project — and it is a parameter rather than a sentence this package invents, because only the caller knows.

dsn := sqlbtest.DSN(t, "SQLB_TEST_POSTGRES", "run `mise run pg-up` first")

func Fresh

func Fresh(t testing.TB, dsn string, opts ...Option) *pgxpool.Pool

Fresh creates a database of its own on the server dsn names, applies each option in order, and returns a pool for it.

The database is dropped when the test ends, so tests are independent without truncating anything, and they may run in parallel: a database per test costs milliseconds, where a server per test costs seconds and a shared one costs the isolation.

db := sqlbtest.Fresh(t, dsn, sqlbtest.Declared(schema.DefaultRegistry()))
handle := sqlb.New(db).WithHooks(hooks)

dsn may name any database on the server — the path is replaced with the maintenance database to create the new one, and with the new one to connect. A user that may not CREATE DATABASE is the one requirement this has beyond a connection.

func FreshDSN

func FreshDSN(t testing.TB, dsn string, opts ...Option) string

FreshDSN is Fresh for a caller that opens its own connection: an application booting from a URL, a pool with settings this package does not know about, a test that hands the string to something else entirely.

The options are applied the same way, through a pool that is closed before this returns — so what the caller gets is a database already built, and the only connection to it is the one they open.

Types

type DB

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

DB is a scripted, database-free [sqlb.Executor]. It also satisfies the transaction-capable interface sqlb.New looks for, so generated writes — which wrap themselves in a transaction by default — run against it unchanged, and BEGIN, COMMIT and ROLLBACK land in the statement log where a test can assert on them.

The zero DB is usable and refuses every statement, since it has no script.

func New

func New(replies ...Reply) *DB

New returns a DB answering from the given script.

func (*DB) Args

func (d *DB) Args() [][]any

Args is the bind parameters of every statement, aligned with DB.Statements. A transaction marker has none.

func (*DB) BeginTx

func (d *DB) BeginTx(context.Context, pgx.TxOptions) (pgx.Tx, error)

BeginTx opens a scripted transaction. Its statements go through this same DB, and its boundary is recorded in the statement log — which is what lets a test assert that a unit of work was wrapped, and that a failing one rolled back rather than committing.

func (*DB) Exec

func (d *DB) Exec(_ context.Context, query string, args ...any) (pgconn.CommandTag, error)

Exec answers a write, reporting a command tag whose row count is the number of rows the matching reply carries.

func (*DB) LastArgs

func (d *DB) LastArgs() []any

LastArgs is the bind parameters of the most recent statement, skipping the transaction markers for the same reason DB.LastStatement does.

This is where a scoping test belongs. The statement text says a predicate was added; the args say what value it was given, which is the half that catches a hook reading the tenant from the request body.

func (*DB) LastStatement

func (d *DB) LastStatement() string

LastStatement is the most recent statement, for asserting on compiled SQL.

Transaction markers are skipped. A generated write is wrapped by default, so the raw last entry is COMMIT and no assertion about SQL has ever been about that; a test asking whether a write was wrapped reads DB.Statements.

func (*DB) Query

func (d *DB) Query(_ context.Context, query string, args ...any) (pgx.Rows, error)

Query answers a read.

func (*DB) QueryRow

func (d *DB) QueryRow(ctx context.Context, query string, args ...any) pgx.Row

QueryRow answers a single-row read. It is here because pgx.Tx requires it; sqlb itself reads through Query.

func (*DB) Reset

func (d *DB) Reset()

Reset clears the statement log, leaving the script in place.

func (*DB) Script

func (d *DB) Script(replies ...Reply)

Script replaces the reply set, for a test that changes what the database says partway through. It does not clear the statement log.

func (*DB) Statements

func (d *DB) Statements() []string

Statements returns every statement issued, in order, including the transaction markers.

type Option

type Option interface {
	// contains filtered or unexported methods
}

Option is what Fresh does after creating the database, or how it connects.

One list rather than two, because the order a caller writes them in is the order the database is built in, and separating "configuration" from "content" would put the pool size somewhere other than beside the extension the schema needs.

func Changes

func Changes(changes []migrate.Change) Option

Changes applies a set of migration changes, which is what a suite that owns a history replays.

func Configure

func Configure(fn func(*pgxpool.Config)) Option

Configure adjusts the pool before it is opened, for the settings this package does not name.

The one that keeps coming up is the query mode: pgx prepares and caches a plan per connection keyed on the statement text, so a suite measuring the same SQL under different session settings measures the first plan every time. pgtest's vector tests were doing exactly that, and reported a perfect result for the query they existed to show failing.

sqlbtest.Configure(func(c *pgxpool.Config) {
    c.ConnConfig.DefaultQueryExecMode = pgx.QueryExecModeExec
})

func Declared

func Declared(reg *schema.Registry, opts ...migrate.Option) Option

Declared builds the schema a registry declares: migrate.Diff from nothing to that registry, applied statement by statement.

This is the baseline every example builds on, and it is deliberately not a migration history — what it proves is that the DDL sqlb renders *now* applies to Postgres. A suite testing a history should replay the history, which is SQL over the checked-in files.

func Do

func Do(fn func(context.Context, *pgxpool.Pool) error) Option

Do runs a caller's own function against the new database, for the preparation that is neither DDL nor a schema: seeding rows, installing a fixture, calling a package's own bootstrap.

func Extensions

func Extensions(names ...string) Option

Extensions creates each named extension if it is not already there.

Separate from SQL because the failure is worth naming: an extension that is not installed on the server cannot be created by a test, and the error says so rather than looking like a syntax problem.

func MaxConns

func MaxConns(n int32) Option

MaxConns caps the pool. The default is four.

It matters more than it looks: the ceiling a suite reaches is this number times the number of tests running in parallel, and a stock server allows a hundred connections in total. A pool sized to the machine rather than to the test is how a suite passes on a laptop and exhausts a CI runner.

func SQL

func SQL(statements ...string) Option

SQL runs statements against the new database, in order.

For the DDL a suite writes by hand — the table three tests share, the trigger under test, the shim the generated DDL assumes exists.

type Reply

type Reply struct {
	Match string
	Cols  []string
	Rows  [][]any

	// Err fails the statement when it is sent, which is what a syntax error or
	// a connection failure looks like.
	Err error

	// RowsErr fails the statement while its result is being read, which is what
	// a constraint violation looks like on pgx's extended protocol. The
	// distinction is not academic: code that only checks what Query returned
	// misses a constraint violation entirely, and a wrapped
	// *pgconn.PgError here is how a test reaches sqlb's constraint
	// classification.
	RowsErr error

	// Tag overrides the command tag an Exec reports. The default is derived
	// from len(Rows), which is what the row-count paths read.
	Tag string
}

Reply is one scripted answer.

Match is a substring of the statement it answers, so a test can tell the page query from the count query without parsing SQL. An empty Match answers anything, which is what a script with a single reply wants. Replies are tried in order and the first match wins, so put the specific ones first.

A statement no reply matches fails, with an error naming the statement. That is deliberate and it is the one place this package is stricter than it has to be: a double that answered an unscripted read with an empty result set would hand back zero columns, and the scan would fail several frames later with a message about the model's db tags rather than about the missing reply. Add a Reply with an empty Match for a catch-all.

Jump to

Keyboard shortcuts

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