testkit

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Aug 4, 2026 License: MIT Imports: 8 Imported by: 0

README

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:

  1. FSMigrator.Hash() fingerprints your migration files: every path and every byte.
  2. 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.
  3. 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:

  1. edit skills/writing-tests/,
  2. bump version in both .claude-plugin/plugin.json and the plugin entry in .claude-plugin/marketplace.json,
  3. claude plugin validate --strict . to check the manifests,
  4. 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.

  1. A helper enters testkit only when a second project needs it. The first occurrence lives in that project's internal/testsupport.
  2. No domain vocabulary. If a helper needs a business noun, it belongs in testsupport.
  3. 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

Documentation

Overview

Package testkit drives an in-process HTTP application from a test and records the calls that application makes on its way out.

Both halves meet at Doer, a one-method seam over "turn a request into a response without binding a port". Client sends requests through a Doer and returns a Response whose body is already read and closed, so tests never defer a Close and can decode the same body more than once. Recorder covers the other direction: a TLS server standing in for a real outbound endpoint, capturing every request it receives so a test can assert on the webhook or API call the application actually sent.

Adapters binding a Doer to a specific web framework live in their own packages, which is what keeps a framework dependency out of this one. See github.com/tanasoft1/testkit/fiberkit for fiber.

The assert subpackage holds the assertion vocabulary; pgkit hands each test its own migrated Postgres database. What belongs in this module, and what belongs in a service's own internal/testsupport package instead, is set out in the README.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Call

type Call struct {
	Method  string
	Path    string
	Header  http.Header
	Body    []byte
	BodyErr error
}

Call is one request the Recorder received. Values returned by (*Recorder).Calls are independent copies: Header is cloned and Body is copied into a freshly allocated slice. Mutating a Call's fields, including its Header and Body, never affects the recorder's internal state or any other snapshot.

BodyErr is set when the recorder could not fully read the request body (for example, a client disconnecting mid-body); Body then holds only the bytes read before the failure, not the complete body. Calls and WaitForCalls fail the test as soon as they observe a BodyErr, rather than handing back a silently truncated Body for an assertion to misdiagnose as a bug in the code under test.

type Client

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

Client issues requests against an in-process app and returns responses whose bodies are already read and closed.

func NewClient

func NewClient(t *testing.T, d Doer) *Client

NewClient returns a Client with no default headers.

func (*Client) Get

func (c *Client) Get(path string) *Response

Get issues a GET request.

func (*Client) PostJSON

func (c *Client) PostJSON(path string, body any) *Response

PostJSON marshals body and issues a POST request with Content-Type application/json.

func (*Client) PutJSON

func (c *Client) PutJSON(path string, body any) *Response

PutJSON marshals body and issues a PUT request with Content-Type application/json.

func (*Client) With

func (c *Client) With(key, value string) *Client

With returns a copy of the Client carrying an additional header. The receiver is not modified, so a base client can be shared across subtests.

type Doer

type Doer interface {
	Do(*http.Request) (*http.Response, error)
}

Doer performs a request against the application under test. Implementations are expected to be in-process; nothing here binds a network port.

type DoerFunc

type DoerFunc func(*http.Request) (*http.Response, error)

DoerFunc adapts a function to Doer.

func (DoerFunc) Do

func (f DoerFunc) Do(req *http.Request) (*http.Response, error)

Do implements Doer.

type Recorder

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

Recorder is a TLS test server that records every request it receives. Use it in place of a real outbound endpoint. The server is closed automatically at test cleanup.

func NewRecorder

func NewRecorder(t *testing.T) *Recorder

NewRecorder starts a TLS server that responds 200 to everything until Respond or RespondFunc changes that.

func (*Recorder) Calls

func (rec *Recorder) Calls() []Call

Calls returns a snapshot of the recorded calls. Mutating the result does not affect the recorder: each Call's Header is cloned and its Body is copied into a fresh slice, so the snapshot shares no reference-typed state with the recorder or with any other snapshot.

It fails the test immediately if any recorded call's body was not fully read; see BodyErr on Call.

func (*Recorder) Client

func (rec *Recorder) Client() *http.Client

Client returns an http.Client that trusts the recorder's self-signed certificate.

func (*Recorder) Respond

func (rec *Recorder) Respond(status int)

Respond makes every subsequent request return the given status with an empty body.

func (*Recorder) RespondFunc

func (rec *Recorder) RespondFunc(h http.HandlerFunc)

RespondFunc installs a custom handler, for cases needing a body or per-call behavior.

func (*Recorder) URL

func (rec *Recorder) URL() string

URL is the base URL of the recorder, with no trailing slash.

func (*Recorder) WaitForCalls

func (rec *Recorder) WaitForCalls(n int, within time.Duration) []Call

WaitForCalls blocks until at least n calls have been recorded, then returns the snapshot. It fails the test on timeout.

The polling sleep lives here, in library code, precisely so that tests never need time.Sleep of their own.

type Response

type Response struct {
	Code   int
	Header http.Header
	Body   []byte
	// contains filtered or unexported fields
}

Response is a fully read response. Body is already closed, so callers never defer.

func (*Response) Decode

func (r *Response) Decode(target any) *Response

Decode unmarshals the body into target.

func (*Response) Status

func (r *Response) Status(want int) *Response

Status fails the test unless the status code matches, printing the body on failure.

Directories

Path Synopsis
Package assert provides the minimum assertion vocabulary the Tanasoft testing standard depends on.
Package assert provides the minimum assertion vocabulary the Tanasoft testing standard depends on.
Package fiberkit adapts a fiber app to testkit.Doer.
Package fiberkit adapts a fiber app to testkit.Doer.
Package pgkit gives each test its own pristine, migrated Postgres database.
Package pgkit gives each test its own pristine, migrated Postgres database.

Jump to

Keyboard shortcuts

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