home

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README | LICENSE

2018-09-18-gotest-tools-icmd.org (8161B)


      1 #+TITLE: Golang testing — gotest.tools icmd
      2 #+date: <2018-09-18 Tue>
      3 #+filetags: go testing cmd
      4 #+setupfile: ../templates/post.org
      5 
      6 #+TOC: headlines 2
      7 
      8 * Introduction
      9 Let's continue the [[https://gotest.tools][=gotest.tools=]] serie, this time with the =icmd= package.
     10 
     11 #+BEGIN_QUOTE
     12 Package icmd executes binaries and provides convenient assertions for testing the results.
     13 #+END_QUOTE
     14 
     15 After file-system operations (seen in [[/posts/2018-09-14-gotest-tools-fs/][=fs=]]), another common use-case in tests is to
     16 *execute a command*. The reasons can be you're testing the =cli= you're currently writing
     17 or you need to setup something using a command line. A classic execution in a test might
     18 lookup like the following.
     19 
     20 #+BEGIN_SRC go
     21   cmd := exec.Command("echo", "foo")
     22   cmd.Stout = &stdout
     23   cmd.Env = env
     24   if err := cmd.Run(); err != nil {
     25           t.Fatal(err)
     26   }
     27   if string(stdout) != "foo" {
     28           t.Fatalf("expected: foo, got %s", string(stdout))
     29   }
     30 #+END_SRC
     31 
     32 The package =icmd= is there to ease your pain (as usual 😉) — we used /the name =icmd=/
     33 instead of =cmd= because it's a pretty common identifier in Go source code, thus would be
     34 really easy to /shadow/ — and have some really weird problems going on.
     35 
     36 The usual =icmd= workflow is the following:
     37 
     38 1. Describe the command you want to execute using : type =Cmd=, function =Command= and
     39    =CmdOp= operators.
     40 2. Run it using : function =RunCmd= or =RunCommand= (that does 1. for you). You can also
     41    use =StartCmd= and =WaitOnCmd= if you want more control on the execution workflow.
     42 3. Check the result using the =Assert=, =Equal= or =Compare= methods attached to the
     43    =Result= struct that the command execution return.
     44 
     45 * Create and run a command
     46 
     47 Let's first dig how to create commands. In this part, the assumption here is that the
     48 command is successful, so we'll have =.Assert(t, icmd.Success)= for now — we'll learn more
     49 about =Assert= in the next section 👼.
     50 
     51 The simplest way to create and run a command is using =RunCommand=, it has the same
     52 signature as =os/exec.Command=. A simple command execution goes as below.
     53 
     54 #+BEGIN_SRC go
     55   icmd.RunCommand("echo", "foo").Assert(t, icmd.Sucess)
     56 #+END_SRC
     57 
     58 Sometimes, you need to customize the command a bit more, like adding some environment
     59 variable. In those case, you are going to use =RunCmd=, it takes a =Cmd= and operators.
     60 Let's look at those functions.
     61 
     62 #+BEGIN_SRC go
     63   func RunCmd(cmd Cmd, cmdOperators ...CmdOp) *Result
     64 
     65   func Command(command string, args ...string) Cmd
     66 
     67   type Cmd struct {
     68           Command []string
     69           Timeout time.Duration
     70           Stdin   io.Reader
     71           Stdout  io.Writer
     72           Dir     string
     73           Env     []string
     74   }
     75 #+END_SRC
     76 
     77 As we've seen [[/posts/2017-01-01-go-testing-functionnal-builders/][multiple]] [[/posts/2018-08-16-gotest-tools-assertions/][times]] [[/posts/2018-09-14-gotest-tools-fs/][before]], it uses the /powerful/ functional arguments. At the
     78 time I wrote this post, the =icmd= package doesn't contains too much =CmdOp= [fn:1], so I'll
     79 propose two version for each example : one with =CmdOpt= present in [[https://github.com/gotestyourself/gotest.tools/pull/122][this PR]] and one
     80 without them.
     81 
     82 #+BEGIN_SRC go
     83   // With
     84   icmd.RunCmd(icmd.Command("sh", "-c", "echo $FOO"),
     85           icmd.WithEnv("FOO=bar", "BAR=baz"), icmd.Dir("/tmp"),
     86           icmd.WithTimeout(10*time.Second),
     87   ).Assert(t, icmd.Success)
     88 
     89   // Without
     90   icmd.RunCmd(icmd.Cmd{
     91           Command: []string{"sh", "-c", "echo $FOO"},
     92           Env: []string{"FOO=bar", "BAR=baz"},
     93           Dir: "/tmp",
     94           Timeout: 10*time.Second,
     95   }).Assert(t, icmd.Success)
     96 #+END_SRC
     97 
     98 As usual, the intent is clear, it's simple to read and composable (with =CmdOp='s).
     99 
    100 [fn:1] The =icmd= package is one of the oldest =gotest.tools= package, that comes from the
    101 [[https://github.com/docker/docker][=docker/docker=]] initially. We introduced these =CmdOp= but implementations were in
    102 =docker/docker= at first and we never really updated them.
    103 
    104 * Assertions
    105 
    106 Let's dig into the assertion part of =icmd=. Running a command returns a struct
    107 =Result=. It has the following methods :
    108 
    109 - =Assert= compares the Result against the Expected struct, and fails the test if any of
    110   the expectations are not met.
    111 - =Compare= compares the result to Expected and return an error if they do not match.
    112 - =Equal= compares the result to Expected. If the result doesn't match expected
    113   returns a formatted failure message with the command, stdout, stderr, exit code, and any
    114   failed expectations. It returns an =assert.Comparison= struct, that can be used by other
    115   =gotest.tools=.
    116 - =Combined= returns the stdout and stderr combined into a single string.
    117 - =Stderr= returns the stderr of the process as a string.
    118 - =Stdout= returns the stdout of the process as a string.
    119 
    120 When you have a result, you, most likely want to do two things :
    121 
    122 - /assert/ that the command succeed or failed with some specific values (exit code,
    123   stderr, stdout)
    124 - use the output — most likely =stdout= but maybe =stderr= — in the rest of the test.
    125 
    126 As seen above, /asserting/ the command result is using the =Expected= struct.
    127 
    128 #+BEGIN_SRC go
    129   type Expected struct {
    130           ExitCode int    // the exit code the command returned
    131           Timeout  bool   // did it timeout ?
    132           Error    string // error returned by the execution (os/exe)
    133           Out      string // content of stdout
    134           Err      string // content of stderr
    135   }
    136 #+END_SRC
    137 
    138 =Success= is a constant that defines a success — it's an exit code of =0=, didn't timeout,
    139 no error. There is also the =None= constant, that should be used for =Out= or =Err=, to
    140 specify that we don't want any content for those standard outputs.
    141 
    142 #+BEGIN_SRC go
    143   icmd.RunCmd(icmd.Command("cat", "/does/not/exist")).Assert(t, icmd.Expected{
    144           ExitCode: 1,
    145           Err:      "cat: /does/not/exist: No such file or directory",
    146   })
    147 
    148   // In case of success, we may want to do something with the result
    149   result := icmd.RunCommand("cat", "/does/exist")
    150   result.Assert(t, icmd.Success)
    151   // Read the output line by line
    152   scanner := bufio.NewScanner(strings.NewReader(result.Stdout()))
    153   for scanner.Scan() {
    154           // Do something with it
    155   }
    156 #+END_SRC
    157 
    158 If the =Result= doesn't map the =Expected=, a test failure will happen with a useful
    159 message that will contains the executed command and what differs between the result and
    160 the expectation.
    161 
    162 #+BEGIN_SRC go
    163   result := icmd.RunCommand(…)
    164   result.Assert(t, icmd.Expected{
    165                   ExitCode: 101,
    166                   Out:      "Something else",
    167                   Err:      None,
    168   })
    169   // Command:  binary arg1
    170   // ExitCode: 99 (timeout)
    171   // Error:    exit code 99
    172   // Stdout:   the output
    173   // Stderr:   the stderr
    174   //
    175   // Failures:
    176   // ExitCode was 99 expected 101
    177   // Expected command to finish, but it hit the timeout
    178   // Expected stdout to contain "Something else"
    179   // Expected stderr to contain "[NOTHING]"
    180    181 #+END_SRC
    182 
    183 Finally, we listed =Equal= above, that returns a =Comparison= struct. This means we can
    184 use it easily with the =assert= package. As written in a [[/posts/2018-08-16-gotest-tools-assertions/][previous post (about the =assert=
    185 package)]], I tend prefer to use =cmp.Comparison=. Let's convert the above examples using
    186 =assert=.
    187 
    188 #+BEGIN_SRC go
    189   result := icmd.RunCmd(icmd.Command("cat", "/does/not/exist"))
    190   assert.Check(t, result.Equal(icmd.Expected{
    191           ExitCode: 1,
    192           Err:      "cat: /does/not/exist: No such file or directory",
    193   }))
    194 
    195   // In case of success, we may want to do something with the result
    196   result := icmd.RunCommand("cat", "/does/exist")
    197   assert.Assert(t, result.Equal(icmd.Success))
    198   // Read the output line by line
    199   scanner := bufio.NewScanner(strings.NewReader(result.Stdout()))
    200   for scanner.Scan() {
    201           // Do something with it
    202   }
    203 #+END_SRC
    204 
    205 * Conclusion…
    206 
    207 … that's a wrap. The =icmd= package allows to easily run command and describe what result
    208 are expected of the execution, with the least noise possible. We *use this package heavily*
    209 on several =docker/*= projects (the engine, the cli)…