Files
job-search/internal/cli/cmdtest/cmdtest.go
T
2026-10-13 13:22:22 +01:00

152 lines
4.1 KiB
Go

// Package cmdtest runs job-search commands in tests, much as net/http/httptest
// serves handlers. Commands read .env and config.yaml from the working
// directory and print to os.Stdout, so Setup gives each test its own working
// directory and Run swaps os.Stdin and os.Stdout for files.
//
// Tests using it change global state (the working directory, os.Stdin,
// os.Stdout, Viper and the logging level), so they must not run in parallel.
package cmdtest
import (
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"git.avsdev.uk/Go/job-search/internal/cli"
"git.avsdev.uk/Go/job-search/internal/logging"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// Setup changes into a new temporary directory holding an empty .env and,
// unless config is empty, config.yaml with those contents. It returns the
// directory.
func Setup(t *testing.T, config string) string {
t.Helper()
dir := t.TempDir()
t.Chdir(dir)
WriteFile(t, ".env", "")
if config != "" {
WriteFile(t, "config.yaml", config)
}
return dir
}
// WriteFile writes contents to path, failing the test on error.
func WriteFile(t *testing.T, path, contents string) {
t.Helper()
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
}
// ReadFile returns the contents of path, failing the test on error.
func ReadFile(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(filepath.Clean(path))
if err != nil {
t.Fatal(err)
}
return string(b)
}
// Exists reports whether path exists.
func Exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// NewRoot returns a root command set up like job-search's: the shared flags
// bound to opts, logging set up before every command, opts.Finish run after,
// and cmds added beneath it.
func NewRoot(opts *cli.Options, cmds ...*cobra.Command) *cobra.Command {
root := &cobra.Command{
Use: "job-search",
SilenceUsage: true,
SilenceErrors: true,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
cli.SetupInitialLogging(cmd)
return nil
},
PersistentPostRunE: func(cmd *cobra.Command, args []string) error {
return opts.Finish()
},
}
cobra.EnableTraverseRunHooks = true
opts.Bind(root)
root.AddCommand(cmds...)
return root
}
// Run runs args on a command tree from newRoot, with stdin as standard
// input, and returns everything written to standard output (including log
// messages). Viper and the logging level are reset around each run, as they
// are global.
func Run(
t *testing.T, newRoot func() *cobra.Command, stdin string, args ...string,
) (string, error) {
t.Helper()
viper.Reset()
t.Cleanup(viper.Reset)
dir := t.TempDir()
inPath, outPath := filepath.Join(dir, "stdin"), filepath.Join(dir, "stdout")
WriteFile(t, inPath, stdin)
in, err := os.Open(filepath.Clean(inPath))
if err != nil {
t.Fatal(err)
}
defer in.Close() //nolint:errcheck // read only
out, err := os.Create(filepath.Clean(outPath))
if err != nil {
t.Fatal(err)
}
oldIn, oldOut, oldLog := os.Stdin, os.Stdout, slog.Default()
os.Stdin, os.Stdout = in, out
defer func() {
os.Stdin, os.Stdout = oldIn, oldOut
slog.SetDefault(oldLog)
logging.SetLevel(slog.LevelInfo)
}()
root := newRoot()
root.SetArgs(args)
runErr := root.Execute()
if err := out.Close(); err != nil {
t.Fatal(err)
}
return ReadFile(t, outPath), runErr
}
// MustRun is Run with no input, for commands that must succeed.
func MustRun(t *testing.T, newRoot func() *cobra.Command, args ...string) string {
t.Helper()
out, err := Run(t, newRoot, "", args...)
if err != nil {
t.Fatalf("job-search %s: %v\n%s", strings.Join(args, " "), err, out)
}
return out
}
// Contains fails the test unless out contains every string in want.
func Contains(t *testing.T, out string, want ...string) {
t.Helper()
for _, w := range want {
if !strings.Contains(out, w) {
t.Errorf("output does not contain %q:\n%s", w, out)
}
}
}
// ErrContains fails the test unless err is an error containing want.
func ErrContains(t *testing.T, err error, want string) {
t.Helper()
if err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("err = %v; want an error containing %q", err, want)
}
}