Repository: gotestyourself/gotestsum
Pull request #249 (2022-05-08 18:24:57)

watch: Add u key for updating golden values
While in watch mode, pressing `u` will rerun the tests with the `-update` flag. This makes it convenient to update expected values in golden files.

A bunch of golden libraries use this flag:
* the [stdlib](https://github.com/golangci/gofmt/blob/master/gofmt/gofmt_test.go#L20), and [hashicorp](https://github.com/hashicorp/consul/blob/main/agent/xds/golden_test.go#L18) projects define the flag in some test packages
* https://pkg.go.dev/gotest.tools/v3/golden (as soon as the next release is done)
* https://github.com/hexops/autogold
* https://pkg.go.dev/github.com/xorcare/golden


Patch:
diff --git a/README.md b/README.md
index 6eb1d5f..d5e8825 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
 # gotestsum
 
-`gotestsum` runs tests using `go test --json`, prints formatted test output, and a summary of the test run.
+`gotestsum` runs tests using `go test -json`, prints formatted test output, and a summary of the test run.
 It is designed to work well for both local development, and for automation like CI.
 `gotest.tools/gotestsum/testjson` ([godoc](https://pkg.go.dev/gotest.tools/gotestsum/testjson)) is a library
 that can be used to read [`test2json`](https://golang.org/cmd/test2json/) output.
@@ -112,7 +112,7 @@ warning.
 When the `--jsonfile` flag or `GOTESTSUM_JSONFILE` environment variable are set
 to a file path, `gotestsum` will write a line-delimited JSON file with all the
 [test2json](https://golang.org/cmd/test2json/#hdr-Output_Format)
-output that was written by `go test --json`. This file can be used to compare test
+output that was written by `go test -json`. This file can be used to compare test
 runs, or find flaky tests.
 
 ```
@@ -202,7 +202,7 @@ how you specify args to `go test`:
 
 ### Custom `go test` command
 
-By default `gotestsum` runs tests using the command `go test --json ./...`. You
+By default `gotestsum` runs tests using the command `go test -json ./...`. You
 can change the command with positional arguments after a `--`. You can change just the
 test directory value (which defaults to `./...`) by setting the `TEST_DIRECTORY`
 environment variable.
@@ -354,6 +354,10 @@ While in watch mode, pressing some keys will perform an action:
 
 * `r` will run tests for the previous event.
   Added in version 1.6.1.
+* `u` will run tests for the previous event, with the `-update` flag added.
+  Many [golden](https://gotest.tools/v3/golden) packages use this flag to automatically
+  update expected values of tests.
+  Added in version 1.8.1.
 * `d` will run tests for the previous event using `dlv test`, allowing you to 
   debug a test failure using [delve]. A breakpoint will automatically be added at
   the first line of any tests which failed in the previous run. Additional
diff --git a/cmd/watch.go b/cmd/watch.go
index b0665a6..a6b6e3a 100644
--- a/cmd/watch.go
+++ b/cmd/watch.go
@@ -13,8 +13,11 @@ import (
 )
 
 func runWatcher(opts *options) error {
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+
 	w := &watchRuns{opts: *opts}
-	return filewatcher.Watch(opts.packages, w.run)
+	return filewatcher.Watch(ctx, opts.packages, w.run)
 }
 
 type watchRuns struct {
@@ -40,8 +43,11 @@ func (w *watchRuns) run(event filewatcher.Event) error {
 		return nil
 	}
 
-	opts := w.opts
-	opts.packages = []string{event.PkgPath}
+	opts := w.opts // shallow copy opts
+	opts.packages = append([]string{}, opts.packages...)
+	opts.packages = append(opts.packages, event.PkgPath)
+	opts.packages = append(opts.packages, event.Args...)
+
 	var err error
 	if w.prevExec, err = runSingle(&opts); !IsExitCoder(err) {
 		return err
diff --git a/internal/filewatcher/term_unix.go b/internal/filewatcher/term_unix.go
index b0c41f0..58109a0 100644
--- a/internal/filewatcher/term_unix.go
+++ b/internal/filewatcher/term_unix.go
@@ -7,6 +7,7 @@ import (
 	"bufio"
 	"context"
 	"fmt"
+	"io"
 	"os"
 
 	"golang.org/x/sys/unix"
@@ -62,13 +63,15 @@ func enableNonBlockingRead(fd int) (func(), error) {
 	return reset, nil
 }
 
+var stdin io.Reader = os.Stdin
+
 // Monitor the terminal for key presses. If the key press is associated with an
 // action, an event will be sent to channel returned by Events.
 func (r *terminal) Monitor(ctx context.Context) {
 	if r == nil {
 		return
 	}
-	in := bufio.NewReader(os.Stdin)
+	in := bufio.NewReader(stdin)
 	for {
 		char, err := in.ReadByte()
 		if err != nil {
@@ -77,20 +80,18 @@ func (r *terminal) Monitor(ctx context.Context) {
 		}
 		log.Debugf("received byte %v (%v)", char, string(char))
 
-		var chResume chan struct{}
+		chResume := make(chan struct{})
 		switch char {
 		case 'r':
-			chResume = make(chan struct{})
-			r.ch <- Event{resume: chResume}
+			r.ch <- Event{resume: chResume, useLastPath: true}
 		case 'd':
-			chResume = make(chan struct{})
-			r.ch <- Event{Debug: true, resume: chResume}
+			r.ch <- Event{resume: chResume, useLastPath: true, Debug: true}
 		case 'a':
-			chResume = make(chan struct{})
 			r.ch <- Event{resume: chResume, PkgPath: "./..."}
 		case 'l':
-			chResume = make(chan struct{})
 			r.ch <- Event{resume: chResume, reloadPaths: true}
+		case 'u':
+			r.ch <- Event{resume: chResume, useLastPath: true, Args: []string{"-update"}}
 		case '\n':
 			fmt.Println()
 			continue
diff --git a/internal/filewatcher/watch.go b/internal/filewatcher/watch.go
index ce8be93..230183e 100644
--- a/internal/filewatcher/watch.go
+++ b/internal/filewatcher/watch.go
@@ -19,18 +19,25 @@ import (
 const maxDepth = 7
 
 type Event struct {
-	PkgPath     string
-	Debug       bool
-	resume      chan struct{}
+	// PkgPath of the package that triggered the event.
+	PkgPath string
+	// Args will be appended to the command line args for 'go test'.
+	Args []string
+	// Debug runs the tests with delve.
+	Debug bool
+	// resume the Watch goroutine when this channel is closed. Used to block
+	// the Watch goroutine while tests are running.
+	resume chan struct{}
+	// reloadPaths will cause the watched path list to be reloaded, to watch
+	// new directories.
 	reloadPaths bool
+	// useLastPath when true will use the PkgPath from the previous run.
+	useLastPath bool
 }
 
 // Watch dirs for filesystem events, and run tests when .go files are saved.
 // nolint: gocyclo
-func Watch(dirs []string, run func(Event) error) error {
-	ctx, cancel := context.WithCancel(context.Background())
-	defer cancel()
-
+func Watch(ctx context.Context, dirs []string, run func(Event) error) error {
 	watcher, err := fsnotify.NewWatcher()
 	if err != nil {
 		return fmt.Errorf("failed to create file watcher: %w", err)
@@ -48,9 +55,11 @@ func Watch(dirs []string, run func(Event) error) error {
 	defer term.Reset()
 	go term.Monitor(ctx)
 
-	h := &handler{last: time.Now(), fn: run}
+	h := &fsEventHandler{last: time.Now(), fn: run}
 	for {
 		select {
+		case <-ctx.Done():
+			return nil
 		case <-timer.C:
 			return fmt.Errorf("exceeded idle timeout while watching files")
 
@@ -218,15 +227,15 @@ func handleDirCreated(watcher *fsnotify.Watcher, event fsnotify.Event) (handled
 	return true
 }
 
-type handler struct {
+type fsEventHandler struct {
 	last     time.Time
 	lastPath string
 	fn       func(opts Event) error
 }
 
-const floodThreshold = 250 * time.Millisecond
+var floodThreshold = 250 * time.Millisecond
 
-func (h *handler) handleEvent(event fsnotify.Event) error {
+func (h *fsEventHandler) handleEvent(event fsnotify.Event) error {
 	if event.Op&(fsnotify.Write|fsnotify.Create) == 0 {
 		return nil
 	}
@@ -242,8 +251,8 @@ func (h *handler) handleEvent(event fsnotify.Event) error {
 	return h.runTests(Event{PkgPath: "./" + filepath.Dir(event.Name)})
 }
 
-func (h *handler) runTests(opts Event) error {
-	if opts.PkgPath == "" {
+func (h *fsEventHandler) runTests(opts Event) error {
+	if opts.useLastPath {
 		opts.PkgPath = h.lastPath
 	}
 	fmt.Printf("\nRunning tests in %v\n", opts.PkgPath)


Test patch:
diff --git a/internal/filewatcher/watch_test.go b/internal/filewatcher/watch_test.go
index f02f8a3..d0e5af0 100644
--- a/internal/filewatcher/watch_test.go
+++ b/internal/filewatcher/watch_test.go
@@ -12,7 +12,7 @@ import (
 	"gotest.tools/v3/fs"
 )
 
-func TestHandler_HandleEvent(t *testing.T) {
+func TestFSEventHandler_HandleEvent(t *testing.T) {
 	type testCase struct {
 		name        string
 		last        time.Time
@@ -27,7 +27,7 @@ func TestHandler_HandleEvent(t *testing.T) {
 			return nil
 		}
 
-		h := handler{last: tc.last, fn: run}
+		h := fsEventHandler{last: tc.last, fn: run}
 		err := h.handleEvent(tc.event)
 		assert.NilError(t, err)
 		assert.Equal(t, ran, tc.expectedRun)
diff --git a/internal/filewatcher/watch_unix_test.go b/internal/filewatcher/watch_unix_test.go
new file mode 100644
index 0000000..8fef2b2
--- /dev/null
+++ b/internal/filewatcher/watch_unix_test.go
@@ -0,0 +1,110 @@
+//go:build !windows && !aix
+// +build !windows,!aix
+
+package filewatcher
+
+import (
+	"context"
+	"io"
+	"testing"
+	"time"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+	"gotest.tools/v3/assert"
+	"gotest.tools/v3/fs"
+)
+
+func TestWatch(t *testing.T) {
+	ctx, cancel := context.WithCancel(context.Background())
+	t.Cleanup(cancel)
+	dir := fs.NewDir(t, t.Name())
+
+	r, w := io.Pipe()
+	patchStdin(t, r)
+	patchFloodThreshold(t, 0)
+
+	chEvents := make(chan Event, 1)
+	capture := func(event Event) error {
+		chEvents <- event
+		return nil
+	}
+
+	go func() {
+		err := Watch(ctx, []string{dir.Path()}, capture)
+		assert.Check(t, err)
+	}()
+
+	t.Run("run all tests", func(t *testing.T) {
+		_, err := w.Write([]byte("a"))
+		assert.NilError(t, err)
+
+		event := <-chEvents
+		expected := Event{PkgPath: "./..."}
+		assert.DeepEqual(t, event, expected, cmpEvent)
+	})
+
+	t.Run("run tests on file change", func(t *testing.T) {
+		fs.Apply(t, dir, fs.WithFile("file.go", ""))
+
+		event := <-chEvents
+		expected := Event{PkgPath: "./" + dir.Path()}
+		assert.DeepEqual(t, event, expected, cmpEvent)
+
+		t.Run("and rerun", func(t *testing.T) {
+			_, err := w.Write([]byte("r"))
+			assert.NilError(t, err)
+
+			event := <-chEvents
+			expected := Event{PkgPath: "./" + dir.Path(), useLastPath: true}
+			assert.DeepEqual(t, event, expected, cmpEvent)
+		})
+
+		t.Run("and debug", func(t *testing.T) {
+			_, err := w.Write([]byte("d"))
+			assert.NilError(t, err)
+
+			event := <-chEvents
+			expected := Event{
+				PkgPath:     "./" + dir.Path(),
+				useLastPath: true,
+				Debug:       true,
+			}
+			assert.DeepEqual(t, event, expected, cmpEvent)
+		})
+
+		t.Run("and update", func(t *testing.T) {
+			_, err := w.Write([]byte("u"))
+			assert.NilError(t, err)
+
+			event := <-chEvents
+			expected := Event{
+				PkgPath:     "./" + dir.Path(),
+				Args:        []string{"-update"},
+				useLastPath: true,
+			}
+			assert.DeepEqual(t, event, expected, cmpEvent)
+		})
+	})
+}
+
+var cmpEvent = cmp.Options{
+	cmp.AllowUnexported(Event{}),
+	cmpopts.IgnoreTypes(make(chan struct{})),
+}
+
+func patchStdin(t *testing.T, in io.Reader) {
+	orig := stdin
+	stdin = in
+	t.Cleanup(func() {
+		stdin = orig
+	})
+}
+
+func patchFloodThreshold(t *testing.T, d time.Duration) {
+	orig := floodThreshold
+	floodThreshold = d
+	t.Cleanup(func() {
+		floodThreshold = orig
+	})
+}