Unit Test Using Commands Instead Of Mocks

Say you wrote some code to benchmark incremental builds:

func Benchmark(
    binary string, sourcePath string,
    resultPath string, numBuilds int) error {
  if err := build(binary); err != nil {
    return err
  }
  source, err := read(sourcePath)
  if err != nil {
    return err
  }
  deltas := []time.Duration{}
  for i := 0; i < numBuilds; i++ {
    if err := write(
        sourcePath,
        edit(source, i)); err != nil {
      return err
    }
    start := now()
    if err := build(binary); err != nil {
      return err
    }
    delta := now().Sub(start)
    deltas = append(deltas, delta)
    if err := revert(sourcePath); err != nil {
      return err
    }
  }
  return write(resultPath, report(deltas))
}

func Main() error {
  // these values would come from flags
  return Benchmark(
    "binary", "source_path",
    "result_path", 3 /* numBuilds */)
}

// compiler
func build(binary string) error {
  /* ... */
}

// file system
func read(path string) (string, error) {
  /* ... */
}

// self-contained
func edit(content string, delta int) string {
  /* ... */
}

// file system
func write(path string, content string) error {
  /* ... */
}

// time
func now() time.Time {
  return time.Now()
}

// version control
func revert(path string) error {
  /* ... */
}

// self-contained
func report(deltas []time.Duration) string {
  /* ... */
}

Explanation:

How would you test the Benchmark function? It has multiple external dependencies:

Some people would only unit test the functions edit and report, which don’t have external dependencies, and not the rest.

Move More Code To Main

Sometimes it is possible to move more code to the Main function to make the remaining code easier to test:

func Benchmark(
    binary string,
    sourcePath string,
    source string,
    numBuilds int) (string, error) {
  deltas := []time.Duration{}
  for i := 0; i < numBuilds; i++ {
    if err := write(
        sourcePath,
        edit(source, i)); err != nil {
      return "", err
    }
    start := now()
    if err := build(binary); err != nil {
      return "", err
    }
    delta := now().Sub(start)
    deltas = append(deltas, delta)
    if err := revert(sourcePath); err != nil {
      return "", err
    }
  }
  return report(deltas), nil
}

func Main() error {
  // these values would come from flags
  binary := "binary"
  sourcePath := "source_path"
  resultPath := "result_path"
  if err := build(binary); err != nil {
    return err
  }
  source, err := read(sourcePath)
  if err != nil {
    return err
  }
  result, err := Benchmark(
    binary, sourcePath,
    source, 3 /* numBuilds */)
  if err != nil {
    return err
  }
  return write(resultPath, result)
}

In this case doing this is not enough.

Use Dependency Injection

One way to make this code more testable (with fast, reliable, self-contained unit tests) is to use dependency injection and fakes. Normally you would inject a separate parameter for Compiler, FileSystem, Timer, VersionControl. I’ve injected a single parameter Delegate to simplify:

type Delegate interface {
  Build(binary string) error
  Read(path string) (string, error)
  Write(path string, content string) error
  Now() time.Time
  Revert(path string) error
}

type Benchmark struct {
  Delegate Delegate
}

func (b *Benchmark) Benchmark(
    binary string, sourcePath string,
    resultPath string, numBuilds int) error {
  if err := b.Delegate.Build(
      binary); err != nil {
    return err
  }
  source, err := b.Delegate.Read(sourcePath)
  if err != nil {
    return err
  }
  deltas := []time.Duration{}
  for i := 0; i < numBuilds; i++ {
    if err := b.Delegate.Write(
        sourcePath,
        edit(source, i)); err != nil {
      return err
    }
    start := b.Delegate.Now()
    if err := b.Delegate.Build(
        binary); err != nil {
      return err
    }
    delta := b.Delegate.Now().Sub(start)
    deltas = append(deltas, delta)
    if err := b.Delegate.Revert(
        sourcePath); err != nil {
      return err
    }
  }
  return b.Delegate.Write(
    resultPath, report(deltas))
}

type RealDelegate struct {
}

func Main() error {
  bench := Benchmark{Delegate: &RealDelegate{}}
  // these values would come from flags
  return bench.Benchmark(
    "binary", "source_path",
    "result_path", 3 /* numBuilds */)
}

// self-contained
func edit(content string, delta int) string {
  /* ... */
}

// self-contained
func report(deltas []time.Duration) string {
  /* ... */
}

// compiler
func (d *RealDelegate) Build(
    binary string) error {
  /* ... */
}

// file system
func (d *RealDelegate) Read(
    path string) (string, error) {
  /* ... */
}

// file system
func (d *RealDelegate) Write(
    path string, content string) error {
  /* ... */
}

// time
func (d *RealDelegate) Now() time.Time {
  return time.Now()
}

// version control
func (d *RealDelegate) Revert(
    path string) error {
  /* ... */
}

This makes it possible to create a FakeDelegate for the unit tests. But it is still a lot of work, specially in the Go language, to make this fake / mock / stub configurable to be able to control what it returns and perhaps also assert on the calls made.

Use Commands

Another way to make the original code more testable is to turn everything into commands that go through a single Runner:

type Runner interface {
  Run(
    command string,
    args ...string) (string, error)
}

type Benchmark struct {
  Runner Runner
}

func (b *Benchmark) Benchmark(
    binary string, sourcePath string,
    resultPath string, numBuilds int) error {
  if err := b.build(binary); err != nil {
    return err
  }
  source, err := b.read(sourcePath)
  if err != nil {
    return err
  }
  deltas := []float64{}
  for i := 0; i < numBuilds; i++ {
    if err := b.write(
        sourcePath,
        edit(source, i)); err != nil {
      return err
    }
    start, err := b.now()
    if err != nil {
      return err
    }
    if err := b.build(binary); err != nil {
      return err
    }
    end, err := b.now()
    if err != nil {
      return err
    }
    delta := end.Sub(start).Seconds()
    deltas = append(deltas, delta)
    if err := b.revert(sourcePath); err != nil {
      return err
    }
  }
  return b.write(resultPath, report(deltas))
}

type RealRunner struct {
}

func Main() error {
  bench := Benchmark{Runner: &RealRunner{}}
  // these values would come from flags
  return bench.Benchmark(
    "binary", "source_path",
    "result_path", 3 /* numBuilds */)
}

// self-contained
func edit(content string, delta int) string {
  return fmt.Sprintf("%s+%d", content, delta)
}

// self-contained
func report(deltas []float64) string {
  return fmt.Sprintf("%v", deltas)
}

// compiler
func (b *Benchmark) build(binary string) error {
  _, err := b.Runner.Run("build", binary)
  return err
}

// file system
func (b *Benchmark) read(
    path string) (string, error) {
  return b.Runner.Run("read", path)
}

// file system
func (b *Benchmark) write(
    path string, content string) error {
  _, err := b.Runner.Run("write", path, content)
  return err
}

// time
func (b *Benchmark) now() (time.Time, error) {
  out, err := b.Runner.Run("now")
  if err != nil {
    return time.Time{}, err
  }
  res, err := time.Parse(time.UnixDate, out)
  if err != nil {
    return time.Time{}, err
  }
  return res, nil
}

// version control
func (b *Benchmark) revert(path string) error {
  _, err := b.Runner.Run("revert", path)
  return err
}

func (r *RealRunner) Run(
    command string,
    args ...string) (string, error) {
  /* ... */
}

FakeRunner

This makes it much easier to create a fully configurable FakeRunner for the unit tests:

type FakeRunner struct {
  results []Result
  index   int
}

type Result struct {
  cmd string
  out string
  err error
}

func (f *FakeRunner) Run(
    command string,
    args ...string) (string, error) {
  if f.index >= len(f.results) {
    return "", fmt.Errorf(
      "Run %s %v index %d too high " +
      "for results %v", command, args,
      f.index, f.results)
  }
  res := f.results[f.index]
  f.index++
  got := join(command, args...)
  // would use diff.Diff
  if res.cmd != got {
    return "", fmt.Errorf(
      "Run %s %v got %s want %s",
      command, args, got, res.cmd)
  }
  return res.out, res.err
}

func join(
    command string,
    args ...string) string {
  return strings.Join(
    append([]string{command}, args...), " ")
}

func TestRun(t *testing.T) {
  inputs := []struct {
    results []Result
  }{{
    results: []Result{{
      cmd: "build binary",
    }, {
      cmd: "read source_path",
      out: "source_content",
    }, {
      // +0
      cmd: "write source_path source_content+0",
    }, {
      cmd: "now",
      out: "Mon Jan 01 00:00:00 MST 2000",
    }, {
      cmd: "build binary",
    }, {
      cmd: "now",
      // +10 seconds
      out: "Mon Jan 01 00:00:10 MST 2000",
    }, {
      cmd: "revert source_path",
    }, {
      // +1
      cmd: "write source_path source_content+1",
    }, {
      cmd: "now",
      out: "Mon Jan 01 00:00:10 MST 2000",
    }, {
      cmd: "build binary",
    }, {
      cmd: "now",
      // +5 seconds
      out: "Mon Jan 01 00:00:15 MST 2000",
    }, {
      cmd: "revert source_path",
    }, {
      cmd: "write result_path [10 5]",
    }},
  }}
  for _, in := range inputs {
    fake := &FakeRunner{results: in.results}
    bench := Benchmark{Runner: fake}
    if err := bench.Benchmark(
        "binary",
        "source_path",
        "result_path",
        2 /* numBuilds */); err != nil {
      t.Errorf(
        "Benchmark\n%#v\ngot err %v", in, err)
    }
    if fake.index != len(in.results) {
      t.Errorf(
        "Benchmark\n%#v\nfake.index %d " +
        "too low for len(in.results) %d",
        in, fake.index, len(in.results))
    }
  }
}

The main downside is that the code becomes less type-safe.

Typed Command Names

One way to make the code more type-safe is to use typed constants instead of strings for the command names:

type Command int

const (
  Build Command = iota
  Read
  Write
  Now
  Revert
)

var (
  commandNames = []string{
    "Build", "Read", "Write", "Now", "Revert"}
)

func (c Command) String() string {
  if int(c) < len(commandNames) {
    return commandNames[c]
  }
  return fmt.Sprintf("Command(%d)", c)
}

type Runner interface {
  Run(
    command Command,
    args ...string) (string, error)
}

type Benchmark struct {
  Runner Runner
}

func (b *Benchmark) Benchmark(
    binary string, sourcePath string,
    resultPath string, numBuilds int) error {
  /* ... */
}

type Fn func(args ...string) (string, error)

type RealRunner struct {
  Delegates map[Command]Fn
}

func Main() error {
  bench := Benchmark{Runner: &RealRunner{
    Delegates: map[Command]Fn{
      Build: func(
        args ...string) (string, error) {
          /* ... */
        },
      // ...
      // could check that
      // all commands are present
    },
  }}
  // these values would come from flags
  return bench.Benchmark(
    "binary", "source_path",
    "result_path", 3 /* numBuilds */)
}

// compiler
func (b *Benchmark) build(binary string) error {
  _, err := b.Runner.Run(Build, binary)
  return err
}

// file system
func (b *Benchmark) read(
    path string) (string, error) {
  return b.Runner.Run(Read, path)
}

// file system
func (b *Benchmark) write(
    path string, content string) error {
  _, err := b.Runner.Run(Write, path, content)
  return err
}

// time
func (b *Benchmark) now() (time.Time, error) {
  out, err := b.Runner.Run(Now)
  /* ... */
}

// version control
func (b *Benchmark) revert(path string) error {
  _, err := b.Runner.Run(Revert, path)
  return err
}

func (r *RealRunner) Run(
    command Command,
    args ...string) (string, error) {
  delegate, ok := r.Delegates[command]
  if !ok {
    return "", fmt.Errorf(
      "no delegate for command %s", command)
  }
  return delegate(args...)
}

Here it would also be possible to have each command be implemented in a different package and have each implementation register itself in the map of Delegates.

Subscribe to my mailing list and get a free email course

* indicates required

Interests



Updated on 2020 Jul 25.

DISCLAIMER: This is not professional advice. The ideas and opinions presented here are my own, not necessarily those of my employer.