blob: 5e1483e0dc785aad3ceb205433c32e290e7c0473 [file] [log] [blame]
// Package testutils contains convenience utilities for testing.
package testutils
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"os"
"path/filepath"
"runtime"
"text/template"
"github.com/google/uuid"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"go.skia.org/infra/bazel/go/bazel"
"go.skia.org/infra/go/repo_root"
"go.skia.org/infra/go/sktest"
"go.skia.org/infra/go/util"
)
// TestDataDir returns the path to the caller's testdata directory, which
// is assumed to be "<path to caller dir>/testdata".
func TestDataDir(t sktest.TestingT) string {
_, thisFile, _, ok := runtime.Caller(0)
require.True(t, ok, "Could not find test data dir: runtime.Caller() failed.")
// Start with the root dir.
testDir := string(filepath.Separator)
for skip := 0; ; skip++ {
_, file, _, ok := runtime.Caller(skip)
require.True(t, ok, "Could not find test data dir: runtime.Caller() failed.")
if file != thisFile {
// Under Bazel, the path returned by runtime.Caller() is relative to the workspace's root
// directory (e.g. "go/testutils"). We prepend this with the absolute path to the runfiles
// directory so that tests can find these files with no further changes.
//
// Under "go test" this is not necessary because the path returned by runtime.Caller() is
// absolute.
if bazel.InBazelTest() {
file = filepath.Join(bazel.RunfilesDir(), file)
}
testDir = filepath.Dir(file)
break
}
}
// If the testDir ends with the path separator, then it is at the root, we should stop.
for testDir[len(testDir)-1] != filepath.Separator {
testdataDir := filepath.Join(testDir, "testdata")
if _, err := os.Stat(testdataDir); os.IsNotExist(err) {
testDir = filepath.Dir(testDir)
} else {
return testdataDir
}
}
require.FailNow(t, "No testdata found.")
return ""
}
// TestDataFilename returns the absolute path for the given relative path to a
// file in the local `testdata` directory.
func TestDataFilename(t sktest.TestingT, relPathParts ...string) string {
pathParts := []string{TestDataDir(t)}
pathParts = append(pathParts, relPathParts...)
return filepath.Join(pathParts...)
}
// ReadFileBytes reads a file from the caller's testdata directory and returns its contents as a
// slice of bytes.
func ReadFileBytes(t sktest.TestingT, filename string) []byte {
f := GetReader(t, filename)
b, err := io.ReadAll(f)
require.NoError(t, err, "Could not read %s: %v", filename)
require.NoError(t, f.Close())
return b
}
// ReadFile reads a file from the caller's testdata directory.
func ReadFile(t sktest.TestingT, filename string) string {
b := ReadFileBytes(t, filename)
return string(b)
}
// GetReader reads a file from the caller's testdata directory and panics on
// error.
func GetReader(t sktest.TestingT, filename string) io.ReadCloser {
f, err := os.Open(TestDataFilename(t, filename))
require.NoError(t, err, "Reading %s from testdir", filename)
return f
}
// ReadJSONFile reads a JSON file from the caller's testdata directory into the
// given interface.
func ReadJSONFile(t sktest.TestingT, filename string, dest interface{}) {
f := GetReader(t, filename)
err := json.NewDecoder(f).Decode(dest)
require.NoError(t, err, "Decoding JSON in %s", filename)
require.NoError(t, f.Close())
}
// WriteFile writes the given contents to the given file path, reporting any
// error.
func WriteFile(t sktest.TestingT, filename, contents string) {
require.NoErrorf(t, os.WriteFile(filename, []byte(contents), os.ModePerm), "Unable to write to file %s", filename)
}
// AssertCloses takes an ioutil.Closer and asserts that it closes. E.g.:
// frobber := NewFrobber()
// defer testutils.AssertCloses(t, frobber)
func AssertCloses(t sktest.TestingT, c io.Closer) {
require.NoError(t, c.Close())
}
// Remove attempts to remove the given file and asserts that no error is returned.
func Remove(t sktest.TestingT, fp string) {
require.NoError(t, os.Remove(fp))
}
// RemoveAll attempts to remove the given directory and asserts that no error is returned.
func RemoveAll(t sktest.TestingT, fp string) {
require.NoError(t, os.RemoveAll(fp))
}
// MarshalJSON encodes the given interface to a JSON string.
func MarshalJSON(t sktest.TestingT, i interface{}) string {
b, err := json.Marshal(i)
require.NoError(t, err)
return string(b)
}
// MarshalIndentJSON encodes the given interface to an indented JSON string.
func MarshalIndentJSON(t sktest.TestingT, i interface{}) string {
b, err := json.MarshalIndent(i, "", " ")
require.NoError(t, err)
return string(b)
}
// MarshalJSONReader encodes the given interface to an io.ByteReader of its JSON
// representation.
func MarshalJSONReader(t sktest.TestingT, i interface{}) io.Reader {
b, err := json.MarshalIndent(i, "", " ")
require.NoError(t, err)
return bytes.NewReader(b)
}
// GetRepoRoot returns the path to the root of the checkout.
func GetRepoRoot(t sktest.TestingT) string {
root, err := repo_root.Get()
require.NoError(t, err)
return root
}
// AnyContext can be used to match any Context objects e.g.
// m.On("Foo", testutils.AnyContext).Return(...)
// This is better than trying to used mock.AnythingOfTypeArgument
// because that only works for concrete types, which could be brittle
// (e.g. a "normal" context is *context.emptyCtx, but one modified by
// trace.StartSpan() could be a *context.valueCtx)
var AnyContext = mock.MatchedBy(func(c context.Context) bool {
// if the passed in parameter does not implement the context.Context interface, the
// wrapping MatchedBy will panic - so we can simply return true, since we
// know it's a context.Context if execution flow makes it here.
return true
})
// ExecTemplate parses the given string as a text template, executes it using
// the given data, and returns the result as a string.
func ExecTemplate(t sktest.TestingT, tmpl string, data interface{}) string {
template, err := template.New(uuid.New().String()).Parse(tmpl)
require.NoError(t, err)
var buf bytes.Buffer
require.NoError(t, template.Execute(&buf, data))
return buf.String()
}
// SetUpFakeHomeDir creates a temporary dir and updates the HOME environment variable with its path.
// After the caller test completes, the HOME environment variable will be restored to its original
// value, and the temporary dir will be deleted.
//
// Under Bazel, this is useful because Bazel does not set the HOME environment variable. Without
// this, some tests that call the "go" binary fail, because some "go" subcommands create a cache
// under $HOME/.cache/go-build.
//
// Outside of Bazel (i.e. "go test"), this is still useful because it leads to more hermetic tests
// which do not depend on the specifics of the $HOME directory in the host system.
//
// See https://docs.bazel.build/versions/master/test-encyclopedia.html#initial-conditions.
func SetUpFakeHomeDir(t sktest.TestingT, tempDirPattern string) {
fakeHome, err := os.MkdirTemp("", tempDirPattern)
require.NoError(t, err)
oldHome := os.Getenv("HOME")
require.NoError(t, os.Setenv("HOME", fakeHome))
t.Cleanup(func() {
require.NoError(t, os.Setenv("HOME", oldHome))
util.RemoveAll(fakeHome)
})
}
// Executable returns the filesystem path to the binary running the current test, panicking on
// failure.
func Executable(t sktest.TestingT) string {
executable, err := os.Executable()
require.NoError(t, err)
return executable
}
// FlagPath returns the path to a temp file for use as a synchronization flag between the test
// runner and a mocked-out subprocess launched by tested code; see go/flag-files. It lives next to
// the test executable and has the specified name, which should contain the name of the test for
// uniqueness across concurrent tests. It also asserts the absence of said file as a safety measure
// against leftover files from earlier runs—admittedly unlikely given "go test"'s and Bazel's
// proclivity for running everything in temp dirs.
func FlagPath(t sktest.TestingT, fileName string) string {
path := filepath.Join(filepath.Dir(Executable(t)), fileName)
_, err := os.Stat(path)
require.True(t, errors.Is(err, os.ErrNotExist), "Flag file %s existed before it was expected.", fileName)
return path
}