testkit

package
v1.2.9 Latest Latest
Warning

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

Go to latest
Published: Oct 11, 2026 License: MIT Imports: 20 Imported by: 0

Documentation

Overview

Package testkit holds the test rigs that were copied from package to package: running a tool's entry point in process with both streams captured and checked (verb.go), writing and reading the files a test sets up (here and tree.go), a recording wait for code a synctest bubble cannot hold (waits.go) and a skip by platform (skip.go). Each helper fails the test through testify's require, so a caller's setup is one line. Time in a test is testing/synctest's first, a clockwork.FakeClock where code does real I/O, and never a clock of the kit's own.

A tool's tests keep one adapter of their own, the entry point as a Main, and call its methods. A tool whose entry point takes a clock, an environment or an app closes over it in that one line:

var cairn = testkit.Main(run)
var bus = testkit.Main(func(a []string, in io.Reader, o, e io.Writer) int { return run(a, in, o, e, now) })
out := cairn.OK(t, "open", "--store", dir).Stdout
r := cairn.Run("open") // r.Code, r.Stdout, r.Stderr
cairn.Do(t, "open").Exit(2).Refused("--store is required")

Index

Constants

View Source
const (
	NoSuchFlag  = "--no-such-flag-breadcrumb"
	NoSuchFlag2 = "--no-such-flag-either"
)

The probes' names: nothing defines them, so every tool refuses them.

Variables

This section is empty.

Functions

func Contract

func Contract(t testing.TB, m Main, verbs []string)

Contract runs the skeleton contract's probes on a tool (docs/STANDARD.md, the skeleton contract's "How a port is proved"): the bare command refuses in one line naming its door at exit 2; an unknown verb refuses naming the nearest; an unknown flag on each verb refuses naming it; two bad flags in one run are both named; --json on a refusal is one object on stdout at the same exit; <verb> -h answers on stdout at exit 0 with nothing on stderr; and a refusal's remedy run back through the tool does not refuse for the same reason. Every breach is reported with assert, naming the probe, so one call holds a tool to the whole shape.

verbs are the tool's verbs, each as its own words ("send", "fn load"). Every verb named must take --json and answer -h: Contract holds the verbs the caller lists, and a verb that prints its own output (tool.Flags.Prints) or refuses help (tool.HelpRefused) is left out by its tool's own contract test.

func DryRunAgrees

func DryRunAgrees(t *testing.T, run func(args ...string) Result, cases []DryCase)

DryRunAgrees holds a verb's --dry-run to its real run: on each case the dry run exits as the real run does, with the same status words opening its first line (a refusal refuses, a failure fails), and leaves the tree under its root byte-identical. The two runs get identical fresh trees, so the real run's writes cannot change what the dry run sees.

func JSON

func JSON[T any](t testing.TB, raw string) T

JSON decodes raw (a tool's --json output, a stored record) into a T, the three lines of a declared value, json.Unmarshal and its require as one expression, failing the test with the text when it does not decode.

func ReadFile

func ReadFile(t testing.TB, path string) string

ReadFile returns the file's contents, failing the test when it cannot be read.

func ReadJSON

func ReadJSON[T any](t testing.TB, path string) T

ReadJSON decodes the JSON file at path into a T, failing the test when the file cannot be read or does not decode.

func Refusals

func Refusals(t testing.TB, m Main, rows []Refusal)

Refusals runs each row and checks it as Exit(row.Code) and Refused(row.Says) do, so a tool's refusal tests are one row each. A row that misses fails the test through assert, naming the run, and the rows after it still run. The rows run one after another: m may close over state its runs share.

func SkipOn

func SkipOn(t testing.TB, goos, why string)

SkipOn skips the test when it runs on the operating system goos (a runtime.GOOS value), saying why the property cannot be observed there:

testkit.SkipOn(t, "windows", "chmod 0 does not refuse reads")

func Snapshot

func Snapshot(t testing.TB, root string) map[string]string

Snapshot is every path under root with its mode and, for a regular file, its bytes; a link is recorded by its target and not followed. Two snapshots are equal only when nothing under root was written, created or removed.

func Tree

func Tree(t testing.TB, root string, files map[string]string) string

Tree writes each file of files under root, making root and the directories, overwriting a file already there, and returns root. A name is a slash-separated path inside root. Every name is checked before anything is written (filepath.IsLocal: not absolute, never climbing out with ".."), and every write goes through an os.Root opened on root, which refuses to follow a symlink out of it; either refusal fails the test naming the path.

root := testkit.Tree(t, t.TempDir(), map[string]string{"tla/CASES.tsv": plan, "go.mod": "module x\n"})

A copy of a fixture directory needs no helper: require.NoError(t, os.CopyFS(dst, os.DirFS(src))).

func Unwritable

func Unwritable(t *testing.T, dir string)

Unwritable takes the write permission off dir for the rest of the test.

func WriteFile

func WriteFile(t testing.TB, path, body string)

WriteFile writes body to path with mode 0o644, making the parent directories first, and fails the test on any error.

Types

type Call

type Call struct {
	Name   string
	Args   []any
	Result any
	Err    error
}

Call is one expected call of a Script: the transport method's name, its arguments, and the result and error the method returns.

type DryCase

type DryCase struct {
	Name     string
	Unwrites bool // the case takes a directory's write permission away (skipped on Windows and as root)
	Setup    func(t *testing.T, root string) (verb, rest []string)
}

DryCase is one bad input for a verb that writes: Setup lays it out under a fresh root and returns the verb's words and the rest of its arguments, so the case runs as `<verb> --dry-run <rest>` and as `<verb> <rest>`.

type GitRig

type GitRig struct {

	// Remote is the bare remote's path; Clones are the clones', in the order made.
	Remote string
	Clones []string
	// contains filtered or unexported fields
}

GitRig is a bare git remote and clones of it, all under the test's own t.TempDir(), for the tests that must run real git (docs/STANDARD.md, section 8: shared rigs live in pkg/testkit, never copies; section 9 rule 10: a test writes only inside its own t.TempDir()). Every command runs with a fixed identity and GIT_CONFIG_GLOBAL pointed at an empty file inside the rig's directory, through the command's environment, never t.Setenv, so the machine's own git identity and config cannot reach the test.

func Git

func Git(t testing.TB, n int) *GitRig

Git builds a rig of one bare remote and n clones of it, failing the test when git is not on PATH or a git command fails. It runs the git exec.LookPath finds on PATH and no other.

func (*GitRig) Commit

func (g *GitRig) Commit(clone string, files map[string]string)

Commit writes files (a path under the clone to its body) into the clone and commits them under the rig's fixed identity.

func (*GitRig) Head

func (g *GitRig) Head(repo string) string

Head returns the commit HEAD names in repo, the remote's or a clone's.

func (*GitRig) Push

func (g *GitRig) Push(clone string)

Push is one git push of the clone's current branch from the clone to the rig's remote, so the remote's branch follows the clone's.

type Main

type Main func(args []string, stdin io.Reader, stdout, stderr io.Writer) int

Main is a tool's entry point in process: the arguments after the tool's name, stdin, the two output streams, and the exit code it returns.

func (Main) Do

func (m Main) Do(t testing.TB, args ...string) Ran

Do runs the entry point with args and an empty stdin. It checks nothing by itself: the checks are the Ran's methods.

func (Main) DoIn

func (m Main) DoIn(t testing.TB, stdin string, args ...string) Ran

DoIn is Do with stdin holding the given text.

func (Main) NoStdin

func (m Main) NoStdin() func(args []string, stdout, stderr io.Writer) int

NoStdin is the entry point in the shape that reads no stdin, as testverbhelp.Run takes it.

func (Main) OK

func (m Main) OK(t testing.TB, args ...string) Result

OK is Run that fails the test unless the exit code is 0, naming the arguments and both streams.

func (Main) OKIn

func (m Main) OKIn(t testing.TB, stdin string, args ...string) Result

OKIn is OK with stdin holding the given text.

func (Main) Run

func (m Main) Run(args ...string) Result

Run calls the entry point with args and an empty stdin. It never fails the test: the caller asserts on the Result.

func (Main) RunIn

func (m Main) RunIn(stdin string, args ...string) Result

RunIn is Run with stdin holding the given text.

type Ran

type Ran struct {
	Result
	Args []string
	// contains filtered or unexported fields
}

Ran is one run of a Main made inside a test, with its checks as methods. Each check is the testify require call it names, with the Ran as its message, so a failing line prints the arguments run, the exit code and both streams; each returns the Ran, so checks chain:

tool.Do(t, "send").Exit(2).Err("--to is required").NotOut("OK")

The fields are the Result's; a check the methods do not cover is a testify call with the Ran as its message: assert.Empty(t, r.Stdout, r).

func (Ran) Err

func (r Ran) Err(wants ...string) Ran

Err is require.Contains on stderr, for each of wants.

func (Ran) Exit

func (r Ran) Exit(want int) Ran

Exit is require.Equal on the exit code.

func (Ran) NotErr

func (r Ran) NotErr(nots ...string) Ran

NotErr is require.NotContains on stderr, for each of nots.

func (Ran) NotOut

func (r Ran) NotOut(nots ...string) Ran

NotOut is require.NotContains on stdout, for each of nots.

func (Ran) Out

func (r Ran) Out(wants ...string) Ran

Out is require.Contains on stdout, for each of wants.

func (Ran) Refused

func (r Ran) Refused(says string) Ran

Refused fails the test unless the run is a refusal in the house grammar that says says: a non-zero exit, and a stderr line holding says that also carries the status word REFUSED and a remedy (`; run: `). Every clause it misses is named at once.

func (Ran) String

func (r Ran) String() string

String is the run as a failure prints it: the arguments, the exit code and both streams.

type Refusal

type Refusal struct {
	Args []string
	Code int
	Says string
}

Refusal is one row of a tool's refusal table: the arguments, the exit code wanted, and what the refusal line must say.

type Result

type Result struct {
	Code           int
	Stdout, Stderr string
}

Result is one run of a Main: the exit code and everything each stream got.

type Script

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

Script is a strict fake for a transport interface: the calls a test declares, consulted in order as the code under test makes them. An unexpected call, a call out of order, or a declared call the run never made fails the test naming it (docs/STANDARD.md section 8: "A fake is strict like the real tool: it refuses what the real one refuses"). Every call is declared: there is no matcher and no wildcard.

A package wraps a Script for its own transport interface: its fake holds the Script and each interface method passes its own name and arguments to Called, then returns the declared result and error. The example is TestScriptAnswersDeclaredCallsInOrder in script_test.go:

type fakeStore struct{ *testkit.Script }

func (f fakeStore) Get(key string) (string, error) {
	got, err := f.Called("Get", key)
	if err != nil {
		return "", err
	}
	return got.(string), nil
}

s := testkit.NewScript(t, testkit.Call{Name: "Get", Args: []any{"k"}, Result: "v"})

func NewScript

func NewScript(t testing.TB, calls ...Call) *Script

NewScript declares the calls a fake may receive, in order, and arms the leftover check at cleanup: every declared call the run never makes fails t then, naming it (the AssertExpectations step of the transport design). A caller that wants the check earlier calls AssertExpectations itself.

func (*Script) AssertExpectations

func (s *Script) AssertExpectations(t testing.TB)

AssertExpectations fails t for every declared call the run never made, naming them in order. NewScript arms it at cleanup, so a test names its calls and does not call this itself; a helper that has to check before cleanup calls it.

func (*Script) Called

func (s *Script) Called(name string, args ...any) (any, error)

Called is one call the fake received. It checks the call against the next declared one and fails t naming both when they differ, so a wrong call is never answered: the declared result and error are returned only for the declared call. A wrapper passes the transport method's name and arguments, and reads the two values at the arity its method returns.

type Waits

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

Waits is a fake for a wait seam of the shape func(stop <-chan struct{}, d time.Duration) bool: "true once d has passed, false when stop is observed closed" (delayproxy.Clock.Wait). It is for code whose tests cannot run in a testing/synctest bubble because the code blocks on real I/O (a loopback socket), and it is not a clock: it records every d asked for and returns true at once, unless the wait is held.

A held wait is the test's hand inside the code: the code stops in its wait, the test looks at what it has done so far (Holding tells it when), and then releases the waits (true) or closes stop (false). An already-closed stop makes even an unheld wait return false. If stop and release are both ready when a held wait chooses, stop wins; Wait does not infer which channel was closed first. What a held wait waits for is the test, never the time, so the first of two waits of the same length can be held while the second goes through, which no clock can do.

func NewWaits

func NewWaits() *Waits

NewWaits is a Waits that holds none of its waits.

func (*Waits) Asked

func (w *Waits) Asked() []time.Duration

Asked is every d a wait was asked for, in the order asked.

func (*Waits) Hold

func (w *Waits) Hold(n int) *Waits

Hold makes the first n waits held (every wait when n is negative) until Release or their stop. It is set before the code runs, and returns w.

func (*Waits) Holding

func (w *Waits) Holding(n int)

Holding returns once at least n waits are held at the same moment.

func (*Waits) Release

func (w *Waits) Release()

Release ends every held wait with true, and lets every later wait through. It is called once.

func (*Waits) Wait

func (w *Waits) Wait(stop <-chan struct{}, d time.Duration) bool

Wait is the seam: it records d, and returns true at once unless stop is already closed. For a held wait, stop wins if it is ready when the wait chooses between stop and Release.

Jump to

Keyboard shortcuts

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