Docs

Write tests

Test the database

A database test runs SQL against a local Postgres that Testkit builds from your schema. Use it for constraints, queries, and anything else the database decides.

View as Markdown

On this page
  1. Before you start
  2. Declare the database
  3. Write the test
  4. Run it
  5. Read a failure
  6. Seed rows with fixtures
  7. Remove the database

Before you start

Start Docker. Testkit runs Postgres in a container. It runs psql and pg_dump in a container too, so you do not need Postgres on the host.

Declare the database

Add database.postgres(...) to the service that owns the schema.

testkit.config.ts
import { app, database, defineConfig } from "@gfdelarue/testkit/config";

export default defineConfig({
	services: {
		api: app.node({
			build: null,
			start: "npm start",
			port: 4392,
			database: database.postgres({
				template: {
					inputs: ["db/schema.sql"],
					migrate: [{ kind: "sql-file", path: "db/schema.sql" }],
				},
			}),
		}),
	},
});
db/schema.sql
CREATE TABLE refunds (
	id text PRIMARY KEY,
	order_id text NOT NULL UNIQUE,
	amount_cents integer NOT NULL
);

Testkit builds a template database from the migrate steps and caches it. It rebuilds the template when a file in inputs changes.

The service process receives its database as DATABASE_URL.

Write the test

src/db/__testkit__/refunds.dal.testkit.ts
import { defineFile } from "@gfdelarue/testkit";
import { defineDalSuite, expect } from "@gfdelarue/testkit/k6";

export const testkit = defineFile({ engine: "k6" });

const suite = defineDalSuite(({ db }) => {
	db.exec("INSERT INTO refunds (id, order_id, amount_cents) VALUES ('r1', '8841', 1200)");
	const error = expect.captureError(() => {
		db.exec("INSERT INTO refunds (id, order_id, amount_cents) VALUES ('r2', '8841', 1200)");
	});
	expect.error.captured(
		error,
		(caught) => String(caught).includes("refunds_order_id_key"),
		"the database rejects a second refund",
	);
	const rows = db.query("SELECT id FROM refunds WHERE order_id = '8841'");
	expect.rowCount(rows, 1, "one refund is stored");
});

export const options = suite.options;
export const setup = suite.setup;
export default suite.exec;

db.exec(sql) runs a statement. db.query(sql) returns the rows.

expect.captureError runs a function and returns what it threw. expect.error.captured takes that error, a predicate, and a label. The predicate must be a function.

The last three lines are required, as in an HTTP test.

Run it

$npx testkit run dal
[1/1] PASS api dal src/db/__testkit__/refunds.dal.testkit.ts 0s

Summary
Result: PASSED
Passed: 1
Failed: 0
Skipped: 0
Not run: 0
Files: 1
Duration: 4s
Run ID: 1791890158174-1344714-af8dd695

By default, Testkit runs the dal files for one service database one at a time.

Read a failure

When an expect helper fails, the run prints what it expected and what it got.

$npx testkit run dal
[1/1] FAIL api dal src/db/__testkit__/refunds.dal.testkit.ts 0s
    k6 thresholds failed: checks(rate==1.0)
    expected 2 row(s), got 1
HelperPasses when
expect.rowCount(rows, n, label)The query returned n rows
expect.singleRow(rows, label)The query returned one row. It returns that row.
expect.noRows(rows, label)The query returned no rows
expect.error.captured(error, predicate, label)The predicate accepts the captured error

Seed rows with fixtures

defineDalFixtures gives a file named fixtures. Each fixture seeds its rows once and returns the values the test needs.

src/db/__testkit__/seeded.dal.testkit.ts
import { defineFile } from "@gfdelarue/testkit";
import { defineDalFixtures, defineDalSuite, expect } from "@gfdelarue/testkit/k6";

export const testkit = defineFile({ engine: "k6" });

const fixtures = defineDalFixtures(({ db, fixtureScope }) => ({
	refund() {
		return fixtureScope.seed("refund", "primary", { amountCents: 1200 }, () => {
			const refundId = fixtureScope.id("refund");
			const orderId = fixtureScope.id("order");
			db.exec(`
				INSERT INTO refunds (id, order_id, amount_cents)
				VALUES ('${refundId}', '${orderId}', 1200)
			`);
			return { refundId };
		});
	},
}));

const suite = defineDalSuite({ fixtures }, ({ db, fixtures }) => {
	const refund = fixtures.refund();
	const rows = db.query(`SELECT amount_cents FROM refunds WHERE id = '${refund.refundId}'`);
	expect.rowCount(rows, 1, "the seeded refund is stored");
});

export const options = suite.options;
export const setup = suite.setup;
export default suite.exec;

fixtureScope.seed(kind, key, signature, create) identifies a fixture by its kind and key.

  • A second call with the same kind, key, and signature returns the first call's value.
  • A second call with a different signature fails the file.
  • Testkit saves the seeded records with the run as a testkit.dal-fixtures artifact.

fixtureScope also makes values that are stable for a label: id(label), uuid(label), slug(label), email(label), string(label, options), and token(label).

Remove the database

testkit destroy removes the Postgres container of each service. It keeps .testkit/results, so earlier run records stay readable.

$npx testkit destroy
Destroyed api

The next run that needs the database builds it again.

  1. OverviewStartWhat Testkit is, and what you need to run it.
  2. Get startedStartFive steps from install to a merge gate.
  3. Test filesWrite testsWhere a test lives and what its name means.
  4. Contract testsWrite testsCall your running service from Node.
  5. HTTP testsWrite testsDrive routes with k6, signed in as named actors.
  6. Browser testsWrite testsAssert on a real page in Chromium.
  7. Database testsWrite testsRun SQL against a Postgres that Testkit builds.
  8. CIRunOne command decides whether a pull request merges.
  9. ResultsRunWhere a run's evidence lands and how to read it.
  10. CLIRunEvery command and flag.
  11. ConfigurationRunEvery field of testkit.config.ts.
  12. CampaignsHunt bugsGive your agent a target and collect proven bugs.
  13. Bug reportsHunt bugsThe five things every issue states.
  14. TurnstoneTurnstoneWhat the dashboard keeps, and what it never stores.
  15. Connect GitHubTurnstoneConnect a repository, and see the access Turnstone asks for.
  16. Connect GiteaTurnstoneAdd your Gitea host, then connect a repository.
  17. SyncTurnstoneWhen Turnstone reads your issues, and what it reads.
  18. FactsTurnstoneEach fact about a bug, and where Turnstone reads it.