src.elv.sh@v0.21.0-dev.0.20240515223629-06979efb9a2a/pkg/prog/progtest/progtest.go (about) 1 // Package progtest provides a framework for testing subprograms. 2 // 3 // The entry point for the framework is the Test function, which accepts a 4 // *testing.T, the Program implementation under test, and any number of test 5 // cases. 6 // 7 // Test cases are constructed using the ThatElvish function, followed by method 8 // calls that add additional information to it. 9 // 10 // Example: 11 // 12 // Test(t, someProgram, 13 // ThatElvish("-c", "echo hello").WritesStdout("hello\n")) 14 package progtest 15 16 import ( 17 "fmt" 18 "io" 19 "os" 20 "strings" 21 "testing" 22 23 "src.elv.sh/pkg/must" 24 "src.elv.sh/pkg/prog" 25 ) 26 27 // Case is a test case that can be used in Test. 28 type Case struct { 29 args []string 30 stdin string 31 want result 32 } 33 34 type result struct { 35 exitCode int 36 stdout output 37 stderr output 38 } 39 40 type output struct { 41 content string 42 partial bool 43 } 44 45 func (o output) String() string { 46 if o.partial { 47 return fmt.Sprintf("text containing %q", o.content) 48 } 49 return fmt.Sprintf("%q", o.content) 50 } 51 52 // ThatElvish returns a new Case with the specified CLI arguments. 53 // 54 // The new Case expects the program run to exit with 0, and write nothing to 55 // stdout or stderr. 56 // 57 // When combined with subsequent method calls, a test case reads like English. 58 // For example, a test for the fact that "elvish -c hello" writes "hello\n" to 59 // stdout reads: 60 // 61 // ThatElvish("-c", "hello").WritesStdout("hello\n") 62 func ThatElvish(args ...string) Case { 63 return Case{args: append([]string{"elvish"}, args...)} 64 } 65 66 // WithStdin returns an altered Case that provides the given input to stdin of 67 // the program. 68 func (c Case) WithStdin(s string) Case { 69 c.stdin = s 70 return c 71 } 72 73 // DoesNothing returns c itself. It is useful to mark tests that otherwise don't 74 // have any expectations, for example: 75 // 76 // ThatElvish("-c", "nop").DoesNothing() 77 func (c Case) DoesNothing() Case { 78 return c 79 } 80 81 // ExitsWith returns an altered Case that requires the program run to return 82 // with the given exit code. 83 func (c Case) ExitsWith(code int) Case { 84 c.want.exitCode = code 85 return c 86 } 87 88 // WritesStdout returns an altered Case that requires the program run to write 89 // exactly the given text to stdout. 90 func (c Case) WritesStdout(s string) Case { 91 c.want.stdout = output{content: s} 92 return c 93 } 94 95 // WritesStdoutContaining returns an altered Case that requires the program run 96 // to write output to stdout that contains the given text as a substring. 97 func (c Case) WritesStdoutContaining(s string) Case { 98 c.want.stdout = output{content: s, partial: true} 99 return c 100 } 101 102 // WritesStderr returns an altered Case that requires the program run to write 103 // exactly the given text to stderr. 104 func (c Case) WritesStderr(s string) Case { 105 c.want.stderr = output{content: s} 106 return c 107 } 108 109 // WritesStderrContaining returns an altered Case that requires the program run 110 // to write output to stderr that contains the given text as a substring. 111 func (c Case) WritesStderrContaining(s string) Case { 112 c.want.stderr = output{content: s, partial: true} 113 return c 114 } 115 116 // Test runs test cases against a given program. 117 func Test(t *testing.T, p prog.Program, cases ...Case) { 118 t.Helper() 119 for _, c := range cases { 120 t.Run(strings.Join(c.args, " "), func(t *testing.T) { 121 t.Helper() 122 r := run(p, c.args, c.stdin) 123 if r.exitCode != c.want.exitCode { 124 t.Errorf("got exit code %v, want %v", r.exitCode, c.want.exitCode) 125 } 126 if !matchOutput(r.stdout, c.want.stdout) { 127 t.Errorf("got stdout %v, want %v", r.stdout, c.want.stdout) 128 } 129 if !matchOutput(r.stderr, c.want.stderr) { 130 t.Errorf("got stderr %v, want %v", r.stderr, c.want.stderr) 131 } 132 }) 133 } 134 } 135 136 // Run runs a Program with the given arguments. It returns the Program's exit 137 // code and output to stdout and stderr. 138 func Run(p prog.Program, args ...string) (exit int, stdout, stderr string) { 139 r := run(p, args, "") 140 return r.exitCode, r.stdout.content, r.stderr.content 141 } 142 143 func run(p prog.Program, args []string, stdin string) result { 144 r0, w0 := must.Pipe() 145 // TODO: This assumes that stdin fits in the pipe buffer. Don't assume that. 146 _, err := w0.WriteString(stdin) 147 if err != nil { 148 panic(err) 149 } 150 w0.Close() 151 defer r0.Close() 152 153 w1, get1 := capturedOutput() 154 w2, get2 := capturedOutput() 155 156 exitCode := prog.Run([3]*os.File{r0, w1, w2}, args, p) 157 return result{exitCode, output{content: get1()}, output{content: get2()}} 158 } 159 160 func matchOutput(got, want output) bool { 161 if want.partial { 162 return strings.Contains(got.content, want.content) 163 } 164 return got.content == want.content 165 } 166 167 func capturedOutput() (*os.File, func() string) { 168 r, w := must.Pipe() 169 output := make(chan string, 1) 170 go func() { 171 b, err := io.ReadAll(r) 172 if err != nil { 173 panic(err) 174 } 175 r.Close() 176 output <- string(b) 177 }() 178 return w, func() string { 179 // Close the write side so captureOutput goroutine sees EOF and 180 // terminates allowing us to capture and cache the output. 181 w.Close() 182 return <-output 183 } 184 }