got

package module
v1.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 3, 2023 License: MIT Imports: 15 Imported by: 0

README

GoT

GoDoc

Pronounced like "goatee".

This package seeks to reduce boilerplate in tests, making it easier to write more and better tests, particularly in a way that follows best-practices.

File-driven tests (aka: testdata)

One approach to writing tests, particularly when they are complex to set up, is to use file-based test fixtures.

Embedding in code is usually a suitable option for light-medium complexity code, but as things grow more sophisticated, particularly for integration testing and fuzz testing of non-trivial functions, embedding all that state into code can become a mess, and (in my experience) less readable the more time has passed.

While opening up files is not hard on it's own, there is usually more to it than that. You likely need to read the contents, sometimes you decode it as JSON. Each of these adds more code that distracts from your test. Beyond dealing with single files, consider reading directories, maybe even recursively. All of that is just boilerplate, and just serves to distract from the test itself.

Load fixtures for a single test

This package includes got.Load for loading files on disk into an annotated struct to eliminate this boilerplate from your own code.

package mypackage

import (
  "path/filepath"
  "strings"
  "testing"
)

// testdata/input.txt
// hello world

// testdata/expected.txt
// HELLO WORLD

func TestSomething(t *testing.T) {
  // define test case
  type Test struct {
    Input    string `testdata:"input.txt"`
    Expected string `testdata:"expected.txt"`
  }

  // load test fixtures
  var test Test
  got.Load(t, "testdata", &test)

  // run the code
  actual := strings.ToUpper(test.Input)

  // run test assertions
  if actual != test.Expected {
    t.Fatalf(`expected "%s", got "%s"`, test.Expected, actual)
  }
}

This is a contrived example, but the test code itself is pretty clear, without much distraction.

Load test fixtures into a complex type (eg: map, struct, slice)

Beyond this, there is support for reading JSON files and unmarshalling them automatically and without any additional boilerplate:

package mypackage

import (
  "path/filepath"
  "reflect"
  "strings"
  "testing"
)

// input.json
// {"a":"hello","b":"world"}

// expected.txt
// {"a":"HELLO","b":"WORLD"}

func TestSomething(t *testing.T) {
  // define test cases
  type Test struct {
    Input    map[string]string `testdata:"input.json"`
    Expected map[string]string `testdata:"expected.json"`
  }

  // load test fixtures
  var test Test
  got.LoadTestData(t, "testdata", &test)

  // run the code
  actual := make(map[string]string)
  for k, v := range test.Input {
    actual[k] = strings.ToUpper(v)
  }

  // run test assertions
  if !reflect.DeepEqual(actual, test.Expected) {
    t.Fatalf(`expected "%+v", got "%+v"`, test.Expected, actual)
  }
}

Out of the box, this library supports decoding .json, .yml and .yaml files into structs, maps and other types automatically. You can define your own codecs using codec.Register.

Running a test for each directory (aka: suite)

To go a step further, imagine you have a fairly complex output that you want to test, such as if you're writing some ETL code or operating on binary data.

All of this is fine, but once you have decided your test environment is complex enough to justify putting the test configuration onto disk, you should probably be making it easy to write many tests all in the same format, akin to what table-driven tests offer for simpler tests.

One approach is to have testdata/ and have subdirectories for each test, for example testdata/some_input_gets_some_output/. Enter got.TestSuite which is just a helper for executing a series of tests using sub-directories.

package mypackage

import (
  "path/filepath"
  "strings"
  "testing"
)

func TestSomething(t *testing.T) {
  // define test cases
  type Test struct {
    Input    string `testdata:"input.txt"`
    Expected string `testdata:"expected.txt"`
  }

  // define test suite
  suite := got.TestSuite{
    Dir: "testdata",
    TestFunc: func (t *testing.T, c got.TestCase) {
      // load test fixtures
      var test Test
      c.Load(t, &test)

      // run the code
      actual := strings.ToUpper(test.Input)

      // run test assertions
      if actual != test.Expected {
        t.Fatalf(`expected "%s", got "%s"`, test.Expected, actual)
      }
    },
  }

  // run the test suite
  suite.Run(t)
}
Using golden files

The next pattern that this library facilitates is golden files, which are generated when your code is known to be working a particular way, then saved somewhere that will be read from later when running later tests. An example of this is HTTP recording, but the possibilities are quite broad.

Enter got.Assert, which is the companion to got.Load in that it takes your annotated struct and then saves the data back to disk in the same format as it would be read. Generally, the only thing you need to treat as "golden" are the outputs, so we will define 2 structs:

package mypackage

import (
  "flag"
  "path/filepath"
  "strings"
  "testing"
)

// define a flag to indicate that we should update the golden files
var updateGolden = flag.Bool("update-golden", false, "Update golden test fixtures")

func TestSomething(t *testing.T) {
  // define the test inputs
  type Test struct {
    Input string `testdata:"input.txt"`
  }

  // define the expectations
  type Expected struct {
    Output string `testdata:"expected.txt"`
  }

  // define test suite
  suite := got.TestSuite{
    Dir: "testdata",
    TestFunc: func (t *testing.T, c got.TestCase) {
      // load test fixtures
      var test Test
      c.Load(t, &test)

      // run the code
      actual := strings.ToUpper(test.Input)

      // by default, run test assertions
      // when -update-golden is used, save the golden outputs to disk
      got.Assert(&Expected{Output: actual})
    },
  }

  // run the test suite
  suite.Run(t)
}
Skipping test cases

Sometimes, a test case needs to be disabled temporarily, but deleting it altogether may not be desirable. To accomplish this, simply rename the directory to have a ".skip" suffix.


Hopefully this demonstrates a bit of what can be accomplished with file-driven tests and golden files in particular. GoT is all about getting rid of the boilerplate that would otherwise obfuscate a complicated test environment. By doing so, the intention is to make it easier to write more tests, improve test coverage and overall just make testing easier.

Check out godoc for more information about the API.

Documentation

Overview

Package got is a collection of packages that aid in writing clearer tests by reducing boilerplate and encouraging clarity of intent.

Package got is a generated GoMock package.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Assert added in v1.0.0

func Assert(t T, dir string, values ...any)

Assert ensures that all the fields within the struct values match what is on disk, using reflection to Load a fresh copy and then comparing the 2 structs using go-cmp to perform the equality check.

When the "test.update-golden" flag is provided, the contents of each value struct will be persisted to disk instead. This allows any test to easily update their "golden files" and also do the assertion transparently.

func Load added in v1.0.0

func Load(t T, dir string, values ...any)

Load extracts the contents of dir into values which are structs annotated with the "testdata" struct tag.

The main parameter of the struct tag will be a path to a file relative to the input directory.

Fields with string or []byte as their types will be populated with the raw contents of the file.

Struct values will be decoded using the file extension to map to a [Codec]. For example, ".json" files can be processed using [JSONCodec] if it has been registered. Additional codecs (eg: YAML, TOML) can be registered if desired.

Map values can be used to dynamically load the contents of a directory, in situations where you don't necessarily know all the files ahead of time.

The defined map type must use string keys, otherwise it will return an error. The filename in the struct tag will then be treated as a glob pattern, populating the map with a key for each matched file (relative to the input directory).

The values in the map can be either string, []byte or structs as described above.

func LoadDirs added in v1.0.0

func LoadDirs(t T, dirs []string, values ...any)

LoadDirs is the same as Load but accepts multiple input directories, which can be used to set up test cases from a common/shared location while allowing an individual test-case to include it's own specific configuration.

Types

type MockT added in v1.0.0

type MockT struct {
	// contains filtered or unexported fields
}

MockT is a mock of T interface.

func NewMockT added in v1.0.0

func NewMockT(ctrl *gomock.Controller) *MockT

NewMockT creates a new mock instance.

func (*MockT) EXPECT added in v1.0.0

func (m *MockT) EXPECT() *MockTMockRecorder

EXPECT returns an object that allows the caller to indicate expected use.

func (*MockT) Fatal added in v1.0.0

func (m *MockT) Fatal(arg0 ...interface{})

Fatal mocks base method.

func (*MockT) Helper added in v1.0.0

func (m *MockT) Helper()

Helper mocks base method.

type MockTMockRecorder added in v1.0.0

type MockTMockRecorder struct {
	// contains filtered or unexported fields
}

MockTMockRecorder is the mock recorder for MockT.

func (*MockTMockRecorder) Fatal added in v1.0.0

func (mr *MockTMockRecorder) Fatal(arg0 ...interface{}) *gomock.Call

Fatal indicates an expected call of Fatal.

func (*MockTMockRecorder) Helper added in v1.0.0

func (mr *MockTMockRecorder) Helper() *gomock.Call

Helper indicates an expected call of Helper.

type T added in v1.0.0

type T interface {
	Helper()
	Fatal(...any)
}

type TestCase added in v1.0.0

type TestCase struct {
	// Name is the base name for this test case (excluding any parent names).
	Name string

	// Skip indicates that the test should be skipped. This is indicated to the
	// TestSuite by having a directory name with a ".skip" suffix.
	Skip bool

	// Dir is the base directory for this test case.
	Dir string

	// SharedDir is an alternate location for test case configuration, if the
	// suite has been configured to search for this.
	SharedDir string
}

TestCase is used to wrap up test metadata.

func (TestCase) Load added in v1.0.0

func (c TestCase) Load(t T, values ...any)

Load is a helper for loading testdata for this test case, factoring in a SharedDir automatically if applicable.

type TestSuite added in v1.0.0

type TestSuite struct {
	// Dir is the location of your test suite.
	Dir string

	// SharedDir adds an additional directory to search for test cases.
	//
	// When set, this directory is scanned first and is treated as the primary
	// test suite. For each sub-directory, a corresponding sub-directory must also
	// be found in Dir, or that sub-test will fail. Any sub-directories found in
	// Dir will be added to the test suite.
	//
	// This allows a test suite to be defined for a common interface, which can
	// then be run for all implementations of that interface, while allowing each
	// implementation to inculde their own additional test cases and
	// configuration.
	SharedDir string

	// TestFunc is the hook for running test code, it will be called for each
	// found test case in the suite.
	TestFunc func(*testing.T, TestCase)
}

TestSuite defines a collection of tests backed by directories/files on disk.

func (*TestSuite) Run added in v1.0.0

func (s *TestSuite) Run(t *testing.T)

Run loads and executes the test suite.

Directories

Path Synopsis

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL