testkit
Shared Go test harness for Tanasoft services. It lets a test drive your real HTTP
application against a real Postgres database, in about 20 milliseconds per test, with no
mocks and no shared state between tests.
testkit knows about docker, postgres and net/http. It knows nothing about any service's
schema, response envelope or domain. Those live in each project's internal/testsupport
package, and this guide shows you how to write that package.
If you have never written a test in one of these services, start at
Quickstart and work down. Everything is copy-pasteable.
For what to test and when, rather than how, see TESTING.md: the definition of
done per change type, and the policies on flakes, skips, speed and naming.
Contents
What you get
- A pristine, fully migrated Postgres database for every single test, safe under
t.Parallel(), with no truncation or rollback tricks.
- An HTTP client that drives your app in-process (no ports, no
httptest.Server for your
own app) and hands back responses already read and closed.
- A TLS server that stands in for whatever your app calls outbound, recording every
request so you can assert on it.
- Five assertion helpers with structural diffs.
What you do not get, on purpose: fixture loaders, factories, or anything that knows
what a "user" or an "order" is. That is your service's job, and
Test data explains how to write it.
Before you start
You need:
- Go 1.23 or newer.
- Docker running. Either you run a Postgres container yourself (fast, recommended) or
testkit boots a throwaway one for you (slower, zero setup). Both need a Docker daemon.
That is all. No global tools, no test runner, no config files.
Quickstart: your first test
Five steps. At the end you will have a test that hits a real endpoint against a real
database.
Step 1: add the module
go get github.com/tanasoft1/testkit
Step 2: give your tests a Postgres
testkit looks for a Postgres server in three places, in this order, and uses the first one
that answers. You only need to understand the one you choose.
| Option |
Setup |
Speed |
Use when |
| A: local compose service |
copy the snippet below |
fastest |
day-to-day development |
B: TESTKIT_POSTGRES_DSN |
export one variable |
fastest |
CI with a service container |
| C: do nothing |
none |
5 to 15s once per run |
first clone, or you just want it to work |
For option A, add this service to your project's compose setup. Port 55433 matters:
that is the port testkit probes by default.
services:
postgres-test:
image: postgres:16-alpine
environment:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: postgres
ports:
- "55433:5432"
# Storage is a tmpfs: this database is disposable and fsync is pure overhead here.
tmpfs:
- /var/lib/postgresql/data
command:
- postgres
- -c
- fsync=off
- -c
- full_page_writes=off
- -c
- max_connections=200
Then start it once and forget about it:
docker compose up -d postgres-test
For option B, point testkit anywhere:
export TESTKIT_POSTGRES_DSN='postgres://test:test@localhost:5555/postgres?sslmode=disable'
Option C needs no action. If nothing answers on 55433 and no env var is set, testkit boots
a container itself. Correct, just slower.
Step 3: create internal/testsupport
This is the package you write once per service. It is where all the domain knowledge
lives. Start with two files.
internal/testsupport/db.go: teaches pgkit how to migrate your schema.
package testsupport
import (
"database/sql"
"embed"
"errors"
"testing"
"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/postgres"
"github.com/golang-migrate/migrate/v4/source/iofs"
_ "github.com/jackc/pgx/v5/stdlib" // registers the "pgx" database/sql driver
"github.com/tanasoft1/testkit/assert"
"github.com/tanasoft1/testkit/pgkit"
)
//go:embed migrations
var migrationsFS embed.FS
// DB is shared by every test in the service. Declare it once, at package level: pgkit
// resolves a server lazily on the first Fresh call and reuses it for the whole test run.
var DB = pgkit.New(pgkit.Config{
Migrator: pgkit.FSMigrator(migrationsFS, RunMigrations),
})
// RunMigrations is your project's own migration runner. pgkit calls it exactly once per
// distinct schema, against a template database. Any function taking a DSN works here;
// golang-migrate is just the common choice.
func RunMigrations(dsn string) error {
src, err := iofs.New(migrationsFS, "migrations")
if err != nil {
return err
}
m, err := migrate.NewWithSourceInstance("iofs", src, dsn)
if err != nil {
return err
}
defer func() { _, _ = m.Close() }()
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return err
}
return nil
}
// Open connects to a database handed out by Fresh and closes it at test cleanup.
func Open(t *testing.T, dsn string) *sql.DB {
t.Helper()
db, err := sql.Open("pgx", dsn)
assert.NoErr(t, err)
// Parallel tests each hold a pool. Capping them keeps a wide suite well inside the
// server's max_connections.
db.SetMaxOpenConns(4)
t.Cleanup(func() { _ = db.Close() })
return db
}
internal/testsupport/app.go: builds your application wired to a test database.
package testsupport
import (
"database/sql"
"testing"
"github.com/tanasoft1/testkit"
"github.com/tanasoft1/testkit/fiberkit"
)
// NewApp builds the real application against the given database, and returns it as
// something testkit.Client can drive.
func NewApp(t *testing.T, dsn string) testkit.Doer {
t.Helper()
// Your service's own wiring: router, handlers, repositories, all pointed at dsn.
// Returns *fiber.App here; use testkit.DoerFunc instead if you are not on fiber.
app := buildYourApp(t, dsn)
return fiberkit.Doer(app)
}
// Client is the shortcut most tests want: a fresh database, the real app, and a client
// pointed at it.
func Client(t *testing.T) (*testkit.Client, *sql.DB) {
t.Helper()
dsn := DB.Fresh(t)
return testkit.NewClient(t, NewApp(t, dsn)), Open(t, dsn)
}
Adapt buildYourApp to however your service constructs itself. The one rule: it must
accept the DSN, so each test talks to its own database.
Step 4: write the test
package orders_test
import (
"net/http"
"testing"
"github.com/tanasoft1/testkit/assert"
"myservice/internal/testsupport"
)
func TestCreateOrder(t *testing.T) {
t.Parallel() // safe: this test has its own database
c, _ := testsupport.Client(t)
var got struct {
ID string `json:"id"`
SKU string `json:"sku"`
}
c.PostJSON("/orders", map[string]any{"sku": "A-100"}).
Status(http.StatusCreated).
Decode(&got)
assert.Equal(t, got.SKU, "A-100")
assert.True(t, got.ID != "", "response must carry an id")
}
Step 5: run it
go test ./... # everything
go test ./internal/orders/ -v # one package, verbose
go test ./internal/orders/ -run TestCreateOrder # one test
The first run against a new schema spends a moment building the template database. Every
run after that is fast. If you skipped Step 2, the first run also spends 5 to 15 seconds
booting a container.
That is the whole loop. The rest of this document explains what you just used and how to
go further.
The four packages: when and why
1. assert: saying what you expect
Use it for every assertion in every test.
Why it exists so that failures print a readable structural diff and every service
reads the same way. The one rule to remember: arguments are always (got, want), the
value your code produced first.
assert.Equal(t, got.Status, "active") // fails with a -want +got diff
assert.NoErr(t, err) // stops the test if err != nil
assert.ErrIs(t, err, orders.ErrNotFound) // errors.Is under the hood
assert.Len(t, got.Items, 3) // slices
assert.True(t, got.Total > 0, "total was %d", got.Total)
Five things worth knowing:
- Only
NoErr stops the test (Fatalf). The rest report and continue, so one run shows
every mismatch instead of only the first.
Equal is generic, so mismatched types fail at compile time.
Equal uses go-cmp, which panics on structs with unexported fields. Compare
primitives, strings, slices of those, exported structs, or pull out the fields you care
about.
Len takes slices only. For a map or string, use assert.True(t, len(m) == 2, ...).
- The helpers accept a small
TB interface, not *testing.T, which is how the package
tests its own failure messages.
2. pgkit: a database per test
Use it for any test that touches the database. That includes most handler tests: the
point of this harness is that you do not mock your repositories.
Why it exists so that tests never share state. One shared database means test B sees
test A's rows, t.Parallel() becomes unsafe, and passing depends on execution order.
pgkit gives each test a private database instead, cheaply enough that you stop thinking
about it.
How to use it: one call.
func TestSomething(t *testing.T) {
t.Parallel()
dsn := testsupport.DB.Fresh(t) // a pristine, fully migrated database
db := testsupport.Open(t, dsn) // your helper from Step 3
// ... the database is empty apart from what migrations created
}
Fresh returns a DSN string, not a connection: your test opens it however it likes, and
your app gets the same string. Two properties matter in daily use:
-
You start with schema and zero rows. Tables, indexes and constraints exist; data does
not, unless a migration inserted it. See Test data.
-
A failing test keeps its database. Passing tests drop theirs. So when a test fails you
can open the exact state that failed. Run with -v and look for the line pgtestdb logs:
testdbconf: postgres://pgtdbuser:pgtdbpass@localhost:55433/testdb_tpl_ac8c..._inst_39de...
Paste that into psql and look around.
3. testkit: driving the app and catching its outbound calls
The root package has two halves that meet at one interface.
type Doer interface {
Do(*http.Request) (*http.Response, error)
}
Anything satisfying that can be driven by a Client. It is in-process: nothing binds a
port, so there is no server to start, no address to find, and no flakiness from either.
Client: requests going in
Use it for exercising your endpoints.
c := testkit.NewClient(t, testsupport.NewApp(t, dsn))
// With returns a copy, so a base client stays clean and is safe to share across subtests
authed := c.With("Authorization", "Bearer "+token)
res := authed.PostJSON("/orders", map[string]any{"sku": "A-100"})
res.Status(http.StatusCreated) // on mismatch: fails and prints the body
res.Decode(&got) // unmarshals into your struct
// Or chained, which is how most tests read:
authed.Get("/orders/"+id).Status(http.StatusOK).Decode(&got)
Response exposes Code, Header and Body. Body is a []byte that has already been
read and closed, which means:
- you never write
defer res.Body.Close(),
- you can
Decode more than once, or inspect the raw bytes directly,
- a non-JSON response is still inspectable:
string(res.Body).
Available verbs today: Get, PostJSON, PutJSON. There is deliberately no Delete or
Patch yet (see admission rules). If you need one now, go
through the Doer directly:
req := httptest.NewRequest(http.MethodDelete, "/orders/"+id, nil)
res, err := doer.Do(req)
assert.NoErr(t, err)
If a second service needs the same verb, that is the signal to add it to Client.
Recorder: requests going out
Use it for asserting on what your app sends to the outside world: webhooks, payment
providers, notification services.
Why it exists so you can test outbound behavior without mocking your HTTP layer and
without time.Sleep.
rec := testkit.NewRecorder(t) // a TLS server, closed automatically at cleanup
rec.Respond(http.StatusAccepted) // what it answers; default is 200
// Point your app at it. Both parts matter: the URL and the client that trusts its cert.
app := testsupport.NewAppWithWebhook(t, dsn, rec.URL(), rec.Client())
c := testkit.NewClient(t, app)
c.PostJSON("/orders", body).Status(http.StatusCreated)
// Block until the outbound call arrives, then assert on it.
calls := rec.WaitForCalls(1, 2*time.Second)
assert.Equal(t, calls[0].Method, http.MethodPost)
assert.Equal(t, calls[0].Path, "/webhooks/order-created")
assert.Equal(t, calls[0].Header.Get("X-Signature"), wantSig)
Notes that save time:
- The recorder is TLS. Your app must use
rec.Client(), or you get a certificate
error. This is why services should accept an injected *http.Client.
Calls() returns a snapshot, deep-copied. Mutating it cannot affect the recorder.
WaitForCalls owns the polling, so your tests never need time.Sleep. Use it for
anything asynchronous.
RespondFunc installs a full handler when you need a response body, per-call
behavior, or a reply that depends on the request. The handler can read r.Body
normally.
rec.RespondFunc(func(w http.ResponseWriter, r *http.Request) {
var in struct{ OrderID string `json:"order_id"` }
_ = json.NewDecoder(r.Body).Decode(&in)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"received":"` + in.OrderID + `"}`))
})
4. fiberkit: plugging your framework in
Use it for one line in your testsupport package.
Why it exists so no other package imports fiber. Framework adapters are quarantined
behind the Doer seam, which is what keeps the root package framework-agnostic.
doer := fiberkit.Doer(app) // routes through app.Test, fiber's in-process test path
If your service uses something else, you do not need a package at all: DoerFunc adapts
any function.
doer := testkit.DoerFunc(func(req *http.Request) (*http.Response, error) {
rec := httptest.NewRecorder()
myMux.ServeHTTP(rec, req)
return rec.Result(), nil
})
When a second service uses that same framework, promote the adapter into its own package
here, next to fiberkit.
How pgkit makes this fast
You do not need this section to write tests, but it explains every surprising thing you
might hit.
Resolving a server. On the first Fresh call in a test binary, pgkit tries the DSN
environment variable, then a login probe against ComposeDSN (port 55433), then boots a
container. The probe logs in with real credentials rather than dialing TCP, so a foreign
Postgres occupying the port is rejected instead of mistaken for yours. The result is cached
for the whole test binary.
Handing out databases. Migrations do not run per test. Instead:
FSMigrator.Hash() fingerprints your migration files: every path and every byte.
- That hash names a template database on the server. If it does not exist yet, pgkit
creates it and runs your migrations into it, once.
- Each
Fresh call issues CREATE DATABASE ... TEMPLATE ..., which Postgres implements
as a file-level copy. That is the ~20ms, and why every test can afford its own database.
Because the template lives on the server, the expensive work survives: other tests, other
packages, and later runs. Migrations re-run only when the fingerprint changes.
| Situation |
Migrations run? |
| First test of a new schema |
once |
| Every other test |
no, it clones |
| A different package's tests |
no, same server and hash |
Tomorrow's go test |
no, the compose server still holds the template |
| You edit or rename a migration |
once more, new hash and new template |
You ran docker compose down |
once more, tmpfs wiped the templates |
The last row is normal: the template is a cache, not data. Losing it costs one migration
run.
One caveat. The hash covers the fs.FS you pass to FSMigrator, not your Go
migration code. If you change seeding logic written in Go, the hash does not change and
you keep cloning a stale template. Keep anything that affects the schema or seed data in
files inside the embedded FS.
Test data: the three tiers
Your test starts with an empty schema. Where the data comes from depends on what kind of
data it is. Three tiers, three different answers.
Tier 1: reference data, put it in a migration
What: rows every environment needs, that the app treats as part of the schema.
Currencies, roles, country codes, plan tiers, feature flags.
How: a normal migration file, for example 003_seed_roles.up.sql.
INSERT INTO roles (code, label) VALUES
('admin', 'Administrator'),
('member', 'Member');
Why there: it lands in the template, so every test's clone carries it for free inside
the same ~20ms copy. Zero per-test cost, and the hash already covers it because the file
lives in the embedded FS.
Tier 2: scenario data, write factory helpers
What: the data one test's story needs. "A user with two orders, one refunded."
How: domain-aware factories in your internal/testsupport. This is the default and
where most of your test-data code will live.
// internal/testsupport/fixtures.go
type OrderOpt func(*Order)
func WithStatus(s string) OrderOpt { return func(o *Order) { o.Status = s } }
// SeedOrder inserts one order and returns it. Insert with raw SQL, not through the
// service's repository layer: a bug in the repository would then corrupt both the setup
// and the thing under test, and the two can cancel out into a false pass.
func SeedOrder(t *testing.T, db *sql.DB, opts ...OrderOpt) Order {
t.Helper()
o := Order{ID: uuid.NewString(), SKU: "A-100", Status: "pending"}
for _, opt := range opts {
opt(&o)
}
_, err := db.Exec(
`INSERT INTO orders (id, sku, status) VALUES ($1, $2, $3)`,
o.ID, o.SKU, o.Status)
assert.NoErr(t, err)
return o
}
Used from a test:
c, db := testsupport.Client(t)
refunded := testsupport.SeedOrder(t, db, testsupport.WithStatus("refunded"))
c.Get("/orders/"+refunded.ID).Status(http.StatusOK).Decode(&got)
assert.Equal(t, got.Status, "refunded")
Why per test and not shared: each test declares what it needs, right next to the
assertion that depends on it. The alternative, one big seed dataset every test leans on
("user 42 exists"), couples tests to a distant file: editing it for one test silently
breaks thirty others. The template-clone model removes the usual excuse for sharing, which
was setup cost.
Cost: negligible. Against a tmpfs, fsync-off server an insert is a fraction of a
millisecond. Seeding 20 rows adds low single-digit milliseconds to the 20ms clone.
Tier 3: a heavy shared dataset, build a second template
What: rare cases needing thousands of rows, for example exercising a reporting query
or pagination at scale.
How: not per test. Give it its own pgkit.Server whose migrator migrates and seeds,
with the seed as SQL files inside the hashed FS.
//go:embed migrations seed
var dbFS embed.FS
// Schema only: what most tests use.
var DB = pgkit.New(pgkit.Config{
Migrator: pgkit.FSMigrator(migrationsFS, RunMigrations),
})
// Schema plus a large dataset. Different hash, so it gets its own template and the two
// coexist on the same server.
var SeededDB = pgkit.New(pgkit.Config{
Migrator: pgkit.FSMigrator(dbFS, func(dsn string) error {
if err := RunMigrations(dsn); err != nil {
return err
}
return applySeedSQL(dsn) // executes seed/*.sql out of dbFS
}),
})
Why: the seed cost is paid once per schema-plus-seed version, and tests still clone in
~20ms. Note the FS passed to FSMigrator embeds both directories, so editing a seed file
invalidates the template. That is exactly the caveat from the previous section, handled.
Which tier?
| Your data |
Goes in |
Runs |
| Every environment needs it (roles, currencies) |
a migration |
once per schema version |
| This test's story (a user, an order) |
testsupport factories |
per test, effectively free |
| Thousands of rows, many tests share it |
seed SQL in a second server's FS |
once per seed version |
When unsure, start at Tier 2. Moving up to Tier 1 or 3 later is easy; untangling tests
from a shared dataset is not.
Recipes
Authenticated requests. Build the header once, branch per subtest.
base := testkit.NewClient(t, testsupport.NewApp(t, dsn))
admin := base.With("Authorization", "Bearer "+testsupport.AdminToken(t))
member := base.With("Authorization", "Bearer "+testsupport.MemberToken(t))
admin.Get("/admin/reports").Status(http.StatusOK)
member.Get("/admin/reports").Status(http.StatusForbidden)
Asserting an error response.
res := c.PostJSON("/orders", map[string]any{"sku": ""}).Status(http.StatusUnprocessableEntity)
var problem struct {
Message string `json:"message"`
}
res.Decode(&problem)
assert.Equal(t, problem.Message, "sku is required")
Table-driven and parallel. Each subtest gets its own database, so the loop is safe.
cases := []struct {
name string
status string
wantCode int
}{
{"pending can be cancelled", "pending", http.StatusOK},
{"shipped cannot", "shipped", http.StatusConflict},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
c, db := testsupport.Client(t)
order := testsupport.SeedOrder(t, db, testsupport.WithStatus(tc.status))
c.PostJSON("/orders/"+order.ID+"/cancel", nil).Status(tc.wantCode)
})
}
Asserting on database state after a request. Sometimes the response is not the point.
c, db := testsupport.Client(t)
c.PostJSON("/orders", map[string]any{"sku": "A-100"}).Status(http.StatusCreated)
var count int
assert.NoErr(t, db.QueryRow(`SELECT count(*) FROM outbox`).Scan(&count))
assert.Equal(t, count, 1)
An outbound call that fails. Make the recorder misbehave and check your app copes.
rec := testkit.NewRecorder(t)
rec.Respond(http.StatusServiceUnavailable)
// ... trigger the call ...
calls := rec.WaitForCalls(3, 2*time.Second) // proves the retry policy actually retries
assert.Len(t, calls, 3)
CI for your service. Either let the container fallback handle it (nothing to
configure), or point testkit at a service container:
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: postgres
ports: ["55433:5432"]
options: >-
--health-cmd pg_isready --health-interval 10s
--health-timeout 5s --health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- run: go test -race ./...
env:
TESTKIT_POSTGRES_DSN: postgres://test:test@localhost:55433/postgres?sslmode=disable
Configuration reference
pgkit.Config fields, all optional except Migrator:
| Field |
Default |
Purpose |
Migrator |
required |
how to build your schema. Use FSMigrator. |
DSNEnv |
TESTKIT_POSTGRES_DSN |
the environment variable checked first |
ComposeDSN |
postgres://test:test@localhost:55433/postgres?sslmode=disable |
probed second |
Image |
postgres:16-alpine |
image for the container fallback |
ReachableTimeout |
3s |
budget for the ComposeDSN login probe |
Environment variables:
| Variable |
Effect |
TESTKIT_POSTGRES_DSN |
use this server, skip all probing |
Raise ReachableTimeout if your machine is slow or heavily loaded: losing that probe
costs a container boot, and because the tests still pass the only symptom is an
unexplained slowdown.
Troubleshooting
pgkit: resolve postgres server: pgkit: Config.Migrator is required
You built a pgkit.Config without a Migrator. Pass pgkit.FSMigrator(fsys, run).
Tests hang for 10 to 15 seconds, then pass
Nothing answered on port 55433, so testkit booted a container. Start your compose service
(Step 2, option A) or set TESTKIT_POSTGRES_DSN.
resolve postgres server: start postgres container: ...
All three branches failed: no env var, nothing on 55433, and no usable Docker daemon.
Start Docker, or point TESTKIT_POSTGRES_DSN at a reachable server.
A compose Postgres is running but testkit still boots a container
Either it is not on port 55433, or its credentials are not test/test, or it did not
answer within ReachableTimeout. The probe validates identity on purpose, so a different
Postgres on the same port is correctly ignored.
relation "orders" does not exist
Migrations did not run. Usually the //go:embed path does not match your directory, so the
FS is empty and hashes to an empty schema. Confirm the directory name and that migration
files are actually in the built binary.
You changed a migration but the schema did not change
Only files in the embedded FS feed the hash. If the change lives in Go code, the hash is
identical and you cloned the old template. Move the change into a file inside the FS, or
wipe the server (docker compose down on the test service) to force a rebuild.
FATAL: sorry, too many clients already
Parallel tests each hold a connection pool. Cap pools in your Open helper
(db.SetMaxOpenConns(4)), and make sure your server was started with
max_connections=200 as in the Step 2 snippet.
x509: certificate signed by unknown authority when your app calls the Recorder
The recorder is TLS. Your app must use rec.Client(), which trusts its self-signed
certificate. If the app builds its own http.Client internally, make that injectable.
panic: cannot handle unexported field from assert.Equal
go-cmp refuses types with unexported fields. Compare exported structs, primitives, or the
specific fields you care about.
A test failed and left a database behind
That is deliberate, so you can inspect it. Run with -v, find the testdbconf: line, and
open that DSN in psql. It disappears when the test server is wiped.
Writing tests with Claude Code
This repo ships a Claude Code skill that encodes everything above: which kind of test a
change actually needs, how to scaffold internal/testsupport, the data tiers, the house
conventions, and the patterns per test kind.
Declare it in each service repo, at project scope, once, by one person:
/plugin marketplace add tanasoft1/testkit --scope project
/plugin install testkit@tanasoft-testkit --scope project
That writes .claude/settings.json. Commit it, and everyone who opens the repo has the skill
without running anything:
{
"extraKnownMarketplaces": {
"tanasoft-testkit": {
"source": { "source": "github", "repo": "tanasoft1/testkit" }
}
},
"enabledPlugins": {
"testkit@tanasoft-testkit": true
}
}
Project scope rather than the default user scope, deliberately. A user-scope install means
every developer opts in separately and nobody can tell who has, which is not a standard. This
declares the dependency next to go.mod, makes it reviewable in a PR, and keeps the skill out
of repos that have nothing to do with testkit. No version is pinned, so the repo tracks
whatever the marketplace resolves to. Confirm with claude plugin list, which should report
Scope: project.
Note the committed source must be github: the CLI writes an absolute directory path, which
resolves only on the machine that created it, so a service repo needs the remote to exist.
testkit itself is the exception, and dogfoods the skill through its own
.claude/settings.json with a relative "path": ".", which works on
any clone.
Then just ask for tests in the service repo. The skill loads on its own for requests like
"write tests for the orders handler", "cover this bug", or "what should I test here". To
invoke it explicitly: /testkit:writing-tests.
What the skill decides for you, which is the part worth having:
| It will |
Rather than |
| Pick the test kind from where the risk lives: integration, repository, pure unit, outbound, or none |
writing an integration test for everything, or a mocked unit test for everything |
| Refuse to test getters, mapping code, framework routing, or library behavior |
padding coverage with tests that protect nothing |
Use a real database, because Fresh costs about 20ms |
mocking the repository |
Poll via WaitForCalls |
time.Sleep |
| Write one test per behavior, table-driven for variations |
one test per function, or one giant test per endpoint |
| Prove a bug-fix test was red before the fix |
claiming a green test proves the fix works |
The skill source lives in skills/writing-tests/, which is why it
is distributed as a plugin rather than copied into each repo: there is one place to change
it.
Changing it is a release, not just an edit. Installing copies the plugin into
~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/, and /plugin update compares
version numbers, not commits. An edit without a version bump reaches nobody. So:
- edit
skills/writing-tests/,
- bump
version in both .claude-plugin/plugin.json and the plugin entry in
.claude-plugin/marketplace.json,
claude plugin validate --strict . to check the manifests,
- commit, then have services run
/plugin update testkit@tanasoft-testkit and restart.
Verify a change actually landed by grepping the installed copy, not the repo:
grep -r "your new text" ~/.claude/plugins/cache/tanasoft-testkit/testkit/
What belongs in testkit
testkit is a published module: once a version ships it cannot be withdrawn, and every
service inherits whatever is in it. Three rules gate what gets in.
- A helper enters testkit only when a second project needs it. The first occurrence lives
in that project's
internal/testsupport.
- No domain vocabulary. If a helper needs a business noun, it belongs in testsupport.
- Nothing secret or internal. A published module version cannot be withdrawn.
In practice: anything that names a thing your business sells goes in your service. Anything
about docker, postgres, or HTTP plumbing that two services both need is a candidate here.
Developing testkit itself
docker compose -f docker-compose.test.yml up -d # the fast local Postgres, port 55433
go test ./... # full suite
go test ./pgkit/ -run TestFreshIsolatesTests # a single test
go test -race ./...
go vet ./...
gofmt -l .
go doc ./pgkit # check what a package publishes
CI (.github/workflows/ci.yml) runs gofmt, vet and go test -race on push and pull
request. It configures no Postgres service on purpose, so every CI run resolves through
the container fallback: the one branch local runs never take, because the compose server
wins the probe first.
Repository layout:
| Path |
Contents |
doer.go |
the Doer seam |
client.go |
Client, Response, the JSON verbs |
recorder.go |
Recorder, Call, WaitForCalls |
fiberkit/ |
the fiber adapter, the only fiber import |
pgkit/server.go |
server resolution, Fresh |
pgkit/migrator.go |
FSMigrator and the schema hash |
assert/ |
the five helpers |
docker-compose.test.yml |
the fast local Postgres |
skills/writing-tests/ |
the Claude Code skill, distributed to services as a plugin |
.claude-plugin/ |
plugin and marketplace manifests for that skill |
TESTING.md |
the standard: what to test, when, and what a reviewer rejects |
scripts/check-test-conventions.sh |
the CI-enforceable part of that standard |