Files
go-mocktesting/t.go
Cursor Agent 50a3e2584b feat: Add Go 1.24 Context and Chdir methods
Co-authored-by: cursor.nop <cursor.nop@jimeh.info>
2025-10-16 05:17:50 +00:00

552 lines
15 KiB
Go

package mocktesting
import (
"context"
"fmt"
"io/ioutil"
"os"
"reflect"
"runtime"
"strings"
"sync"
"testing"
"time"
)
// TestingT is an interface covering *mocktesting.T's internal use of
// *testing.T. See WithTestingT() for more details.
type TestingT interface {
Fatal(args ...interface{})
}
// T is a fake/mock implementation of *testing.T. All methods available on
// *testing.T are available on *T with the exception of Run(), which has a
// slightly different func type.
//
// All method calls against *T are recorded, so they can be inspected and
// asserted later. To be able to pass in *testing.T or *mocktesting.T, functions
// will need to use an interface instead of *testing.T explicitly.
//
// For basic use cases, the testing.TB interface should suffice. For more
// advanced use cases, create a custom interface that exactly specifies the
// methods of *testing.T which are needed, and then freely pass *testing.T or
// *mocktesting.T.
type T struct {
// Settings - These fields control the behavior of T.
name string
abort bool
baseTempdir string
testingT TestingT
deadline time.Time
timeout bool
// State - Fields which record how T has been modified via method calls.
mux sync.RWMutex
skipped bool
failed int
parallel bool
output []string
helpers []string
aborted bool
cleanups []func()
env map[string]string
subtests []*T
tempdirs []string
ctx context.Context
ctxCancel context.CancelFunc
origDir string
// subtestNames is used to ensure subtests do not have conflicting names.
subtestNames map[string]bool
// mkdirTempFunc is used by the TempDir function instead of ioutil.TempDir()
// if it is not nil. This is only used by tests for TempDir itself to ensure
// it behaves correctly if temp directory creation fails.
mkdirTempFunc func(string, string) (string, error)
// Embed *testing.T to implement the testing.TB interface, which has a
// private method to prevent it from being implemented. However that means
// it's very difficult to test testing helpers.
*testing.T
}
// Ensure T struct implements testing.TB interface.
var _ testing.TB = (*T)(nil)
func NewT(name string, options ...Option) *T {
ctx, cancel := context.WithCancel(context.Background())
t := &T{
name: strings.ReplaceAll(name, " ", "_"),
abort: true,
baseTempdir: os.TempDir(),
deadline: time.Now().Add(10 * time.Minute),
timeout: true,
ctx: ctx,
ctxCancel: cancel,
}
for _, opt := range options {
opt.apply(t)
}
return t
}
type Option interface {
apply(*T)
}
type optionFunc func(*T)
func (fn optionFunc) apply(g *T) {
fn(g)
}
// WithTimeout specifies a custom timeout for the mock test. It effectively
// determines the return values of Deadline().
//
// When given a zero-value time.Duration, Deadline() will act as if no timeout
// has been set.
//
// If this option is not used, the default timeout value is set to 10 minutes.
func WithTimeout(d time.Duration) Option {
return optionFunc(func(t *T) {
if d > 0 {
t.timeout = true
t.deadline = time.Now().Add(d)
} else {
t.timeout = false
t.deadline = time.Time{}
}
})
}
// WithDeadline specifies a custom timeout for the mock test, but setting the
// deadline to an exact value, rather than setting it based on the offset from
// now of a time.Duration. It effectively determines the return values of
// Deadline().
//
// When given a empty time.Time{}, Deadline() will act as if no timeout has been
// set.
//
// If this option is not used, the default timeout value is set to 10 minutes.
func WithDeadline(d time.Time) Option {
return optionFunc(func(t *T) {
if d != (time.Time{}) {
t.timeout = true
t.deadline = d
} else {
t.timeout = false
t.deadline = time.Time{}
}
})
}
// WithNoAbort disables aborting the current goroutine with runtime.Goexit()
// when SkipNow or FailNow is called. This should be used with care, as it
// causes behavior to diverge from normal *tesing.T, as code after calling
// t.Fatal() will be executed.
func WithNoAbort() Option {
return optionFunc(func(t *T) {
t.abort = false
})
}
// WithBaseTempdir sets the base directory that TempDir() creates temporary
// directories within.
//
// If this option is not used, the default base directory used is os.TempDir().
func WithBaseTempdir(dir string) Option {
return optionFunc(func(t *T) {
if dir != "" {
t.baseTempdir = dir
}
})
}
// WithTestingT accepts a *testing.T instance which is used to report internal
// errors within *mocktesting.T itself. For example if the TempDir() function
// fails to create a temporary directory on disk, it will call Fatal() on the
// *testing.T instance provided here.
//
// If this option is not used, internal errors will instead cause a panic.
func WithTestingT(testingT TestingT) Option {
return optionFunc(func(t *T) {
t.testingT = testingT
})
}
func (t *T) goexit() {
t.aborted = true
if t.abort {
runtime.Goexit()
}
}
func (t *T) internalError(err error) {
err = fmt.Errorf("mocktesting: %w", err)
if t.testingT != nil {
t.testingT.Fatal(err)
} else {
panic(err)
}
}
// Name returns the name given to the *T instance.
func (t *T) Name() string {
return t.name
}
// Name returns the time at which the *T instance is set to timeout. If no
// timeout is set, the bool return value is false, otherwise it is true.
func (t *T) Deadline() (time.Time, bool) {
return t.deadline, t.timeout
}
// Error logs the given args with Log(), and then calls Fail() to mark the *T
// instance as failed.
func (t *T) Error(args ...interface{}) {
t.Log(args...)
t.Fail()
}
// Errorf logs the given format and args with Logf(), and then calls Fail() to
// mark the *T instance as failed.
func (t *T) Errorf(format string, args ...interface{}) {
t.Logf(format, args...)
t.Fail()
}
// Fail marks the *T instance as having failed. You can check if the *T instance
// has been failed with Failed(), or how many times it has been failed with
// FailedCount().
func (t *T) Fail() {
t.failed++
}
// FailNow marks the *T instance as having failed, and also aborts the current
// goroutine with runtime.Goexit(). If the WithNoAbort() option was used when
// initializing the *T instance, runtime.Goexit() will not be called.
func (t *T) FailNow() {
t.Fail()
t.goexit()
}
// Failed returns true if the *T instance has been marked as failed.
func (t *T) Failed() bool {
return t.failed > 0
}
// Fatal logs the given args with Log(), and then calls FailNow() to fail the *T
// instance and abort the current goroutine.
//
// See FailNow() and WithNoAbort() for details about how abort works.
func (t *T) Fatal(args ...interface{}) {
t.Log(args...)
t.FailNow()
}
// Fatalf logs the given format and args with Logf(), and then calls FailNow()
// to fail the *T instance and abort the current goroutine.
//
// See FailNow() and WithNoAbort() for details about how abort works.
func (t *T) Fatalf(format string, args ...interface{}) {
t.Logf(format, args...)
t.FailNow()
}
// Log renders given args to a string with fmt.Sprintln() and stores the result
// in a string slice which can be accessed with Output().
func (t *T) Log(args ...interface{}) {
t.mux.Lock()
defer t.mux.Unlock()
t.output = append(t.output, fmt.Sprintln(args...))
}
// Logf renders given format and args to a string with fmt.Sprintf() and stores
// the result in a string slice which can be accessed with Output().
func (t *T) Logf(format string, args ...interface{}) {
t.mux.Lock()
defer t.mux.Unlock()
if len(format) == 0 || format[len(format)-1] != '\n' {
format += "\n"
}
t.output = append(t.output, fmt.Sprintf(format, args...))
}
// Parallel marks the *T instance to indicate Parallel() has been called.
// Use Paralleled() to check if Parallel() has been called.
func (t *T) Parallel() {
t.parallel = true
}
// Skip logs the given args with Log(), and then uses SkipNow() to mark the *T
// instance as skipped and aborts the current goroutine.
//
// See SkipNow() for more details about aborting the current goroutine.
func (t *T) Skip(args ...interface{}) {
t.Log(args...)
t.SkipNow()
}
// Skipf logs the given format and args with Logf(), and then uses SkipNow() to
// mark the *T instance as skipped and aborts the current goroutine.
//
// See SkipNow() for more details about aborting the current goroutine.
func (t *T) Skipf(format string, args ...interface{}) {
t.Logf(format, args...)
t.SkipNow()
}
// SkipNow marks the *T instance as skipped, and then aborts the current
// goroutine with runtime.Goexit(). If the WithNoAbort() option was used when
// initializing the *T instance, runtime.Goexit() will not be called.
func (t *T) SkipNow() {
t.skipped = true
t.goexit()
}
// Skipped returns true if the *T instance has been marked as skipped, otherwise
// it returns false.
func (t *T) Skipped() bool {
return t.skipped
}
// Helper marks the function that is calling Helper() as a helper function.
// Within *T it simply stores a reference to the function.
//
// The list of functions which have called Helper() can be inspected with
// HelperNames(). The names are resolved using runtime.FuncForPC(), meaning they
// include the absolute Go package path to the function, along with the function
// name itself.
func (t *T) Helper() {
pc, _, _, ok := runtime.Caller(1)
if !ok {
return
}
fnName := runtime.FuncForPC(pc).Name()
t.mux.Lock()
defer t.mux.Unlock()
t.helpers = append(t.helpers, fnName)
}
// Cleanup registers a cleanup function. *T does not run cleanup functions, it
// simply records them for the purpose of later inspection via CleanupFuncs() or
// CleanupNames().
func (t *T) Cleanup(f func()) {
t.mux.Lock()
defer t.mux.Unlock()
t.cleanups = append(t.cleanups, f)
}
// TempDir creates an actual temporary directory on the system using
// ioutil.TempDir(). This actually does perform a action, rather than just
// recording the fact the method was called list most other *T methods.
//
// This is because returning a string that is not the path to a real directory,
// would most likely be useless. Hence it does create a real temporary
// directory.
//
// It is important to note that the temporary directory is not cleaned up by
// mocktesting. But it is created via ioutil.TempDir(), so the operating system
// should eventually clean it up.
//
// A string slice of temporary directory paths created by calls to TempDir() can
// be accessed with TempDirs().
func (t *T) TempDir() string {
// Allow setting MkdirTemp function for the purpose of testing mocktesting
// itself..
f := t.mkdirTempFunc
if f == nil {
f = ioutil.TempDir
}
dir, err := f(t.baseTempdir, "go-mocktesting*")
if err != nil {
err = fmt.Errorf("TempDir() failed to create directory: %w", err)
t.internalError(err)
}
t.mux.Lock()
defer t.mux.Unlock()
t.tempdirs = append(t.tempdirs, dir)
return dir
}
// Run allows running sub-tests just very much like *testing.T. The one
// difference is that the function argument accepts a testing.TB instead of
// *testing.T type. This is to allow passing a *mocktesting.T to the sub-test
// function instead of a *testing.T.
//
// Sub-test functions are executed in a separate blocking goroutine, so calls to
// SkipNow() and FailNow() abort the new goroutine that the sub-test is running
// in, rather than the gorouting which is executing Run().
//
// The sub-test function will receive a new instance of *T which is a sub-test,
// which name and other attributes set accordingly.
//
// If any sub-test *T is marked as failed, the parent *T instance will also
// be marked as failed.
//
// The list of sub-test *T instances can be accessed with Subtests().
func (t *T) Run(name string, f func(testing.TB)) bool {
name = t.newSubTestName(name)
fullname := name
if t.name != "" {
fullname = t.name + "/" + name
}
subtest := NewT(fullname)
subtest.abort = t.abort
subtest.baseTempdir = t.baseTempdir
subtest.testingT = t.testingT
subtest.deadline = t.deadline
subtest.timeout = t.timeout
if t.subtestNames == nil {
t.subtestNames = map[string]bool{}
}
t.mux.Lock()
t.subtests = append(t.subtests, subtest)
t.subtestNames[name] = true
t.mux.Unlock()
Go(func() {
f(subtest)
// Cancel subtest's context after test function completes
// but before cleanup functions run
if subtest.ctxCancel != nil {
subtest.ctxCancel()
}
})
if subtest.Failed() {
t.Fail()
}
return !subtest.Failed()
}
func (t *T) newSubTestName(name string) string {
name = strings.ReplaceAll(name, " ", "_")
if !t.subtestNames[name] {
return name
}
i := 1
for {
n := name + "#" + fmt.Sprintf("%02d", i)
if !t.subtestNames[n] {
return n
}
i++
}
}
//
// Inspection Methods which are not part of the testing.TB interface.
//
// Output returns a string slice of all output produced by calls to Log() and
// Logf().
func (t *T) Output() []string {
t.mux.RLock()
defer t.mux.RUnlock()
return t.output
}
// CleanupFuncs returns a slice of functions given to Cleanup().
func (t *T) CleanupFuncs() []func() {
t.mux.RLock()
defer t.mux.RUnlock()
return t.cleanups
}
// CleanupNames returns a string slice of function names given to Cleanup(). The
// names are resolved using runtime.FuncForPC(), meaning they include the
// absolute Go package path to the function, along with the function name
// itself.
func (t *T) CleanupNames() []string {
r := make([]string, 0, len(t.cleanups))
for _, f := range t.cleanups {
p := reflect.ValueOf(f).Pointer()
r = append(r, runtime.FuncForPC(p).Name())
}
return r
}
// FailedCount returns the number of times the *T instance has been marked as
// failed.
func (t *T) FailedCount() int {
return t.failed
}
// Aborted returns true if the *T instance aborted the current goroutine via
// runtime.Goexit(), which is called by FailNow() and SkipNow().
//
// This returns true even if *T was initialized using the WithNoAbort() option.
// Because the test was still instructed to abort, which is a separate matter
// than that *T was specifically set to not abort the current goroutine.
func (t *T) Aborted() bool {
return t.aborted
}
// HelperNames returns a list of function names which called Helper(). The names
// are resolved using runtime.FuncForPC(), meaning they include the absolute Go
// package path to the function, along with the function name itself.
func (t *T) HelperNames() []string {
t.mux.RLock()
defer t.mux.RUnlock()
return t.helpers
}
// Paralleled returns true if Parallel() has been called.
func (t *T) Paralleled() bool {
return t.parallel
}
// Subtests returns a slice of *T instances created for any subtests executed
// via Run().
func (t *T) Subtests() []*T {
if t.subtests == nil {
t.mux.Lock()
t.subtests = []*T{}
t.mux.Unlock()
}
t.mux.RLock()
defer t.mux.RUnlock()
return t.subtests
}
// TempDirs returns a string slice of temporary directories created by
// TempDir().
func (t *T) TempDirs() []string {
if t.tempdirs == nil {
t.mux.Lock()
t.tempdirs = []string{}
t.mux.Unlock()
}
t.mux.RLock()
defer t.mux.RUnlock()
return t.tempdirs
}