// 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" "github.com/spf13/cobra" "github.com/spf13/viper" "git.avsdev.uk/Go/job-search/internal/cli" "git.avsdev.uk/Go/job-search/internal/logging" ) // 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) input, err := os.Open(filepath.Clean(inPath)) if err != nil { t.Fatal(err) } defer input.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 = input, 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 _, wanted := range want { if !strings.Contains(out, wanted) { t.Errorf("output does not contain %q:\n%s", wanted, 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) } }