From 71d31d73e7dc3b9cdb3c53b4f343b1928a9e858b Mon Sep 17 00:00:00 2001 From: Stefan Majewsky Date: Thu, 18 Jun 2026 23:23:36 +0200 Subject: add package testcapture, package assert (minimal implementation) --- testcapture/capture.go | 416 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 416 insertions(+) create mode 100644 testcapture/capture.go (limited to 'testcapture/capture.go') diff --git a/testcapture/capture.go b/testcapture/capture.go new file mode 100644 index 0000000..3ac4f52 --- /dev/null +++ b/testcapture/capture.go @@ -0,0 +1,416 @@ +// SPDX-FileCopyrightText: 2026 Stefan Majewsky +// SPDX-License-Identifier: Apache-2.0 + +// Package testcapture contains [Capture], a function that executes test code in a way that captures error messages and side effects without failing the overall test. +// +// The main intended use case is testing test assertions where calls to e.g. t.Error() are an expected part of a successful test run. +package testcapture + +import ( + "context" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "runtime" + "slices" + "sync" + "sync/atomic" + + "go.xyrillian.de/gg/assert" +) + +// Result is returned by func [Capture]. +type Result struct { + // Outcome describes how the test ended. + Outcome Outcome + // Panic contains a payload recovered from a panic(), if Outcome is [OutcomePanicked]. + Panic any + // Messages contains log lines captured from t.Log() calls, or functions calling t.Log(), such as t.Error() and t.Fatal(); + // as well as data captured from t.Output().Write() calls. + Messages []Message + // Attrs contains attributes captured in t.Attr() calls. + Attrs map[string]string + // Artifacts holds the contents of any regular files that were created below t.ArtifactDir(), keyed with the path relative to t.ArtifactDir(). + Artifacts map[string]string +} + +// Outcome is an enum. +// It appears in type [Result]. +type Outcome string + +const ( + // OutcomeFinished describes a [Capture] that ended with the test running to completion. + OutcomeFinished Outcome = "finished" + // OutcomeFailed describes a [Capture] that ended early because of a t.FailNow() call. + OutcomeFailed Outcome = "failed" + // OutcomeSkipped describes a [Capture] that ended early because of a t.SkipNow() call. + OutcomeSkipped Outcome = "skipped" + // OutcomePanicked describes a [Capture] that ended early because of a panic() call. + OutcomePanicked Outcome = "panicked" +) + +// Message is a piece of log output captured by func [Capture]. +// It appears in type [Result]. +// - Each call to t.Log(), t.Logf() or their derived functions results in one Message instance of type [Log]. +// - Writing into t.Output() between two calls to t.Log(), t.Logf() etc. results in a single Message instance of type [Output], even if Write() is called multiple times. +type Message struct { + Message string + Type MessageType +} + +// Log is a shorthand for constructing [Message] objects of type [MessageTypeLog]. +func Log[T interface{ ~string }](message T) Message { + return Message{string(message), MessageTypeLog} +} + +// Output is a shorthand for constructing [Message] objects of type [MessageTypeOutput]. +func Output[T interface{ ~string | ~[]byte }](message T) Message { + return Message{string(message), MessageTypeOutput} +} + +// MessageType is an enum. +// It appears in type [Message]. +type MessageType string + +const ( + // MessageTypeLog describes [Message] instances created by calls to t.Log(), t.Logf(), or functions calling them, such as t.Error() and t.Fatal(). + MessageTypeLog MessageType = "log" + // MessageTypeOutput describes [Message] instances created by calls to t.Output().Write(). + MessageTypeOutput MessageType = "output" +) + +// Capture executes a test function with a stub implementation of [assert.TestingTB] that captures all calls to it. +// It is intended for unit-testing test assertions. +// +// The name argument is what will be reported in t.Name() within the test. +func Capture(ctx context.Context, name string, test func(assert.TestingTB)) Result { + r := Result{ + Outcome: OutcomeFinished, // can be overridden by Fail() or SkipNow() + } + executeCapture(ctx, name, &r, test) + return r +} + +// capturer is the implementation of [assert.TestingTB] used by func [Capture]. +type capturer struct { + context context.Context + cleanups []func() + name string + result *Result + state struct { + ArtifactDir string + } + + cleanupsMutex sync.Mutex // lock for access to the `cleanups` field + resultMutex sync.RWMutex // lock for access to the `result` field + stateMutex sync.Mutex // lock for access to the `state` field + nonlocalMutex sync.Mutex // lock for non-local effects like Setenv() or filesystem operations +} + +func executeCapture(ctx context.Context, name string, r *Result, test func(assert.TestingTB)) { + ctx, cancel := context.WithCancel(ctx) + t := capturer{ + context: ctx, + cleanups: nil, + name: name, + result: r, + } + defer func() { + t.setOutcome(recover()) + cancel() // T.Context() demands that the context be canceled before any cleanup handlers + for _, cleanup := range slices.Backward(t.cleanups) { + cleanup() + } + }() + test(&t) +} + +func (t *capturer) setOutcome(panicPayload any) { + t.resultMutex.Lock() + defer t.resultMutex.Unlock() + if panicPayload == nil { + return + } else if outcome, ok := panicPayload.(Outcome); ok { + t.result.Outcome = outcome + } else { + t.result.Outcome = OutcomePanicked + t.result.Panic = panicPayload + } +} + +func (t *capturer) pushOutput(buf []byte, msgType MessageType) { + t.resultMutex.Lock() + defer t.resultMutex.Unlock() + + // try to merge consecutive t.Output().Write() calls together + if msgType == MessageTypeOutput && len(t.result.Messages) > 0 { + idx := len(t.result.Messages) - 1 + msg := t.result.Messages[idx] + if msg.Type == MessageTypeOutput { + msg.Message = msg.Message + string(buf) + t.result.Messages[idx] = msg + return + } + } + + t.result.Messages = append(t.result.Messages, Message{ + Message: string(buf), + Type: msgType, + }) +} + +var tempdirID atomic.Uint64 + +func pickTempdir() (string, error) { + path := filepath.Join(os.TempDir(), fmt.Sprintf("gg-assert-capture-%d", tempdirID.Add(1))) + return path, os.MkdirAll(path, 0777) +} + +func collectArtifacts(dirPath string) (map[string]string, error) { + dir, err := os.OpenRoot(dirPath) + if err != nil { + return nil, err + } + dirFS := dir.FS() + + result := make(map[string]string) + err = fs.WalkDir(dirFS, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.Type().IsRegular() { + buf, err := fs.ReadFile(dirFS, path) + if err != nil { + return err + } + result[path] = string(buf) + } + return nil + }) + if err != nil { + return nil, err + } + + return result, os.RemoveAll(dirPath) +} + +// ArtifactDir implements the [assert.TestingTB] interface. +func (t *capturer) ArtifactDir() string { + t.stateMutex.Lock() + defer t.stateMutex.Unlock() + if t.state.ArtifactDir == "" { + path, err := pickTempdir() + if err != nil { + t.Fatal("in t.ArtifactDir(): ", err) + } + t.state.ArtifactDir = path + + t.Cleanup(func() { + artifacts, err := collectArtifacts(path) + if err == nil { + t.resultMutex.Lock() + defer t.resultMutex.Unlock() + t.result.Artifacts = artifacts + } else { + t.Error(err) + } + }) + } + return t.state.ArtifactDir +} + +// Attr implements the [assert.TestingTB] interface. +func (t *capturer) Attr(key, value string) { + t.resultMutex.Lock() + defer t.resultMutex.Unlock() + if t.result.Attrs == nil { + t.result.Attrs = make(map[string]string) + } + t.result.Attrs[key] = value +} + +// Chdir implements the [assert.TestingTB] interface. +func (t *capturer) Chdir(dir string) { + t.doChdir(dir) + + // the following is done outside of doChdir() because t.Setenv() also locks t.nonlocalMutex + switch runtime.GOOS { + case "windows", "plan9": + // these platforms do not use the PWD variable + default: + dir, err := os.Getwd() // returns an absolute path even if `dir` is not one + if err != nil { + t.Fatal(err) + } + t.Setenv("PWD", dir) + } +} + +func (t *capturer) doChdir(dir string) { + t.nonlocalMutex.Lock() + defer t.nonlocalMutex.Unlock() + + oldDir, err := os.Open(".") + if err != nil { + t.Fatal(err) + } + err = os.Chdir(dir) + if err != nil { + t.Fatal(err) + } + + t.Cleanup(func() { + err := oldDir.Chdir() + if err != nil { + t.Error("could not reset cwd changed by t.Chdir(): ", err) + } + }) +} + +// Cleanup implements the [assert.TestingTB] interface. +func (t *capturer) Cleanup(action func()) { + t.cleanupsMutex.Lock() + defer t.cleanupsMutex.Unlock() + t.cleanups = append(t.cleanups, action) +} + +// Context implements the [assert.TestingTB] interface. +func (t *capturer) Context() context.Context { + return t.context +} + +// Error implements the [assert.TestingTB] interface. +func (t *capturer) Error(args ...any) { + t.Log(args...) + t.Fail() +} + +// Errorf implements the [assert.TestingTB] interface. +func (t *capturer) Errorf(format string, args ...any) { + t.Logf(format, args...) + t.Fail() +} + +// Fail implements the [assert.TestingTB] interface. +func (t *capturer) Fail() { + t.resultMutex.Lock() + defer t.resultMutex.Unlock() + t.result.Outcome = OutcomeFailed +} + +// Failed implements the [assert.TestingTB] interface. +func (t *capturer) Failed() bool { + t.resultMutex.RLock() + defer t.resultMutex.RUnlock() + return t.result.Outcome == OutcomeFailed +} + +// FailNow implements the [assert.TestingTB] interface. +func (t *capturer) FailNow() { + panic(OutcomeFailed) +} + +// Fatal implements the [assert.TestingTB] interface. +func (t *capturer) Fatal(args ...any) { + t.Log(args...) + t.FailNow() +} + +// Fatalf implements the [assert.TestingTB] interface. +func (t *capturer) Fatalf(format string, args ...any) { + t.Logf(format, args...) + t.FailNow() +} + +// Helper implements the [assert.TestingTB] interface. +func (t *capturer) Helper() { + // no-op because we do not collect file and line information at the moment +} + +// Log implements the [assert.TestingTB] interface. +func (t *capturer) Log(args ...any) { + t.pushOutput(fmt.Append(nil, args...), MessageTypeLog) +} + +// Logf implements the [assert.TestingTB] interface. +func (t *capturer) Logf(format string, args ...any) { + t.pushOutput(fmt.Appendf(nil, format, args...), MessageTypeLog) +} + +// Name implements the [assert.TestingTB] interface. +func (t *capturer) Name() string { + return t.name +} + +// Output implements the [assert.TestingTB] interface. +func (t *capturer) Output() io.Writer { + return outputCapturer{t} +} + +type outputCapturer struct { + t *capturer +} + +// Write implements the [io.Writer] interface. +func (c outputCapturer) Write(buf []byte) (int, error) { + c.t.pushOutput(buf, MessageTypeOutput) + return len(buf), nil +} + +// Setenv implements the [assert.TestingTB] interface. +func (t *capturer) Setenv(key, value string) { + t.nonlocalMutex.Lock() + defer t.nonlocalMutex.Unlock() + + oldValue, hasOldValue := os.LookupEnv(key) + os.Setenv(key, value) + + t.Cleanup(func() { + if hasOldValue { + os.Setenv(key, oldValue) + } else { + os.Unsetenv(key) + } + }) +} + +// Skip implements the [assert.TestingTB] interface. +func (t *capturer) Skip(args ...any) { + t.Log(args...) + t.SkipNow() +} + +// Skipf implements the [assert.TestingTB] interface. +func (t *capturer) Skipf(format string, args ...any) { + t.Logf(format, args...) + t.SkipNow() +} + +// SkipNow implements the [assert.TestingTB] interface. +func (t *capturer) SkipNow() { + panic(OutcomeSkipped) +} + +// Skipped implements the [assert.TestingTB] interface. +func (t *capturer) Skipped() bool { + t.resultMutex.RLock() + defer t.resultMutex.RUnlock() + return t.result.Outcome == OutcomeSkipped +} + +// TempDir implements the [assert.TestingTB] interface. +func (t *capturer) TempDir() string { + path, err := pickTempdir() + if err != nil { + t.Fatal("in t.TempDir(): ", err) + } + t.Cleanup(func() { + err := os.RemoveAll(path) + if err != nil { + t.Error(err) + } + }) + return path +} -- cgit v1.3.1