tstr
Package overview, exported API, and runnable examples extracted from the latest tagged release.
v0.2.6
Declare the containers, compose stacks, commands, and Go binaries your tests need. tstr starts them in order, waits until each is ready, runs go test as usual, and tears everything down in reverse.
go get github.com/go-tstr/tstr@latest
Application, integration, and black-box tests need real things running: a database, a message broker, the service under test. Wiring their startup, readiness checks, and cleanup by hand in every repo is repetitive and easy to get wrong.
Every dependency implements Start, Ready, and Stop. The runner starts them in order, blocks on each Ready, and stops them in reverse. Plain functions slot in through depfn.New.
Hook the runner into TestMain with tstr.RunMain for one environment per package, or scope it to a single test with tstr.Run. No extra CLI, no test harness to learn, just go test.
Ready-made dependencies for Docker containers via testcontainers-go, compose stacks, arbitrary commands, and cmd.WithGoCode, which compiles your own service with coverage instrumentation and runs it as a dependency.
Stop runs on every started dependency even when a later one fails to start or become ready. Errors from start, test, and stop are joined and reported, never swallowed. RunMain exits with the test binary's code.
Three dependencies and a test suite going through tstr's runner under different scenarios. One row plays at a time; hover any row to lock the animation to it.
Dependencies start one at a time and each blocks until ready. Tests run once all are up. Dependencies stop in reverse order.
D2 never becomes ready. D3 is never started and the tests never run. D2 and D1 are still stopped, in reverse, and the error is reported.
Tests fail. Every dependency is still stopped in reverse order, and RunMain exits with the test binary's non-zero exit code.
A black-box test suite for an HTTP service. A Postgres container comes up first, the service is compiled from source with coverage enabled and started with the container's connection string, and each request is asserted against a golden file. All of it runs with a plain go test ./....
package e2e
import (
"context"
"net/http"
"strings"
"testing"
"github.com/go-tstr/golden"
"github.com/go-tstr/tstr"
"github.com/go-tstr/tstr/dep/cmd"
"github.com/go-tstr/tstr/dep/container"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go/modules/postgres"
)
const addr = "127.0.0.1:8080"
func TestMain(m *testing.M) {
db := container.New(
container.WithModule(postgres.Run, "postgres:16-alpine",
postgres.WithDatabase("app"),
postgres.WithUsername("app"),
postgres.WithPassword("secret"),
postgres.BasicWaitStrategies(),
),
)
tstr.RunMain(m, tstr.WithDeps(
db,
cmd.New(
cmd.WithGoCode("../", "./app"),
withDSN(db),
cmd.WithEnvAppend("ADDR="+addr),
cmd.WithGoCover(),
cmd.WithReadyHTTP("http://"+addr+"/ready"),
),
))
}
// withDSN reads the connection string from the container started before the app.
func withDSN(db *container.Container) cmd.Opt {
return func(c *cmd.Cmd) error {
pg := db.Container().(*postgres.PostgresContainer)
dsn, err := pg.ConnectionString(context.Background(), "sslmode=disable")
if err != nil {
return err
}
return cmd.WithEnvAppend("DSN=" + dsn)(c)
}
}
func TestUsers(t *testing.T) {
tests := []struct {
name string
method string
path string
body string
code int
}{
{name: "create", method: http.MethodPost, path: "/api/v1/users", body: `{"name":"ada"}`, code: http.StatusCreated},
{name: "list", method: http.MethodGet, path: "/api/v1/users", code: http.StatusOK},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, err := http.NewRequest(tt.method, "http://"+addr+tt.path, strings.NewReader(tt.body))
require.NoError(t, err)
golden.Request(t, http.DefaultClient, req, tt.code)
})
}
}
Options are applied when a dependency starts, so a custom cmd.Opt can read the database connection string from the container that was started just before it. Set GOLDEN_FILES_RECREATE=true to regenerate the expected responses and review the diff in git.
Anything with Start, Ready, and Stop is a dependency. For small glue, depfn.New wraps three plain functions, and a nil function is a no-op. deptest.ErrorIs runs a single dependency through the runner so you can unit test your own.
package custom
import (
"errors"
"net"
"os"
"testing"
"github.com/go-tstr/tstr"
"github.com/go-tstr/tstr/dep/depfn"
"github.com/go-tstr/tstr/dep/deptest"
"github.com/stretchr/testify/require"
)
// Listener owns a TCP listener for the duration of a test.
type Listener struct {
Addr string
l net.Listener
}
func (d *Listener) Start() (err error) {
d.l, err = net.Listen("tcp", "127.0.0.1:0")
if err == nil {
d.Addr = d.l.Addr().String()
}
return err
}
func (d *Listener) Ready() error { return nil }
func (d *Listener) Stop() error { return d.l.Close() }
func TestWithCustomDeps(t *testing.T) {
ln := &Listener{}
err := tstr.Run(
tstr.WithDeps(
ln,
// Small glue as three plain functions. A nil function is a no-op.
depfn.New(
func() error { return os.Setenv("APP_ADDR", ln.Addr) },
nil,
func() error { return os.Unsetenv("APP_ADDR") },
),
),
tstr.WithFn(func() {
conn, err := net.Dial("tcp", os.Getenv("APP_ADDR"))
require.NoError(t, err)
require.NoError(t, conn.Close())
}),
)
require.NoError(t, err)
}
var errNotReady = errors.New("not ready")
func TestReadyFailure(t *testing.T) {
// deptest runs a single dependency through the runner and asserts on the error.
d := depfn.New(nil, func() error { return errNotReady }, nil)
deptest.ErrorIs(t, d, nil, errNotReady)
}
Start should return quickly, Ready should block until the dependency is usable, and Stop should block until it is gone. The runner takes care of ordering and error aggregation.
Package overview, exported API, and runnable examples extracted from the latest tagged release.
v0.2.6
Ready-made dependencies for containers, compose stacks, commands, compiled Go binaries, and plain functions.
5 packages
Golden file assertions for strings and HTTP responses. Recreate expected output with an env flag.
v0.1.1