Mitchell Hashimoto on July 14, 2017
Liveblog by Beyang Liu (@beyang)
Mitchell Hashimoto is the founder of HashiCorp and creator of popular DevOps tools such as Vagrant, Packer, Terraform, Consul, Vault, and more. Mitchell is an O'Reilly author and one of the top GitHub users by followers, activity, and contributions. He has been using Go since prior to Go 1.0, and he is obsessed with automation.
Go is used everywhere at Hashicorp. It has been the primary language for the past 5 years.
Their projects have a number of properties that make testing "interesting":
There are two parts to testing:
Test Methodology:
Testable code:
The topics in this talk cover both of these parts. From here on out, we're just going to dive into a bunch of test examples. Topics are roughly ordered from "beginner" topics to "advanced" topics at the end.
func TestAdd(t *testing.T) {
a := 1
t.Run(“+1”, func(t *testing.T) {
if a + 1 != 2 { t.Fatal("fail!") }
})
t.Run(“+2”, func(t *testing.T) {
if a + 2 != 3 { t.Fatal("fail!") }
})
}
Subtests are built-in to Go. You can target subtests and can nest subtests further if necessary.
$ go test -run=TestAdd/+1
It's hard to explain the value of subtests without talking about table-driven tests.
Table tests are a way to build a table of data within a test and run through the table. Hashicorp uses table-driven tests everywhere. Mitchell defaults to table-driven tests since there might be other parameters you want to test later.
func TestAdd(t *testing.T) {
cases := []struct{ A, B, Expected int }{
{ 1, 1, 2 },
{ 1, -1, 0 },
{ 1, 0, 1 },
{ 0, 0, 0 },
}
for _, tc := range cases {
t.Run(fmt.Sprintf("%d + %d", tc.A, tc.B), func(t *testing.T) {
actual := tc.A + tc.B
if actual != expected { t.Fatal("failure") }
})
}
}
Consider naming the cases in a table-driven test:
func TestAdd(t *testing.T) {
cases := []struct{
Name string
A, B, Expected int
}{
{“foo”, 1, 1, 2 },
{“bar”, 1, -1, 0 },
}
for k, tc := range cases {
t.Run(tc.Name, func(t *testing.T) {
...
})
}
}
The first place Mitchell saw table-driven tests was in the Go standard library. A lot of these patterns come from the standard library or other popular Go open source libraries.
func TestAdd(t *testing.T) {
data := filepath.Join(“test-fixtures”, “add_data.json”)
// ... Do something with data
}
var update = flag.Bool(“update”, false, “update golden files”)
func TestAdd(t *testing.T) {
// ... table (probably!)
for _, tc := range cases {
actual := doSomething(tc)
golden := filepath.Join(“test-fixtures”, tc.Name+”.golden”)
if *update {
ioutil.WriteFile(golden, actual, 0644)
}
expected, _ := ioutil.ReadFile(golden)
if !bytes.Equal(actual, expected) {
// FAIL!
}
}
}
Then you can run
$ go test
...
$ go test -update
...
// Not good on its own
const port = 1000
// Better
var port = 1000
// Best
const defaultPort = 1000
type ServerOpts {
Port int // default it to defaultPort somewhere
}
func testTempFile(t *testing.T) string {
t.Helper()
tf, err := ioutil.TempFile(“”, “test”)
if err != nil {
t.Fatalf(“err: %s”, err)
}
tf.Close()
return tf.Name()
}
t.Helper()
for cleaner failure output (Go 1.9)func testTempFile(t *testing.T) (string, func()) {
t.Helper()
tf, err := ioutil.TempFile(“”, “test”)
if err != nil {
t.Fatalf(“err: %s”, err)
}
tf.Close()
return tf.Name(), func() { os.Remove(tf.Name()) }
}
func TestThing(t *testing.T) {
tf, tfclose := testTempFile(t)
defer tfclose()
}
func testChdir(t *testing.T, dir string) func() {
t.Helper()
old, err := os.Getwd()
if err != nil {
t.Fatalf(“err: %s”, err)
}
if err := os.Chdir(dir); err != nil {
t.Fatalf(“err: %s”, err)
}
return func() { os.Chdir(old) }
}
func TestThing(t *testing.T) {
defer testChdir(t, “/other”)()
}
This will be a little controversial. Some experienced Go devs disagree with this, but over time, this has proven effective at Hashicorp.
If you're testing networking, make a real network connection. Don’t mock net.Conn
, no point.
// Error checking omitted for brevity
func TestConn(t *testing.T) (client, server net.Conn) {
t.Helper()
ln, err := net.Listen(“tcp”, “127.0.0.1:0”)
var server net.Conn
go func() {
defer ln.Close()
server, err = ln.Accept()
}()
client, err := net.Dial(“tcp”, ln.Addr().String())
return client, server
}
Unconfigurable behavior is often a point of difficulty for tests.
// Do this, even if cache path and port are always the same
// in practice. For testing, it lets us be more careful.
type ServerOpts struct {
CachePath string
Port
int
}
type ServerOpts struct {
// ...
// Enables test mode which changes the behavior by X, Y, Z.
Test bool
}
type ComplexThing struct { /* ... */ }
func (c *ComplexThing) testString() string {
// produce human-friendly output for test comparison
}
//---------------------------------------------------------------
func TestComplexThing(t *testing.T) {
c1, c2 := createComplexThings()
if c1.testString() != c2.testString() {
t.Fatalf("no match:\n\n%s\n\n%s", c1.testString(), c2.testString())
}
}
testString()
method, which just converts structs to strings and tests for string equality. This method is a bit blunt, but we've had good results, because string diffs are easy to read.const testSingleDepStr = `
root: root
aws_instance.bar
aws_instance.bar -> provider.aws
aws_instance.foo
aws_instance.foo -> provider.aws
provider.aws
root
root -> aws_instance.bar
root -> aws_instance.foo
Subprocessing is typically a point of difficult-to-test behavior.
You have two options:
var testHasGit bool
func init() {
if _, err := exec.LookPath("git"); err == nil {
testHasGit = true
}
}
func TestGitGetter(t *testing.T) {
if !testHasGit {
t.Log("git not found, skipping")
t.Skip()
}
// ...
}
Get the *exec.Cmd
:
func helperProcess(s ...string) *exec.Cmd {
cs := []string{"-test.run=TestHelperProcess", "--"}
cs = append(cs, s...)
env := []string{
"GO_WANT_HELPER_PROCESS=1",
}
cmd := exec.Command(os.Args[0], cs...)
cmd.Env = append(env, os.Environ()...)
return cmd
}
What it executes:
func TestHelperProcess(*testing.T) {
if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
return
}
defer os.Exit(0)
args := os.Args
for len(args) > 0 {
if args[0] == "--" {
args = args[1:]
break
}
args = args[1:]
}
...
cmd, args := args[0], args[1:]
switch cmd {
case “foo”:
// ...
func ServeConn(rwc io.ReadWriteCloser) error {
// ...
}
func main() {
conn, err := net.Dial("tcp", "127.0.0.1")
ServeConn(conn)
}
Example: config file parser
Example: API server
Example: interface for downloading files
import "github.com/mitchellh/go-testing-interface"
// NOTE: non-pointer, cause its not the real "testing" package
func TestConfig(t testing.T) {
t.Fatal("fail!")
}
go test
is an incredible workflow toolgo test
, rather than a separate test harness.// Example from Vault
func TestBackend_basic(t *testing.T) {
b, _ := Factory(logical.TestBackendConfig())
logicaltest.Test(t, logicaltest.TestCase{
PreCheck: func() { testAccPreCheck(t) },
Backend: b,
Steps: []logicaltest.TestStep{
testAccStepConfig(t, false),
testAccStepRole(t),
testAccStepReadCreds(t, b, "web"),
testAccStepConfig(t,false),
testAccStepRole(t),
testAccStepReadCreds(t, b, "web"),
},
})
}
go test
to run themfunc TestThing(t *testing.T) {
// ...
select {
case <-thingHappened:
case <-time.After(timeout):
t.Fatal(“timeout”)
}
}
func TestThing(t *testing.T) {
// ...
timeout := 3 * time.Minute * timeMultiplier
select {
case <-thingHappened:
case <-time.After(timeout):
t.Fatal(“timeout”)
}
}
WaitForLeader
helper that uses APIs to check for
leadership vs. assuming that after some period of time that we have a
leader.Test helpers:
func TestThing(t *testing.T) {
t.Parallel()
}
-parallel=1
and -parallel=N