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 ¶
- func DSN(t testing.TB, env, hint string) string
- func Fresh(t testing.TB, dsn string, opts ...Option) *pgxpool.Pool
- func FreshDSN(t testing.TB, dsn string, opts ...Option) string
- type DB
- func (d *DB) Args() [][]any
- func (d *DB) BeginTx(context.Context, pgx.TxOptions) (pgx.Tx, error)
- func (d *DB) Exec(_ context.Context, query string, args ...any) (pgconn.CommandTag, error)
- func (d *DB) LastArgs() []any
- func (d *DB) LastStatement() string
- func (d *DB) Query(_ context.Context, query string, args ...any) (pgx.Rows, error)
- func (d *DB) QueryRow(ctx context.Context, query string, args ...any) pgx.Row
- func (d *DB) Reset()
- func (d *DB) Script(replies ...Reply)
- func (d *DB) Statements() []string
- type Option
- func Changes(changes []migrate.Change) Option
- func Configure(fn func(*pgxpool.Config)) Option
- func Declared(reg *schema.Registry, opts ...migrate.Option) Option
- func Do(fn func(context.Context, *pgxpool.Pool) error) Option
- func Extensions(names ...string) Option
- func MaxConns(n int32) Option
- func SQL(statements ...string) Option
- type Reply
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func DSN ¶
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 ¶
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 ¶
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 (*DB) Args ¶
Args is the bind parameters of every statement, aligned with DB.Statements. A transaction marker has none.
func (*DB) BeginTx ¶
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 ¶
Exec answers a write, reporting a command tag whose row count is the number of rows the matching reply carries.
func (*DB) LastArgs ¶
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 ¶
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) QueryRow ¶
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 ¶
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 ¶
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 ¶
Changes applies a set of migration changes, which is what a suite that owns a history replays.
func Configure ¶
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 ¶
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 ¶
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 ¶
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 ¶
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.
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.