152 lines
4.1 KiB
Go
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)
|
|
}
|
|
}
|