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  }