diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..daf913b --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Compiled Object files, Static and Dynamic libs (Shared Objects) +*.o +*.a +*.so + +# Folders +_obj +_test + +# Architecture specific extensions/prefixes +*.[568vq] +[568vq].out + +*.cgo1.go +*.cgo2.c +_cgo_defun.c +_cgo_gotypes.go +_cgo_export.* + +_testmain.go + +*.exe +*.test +*.prof diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..a7efa7b --- /dev/null +++ b/.travis.yml @@ -0,0 +1,19 @@ +language: go + +sudo: required + +go: + - 1.6.x + - 1.7.x + - tip + +env: + - GIMME_OS=linux GIMME_ARCH=amd64 + - GIMME_OS=darwin GIMME_ARCH=amd64 + - GIMME_OS=windows GIMME_ARCH=amd64 + +install: + - go get -d -v ./... + +script: + - go build -v ./... diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3154c15 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017-2018 Mat Ryer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..eb2a72f --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# is [![GoDoc](https://godoc.org/github.com/matryer/is?status.png)](http://godoc.org/github.com/matryer/is) [![Go Report Card](https://goreportcard.com/badge/github.com/matryer/is)](https://goreportcard.com/report/github.com/matryer/is) [![Build Status](https://travis-ci.org/matryer/is.svg?branch=master)](https://travis-ci.org/matryer/is) +Professional lightweight testing mini-framework for Go. + +* Easy to write and read +* [Beautifully simple API](https://godoc.org/github.com/matryer/is) with everything you need: `is.Equal`, `is.True`, `is.NoErr`, and `is.Fail` +* Use comments to add descriptions (which show up when tests fail) + +Failures are very easy to read: + +![Examples of failures](https://github.com/matryer/is/raw/master/misc/delicious-failures.png) + +### Usage + +The following code shows a range of useful ways you can use +the helper methods: + +```go +func Test(t *testing.T) { + + is := is.New(t) + + signedin, err := isSignedIn(ctx) + is.NoErr(err) // isSignedIn error + is.Equal(signedin, true) // must be signed in + + body := readBody(r) + is.True(strings.Contains(body, "Hi there")) + +} +``` + +## Color + +To turn off the colors, run `go test` with the `-nocolor` flag. + +``` +go test -nocolor +``` diff --git a/is.go b/is.go new file mode 100644 index 0000000..848be11 --- /dev/null +++ b/is.go @@ -0,0 +1,403 @@ +// Package is provides a lightweight extension to the +// standard library's testing capabilities. +// +// Comments on the assertion lines are used to add +// a description. +// +// The following failing test: +// +// func Test(t *testing.T) { +// is := is.New(t) +// a, b := 1, 2 +// is.Equal(a, b) // expect to be the same +// } +// +// Will output: +// +// your_test.go:123: 1 != 2 // expect to be the same +// +// Usage +// +// The following code shows a range of useful ways you can use +// the helper methods: +// +// func Test(t *testing.T) { +// +// // always start tests with this +// is := is.New(t) +// +// signedin, err := isSignedIn(ctx) +// is.NoErr(err) // isSignedIn error +// is.Equal(signedin, true) // must be signed in +// +// body := readBody(r) +// is.True(strings.Contains(body, "Hi there")) +// +// } +package is + +import ( + "bufio" + "bytes" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" +) + +// T reports when failures occur. +// testing.T implements this interface. +type T interface { + // Fail indicates that the test has failed but + // allowed execution to continue. + // Fail is called in relaxed mode (via NewRelaxed). + Fail() + // FailNow indicates that the test has failed and + // aborts the test. + // FailNow is called in strict mode (via New). + FailNow() +} + +// I is the test helper harness. +type I struct { + t T + fail func() + out io.Writer + colorful bool +} + +var isColorful bool + +func init() { + noColor := flag.Bool("nocolor", false, "turns off colors") + flag.Parse() + isColorful = !*noColor +} + +// New makes a new testing helper using the specified +// T through which failures will be reported. +// In strict mode, failures call T.FailNow causing the test +// to be aborted. See NewRelaxed for alternative behavior. +func New(t T) *I { + return &I{t, t.FailNow, os.Stdout, isColorful} +} + +// NewRelaxed makes a new testing helper using the specified +// T through which failures will be reported. +// In relaxed mode, failures call T.Fail allowing +// multiple failures per test. +func NewRelaxed(t T) *I { + return &I{t, t.Fail, os.Stdout, isColorful} +} + +func (is *I) log(args ...interface{}) { + s := is.decorate(fmt.Sprint(args...)) + fmt.Fprintf(is.out, s) + is.fail() +} + +func (is *I) logf(format string, args ...interface{}) { + is.log(fmt.Sprintf(format, args...)) +} + +// Fail immediately fails the test. +// +// func Test(t *testing.T) { +// is := is.New(t) +// is.Fail() // TODO: write this test +// } +// +// In relaxed mode, execution will continue after a call to +// Fail, but that test will still fail. +func (is *I) Fail() { + is.log("failed") +} + +// True asserts that the expression is true. The expression +// code itself will be reported if the assertion fails. +// +// func Test(t *testing.T) { +// is := is.New(t) +// val := method() +// is.True(val != nil) // val should never be nil +// } +// +// Will output: +// +// your_test.go:123: not true: val != nil +func (is *I) True(expression bool) { + if !expression { + is.log("not true: $ARGS") + } +} + +// Equal asserts that a and b are equal. +// +// func Test(t *testing.T) { +// is := is.New(t) +// a := greet("Mat") +// is.Equal(a, "Hi Mat") // greeting +// } +// +// Will output: +// +// your_test.go:123: Hey Mat != Hi Mat // greeting +func (is *I) Equal(a, b interface{}) { + if !areEqual(a, b) { + if isNil(a) || isNil(b) { + aLabel := is.valWithType(a) + bLabel := is.valWithType(b) + if isNil(a) { + aLabel = "" + } + if isNil(b) { + bLabel = "" + } + is.logf("%s != %s", aLabel, bLabel) + return + } + if reflect.ValueOf(a).Type() == reflect.ValueOf(b).Type() { + is.logf("%v != %v", a, b) + return + } + is.logf("%s != %s", is.valWithType(a), is.valWithType(b)) + } +} + +// New is a method wrapper around the New function. +// It allows you to write subtests using a fimilar +// pattern: +// +// func Test(t *testing.T) { +// is := is.New(t) +// t.Run("sub", func(t *testing.T) { +// is := is.New(t) +// // TODO: test +// }) +// } +func (is *I) New(t *testing.T) *I { + return New(t) +} + +// NewRelaxed is a method wrapper aorund the NewRelaxed +// method. It allows you to write subtests using a fimilar +// pattern: +// +// func Test(t *testing.T) { +// is := is.New(t) +// t.Run("sub", func(t *testing.T) { +// is := is.New(t) +// // TODO: test +// }) +// } +func (is *I) NewRelaxed(t *testing.T) *I { + return NewRelaxed(t) +} + +func (is *I) valWithType(v interface{}) string { + if is.colorful { + return fmt.Sprintf("%[1]s%[3]T(%[2]s%[3]v%[1]s)%[2]s", colorType, colorNormal, v) + } + return fmt.Sprintf("%[1]T(%[1]v)", v) +} + +// NoErr asserts that err is nil. +// +// func Test(t *testing.T) { +// is := is.New(t) +// val, err := getVal() +// is.NoErr(err) // getVal error +// is.OK(len(val) > 10) // val cannot be short +// } +// +// Will output: +// +// your_test.go:123: err: not found // getVal error +func (is *I) NoErr(err error) { + if err != nil { + is.logf("err: %s", err.Error()) + } +} + +// isNil gets whether the object is nil or not. +func isNil(object interface{}) bool { + if object == nil { + return true + } + value := reflect.ValueOf(object) + kind := value.Kind() + if kind >= reflect.Chan && kind <= reflect.Slice && value.IsNil() { + return true + } + return false +} + +// areEqual gets whether a equals b or not. +func areEqual(a, b interface{}) bool { + if isNil(a) || isNil(b) { + if isNil(a) && !isNil(b) { + return false + } + if !isNil(a) && isNil(b) { + return false + } + return true + } + if reflect.DeepEqual(a, b) { + return true + } + aValue := reflect.ValueOf(a) + bValue := reflect.ValueOf(b) + return aValue == bValue +} + +func callerinfo() (path string, line int, ok bool) { + for i := 0; ; i++ { + _, path, line, ok = runtime.Caller(i) + if !ok { + return + } + if strings.HasSuffix(path, "is.go") { + continue + } + return path, line, true + } +} + +// loadComment gets the Go comment from the specified line +// in the specified file. +func loadComment(path string, line int) (string, bool) { + f, err := os.Open(path) + if err != nil { + return "", false + } + defer f.Close() + s := bufio.NewScanner(f) + i := 1 + for s.Scan() { + if i == line { + text := s.Text() + commentI := strings.Index(text, "//") + if commentI == -1 { + return "", false // no comment + } + text = text[commentI+2:] + text = strings.TrimSpace(text) + return text, true + } + i++ + } + return "", false +} + +// loadArguments gets the arguments from the function call +// on the specified line of the file. +func loadArguments(path string, line int) (string, bool) { + f, err := os.Open(path) + if err != nil { + return "", false + } + defer f.Close() + s := bufio.NewScanner(f) + i := 1 + for s.Scan() { + if i == line { + text := s.Text() + braceI := strings.Index(text, "(") + if braceI == -1 { + return "", false + } + text = text[braceI+1:] + cs := bufio.NewScanner(strings.NewReader(text)) + cs.Split(bufio.ScanBytes) + j := 0 + c := 1 + for cs.Scan() { + switch cs.Text() { + case ")": + c-- + case "(": + c++ + } + if c == 0 { + break + } + j++ + } + text = text[:j] + return text, true + } + i++ + } + return "", false +} + +// decorate prefixes the string with the file and line of the call site +// and inserts the final newline if needed and indentation tabs for formatting. +// this function was copied from the testing framework and modified. +func (is *I) decorate(s string) string { + path, lineNumber, ok := callerinfo() // decorate + log + public function. + file := filepath.Base(path) + if ok { + // Truncate file name at last file name separator. + if index := strings.LastIndex(file, "/"); index >= 0 { + file = file[index+1:] + } else if index = strings.LastIndex(file, "\\"); index >= 0 { + file = file[index+1:] + } + } else { + file = "???" + lineNumber = 1 + } + buf := new(bytes.Buffer) + // Every line is indented at least one tab. + buf.WriteByte('\t') + if is.colorful { + buf.WriteString(colorFile) + } + fmt.Fprintf(buf, "%s:%d: ", file, lineNumber) + if is.colorful { + buf.WriteString(colorNormal) + } + lines := strings.Split(s, "\n") + if l := len(lines); l > 1 && lines[l-1] == "" { + lines = lines[:l-1] + } + for i, line := range lines { + if i > 0 { + // Second and subsequent lines are indented an extra tab. + buf.WriteString("\n\t\t") + } + // expand arguments (if $ARGS is present) + if strings.Contains(line, "$ARGS") { + args, _ := loadArguments(path, lineNumber) + line = strings.Replace(line, "$ARGS", args, -1) + } + buf.WriteString(line) + } + comment, ok := loadComment(path, lineNumber) + if ok { + if is.colorful { + buf.WriteString(colorComment) + } + buf.WriteString(" // ") + buf.WriteString(comment) + if is.colorful { + buf.WriteString(colorNormal) + } + } + buf.WriteString("\n") + return buf.String() +} + +const ( + colorNormal = "\u001b[39m" + colorComment = "\u001b[32m" + colorFile = "\u001b[90m" + colorType = "\u001b[90m" +) diff --git a/is_test.go b/is_test.go new file mode 100644 index 0000000..b27dcf2 --- /dev/null +++ b/is_test.go @@ -0,0 +1,278 @@ +package is + +import ( + "bytes" + "errors" + "fmt" + "strings" + "testing" +) + +type mockT struct { + failed bool +} + +func (m *mockT) FailNow() { + m.failed = true +} +func (m *mockT) Fail() { + m.failed = true +} + +var tests = []struct { + N string + F func(is *I) + Fail string +}{ + // Equal + { + N: "Equal(1, 1)", + F: func(is *I) { + is.Equal(1, 1) // 1 doesn't equal 2 + }, + Fail: ``, + }, + + { + N: "Equal(1, 2)", + F: func(is *I) { + is.Equal(1, 2) // 1 doesn't equal 2 + }, + Fail: `1 != 2 // 1 doesn't equal 2`, + }, + { + N: "Equal(1, nil)", + F: func(is *I) { + is.Equal(1, nil) // 1 doesn't equal nil + }, + Fail: `int(1) != // 1 doesn't equal nil`, + }, + { + N: "Equal(nil, 2)", + F: func(is *I) { + is.Equal(nil, 2) // nil doesn't equal 2 + }, + Fail: ` != int(2) // nil doesn't equal 2`, + }, + { + N: "Equal(false, false)", + F: func(is *I) { + is.Equal(false, false) // nil doesn't equal 2 + }, + Fail: ``, + }, + { + N: "Equal(int32(1), int64(1))", + F: func(is *I) { + is.Equal(int32(1), int64(1)) // nope + }, + Fail: `int32(1) != int64(1) // nope`, + }, + { + N: "Equal(map1, map2)", + F: func(is *I) { + m1 := map[string]interface{}{"value": 1} + m2 := map[string]interface{}{"value": 2} + is.Equal(m1, m2) // maps + }, + Fail: `map[value:1] != map[value:2] // maps`, + }, + { + N: "Equal(true, map2)", + F: func(is *I) { + m1 := map[string]interface{}{"value": 1} + m2 := map[string]interface{}{"value": 2} + is.Equal(m1, m2) // maps + }, + Fail: `map[value:1] != map[value:2] // maps`, + }, + { + N: "Equal(slice1, slice2)", + F: func(is *I) { + s1 := []string{"one", "two", "three"} + s2 := []string{"one", "two", "three", "four"} + is.Equal(s1, s2) // slices + }, + Fail: `[one two three] != [one two three four] // slices`, + }, + { + N: "Equal(nil, chan)", + F: func(is *I) { + var a chan string + b := make(chan string) + is.Equal(a, b) // channels + }, + Fail: ` // channels`, + }, + { + N: "Equal(nil, slice)", + F: func(is *I) { + var s1 []string + s2 := []string{"one", "two", "three", "four"} + is.Equal(s1, s2) // nil slice + }, + Fail: ` // nil slice`, + }, + { + N: "Equal(nil, nil)", + F: func(is *I) { + var s1 []string + var s2 []string + is.Equal(s1, s2) // nil slices + }, + Fail: ``, + }, + { + N: "Equal(nil, map)", + F: func(is *I) { + var m1 map[string]string + m2 := map[string]string{} + is.Equal(m1, m2) // nil map + }, + Fail: ` // nil map`, + }, + { + N: "Equal(nil, nil)", + F: func(is *I) { + var m1 map[string]string + var m2 map[string]string + is.Equal(m1, m2) // nil maps + }, + Fail: ``, + }, + + // Fail + { + N: "Fail()", + F: func(is *I) { + is.Fail() // something went wrong + }, + Fail: "failed // something went wrong", + }, + + // NoErr + { + N: "NoErr(nil)", + F: func(is *I) { + var err error + is.NoErr(err) // method shouldn't return error + }, + Fail: "", + }, + { + N: "NoErr(error)", + F: func(is *I) { + err := errors.New("nope") + is.NoErr(err) // method shouldn't return error + }, + Fail: "err: nope // method shouldn't return error", + }, + + // OK + { + N: "True(1 == 2)", + F: func(is *I) { + is.True(1 == 2) + }, + Fail: "not true: 1 == 2", + }, +} + +func TestFailures(t *testing.T) { + colorful, notColorful := true, false + testFailures(t, colorful) + testFailures(t, notColorful) +} + +func testFailures(t *testing.T, colorful bool) { + for _, test := range tests { + tt := &mockT{} + is := New(tt) + var buf bytes.Buffer + is.out = &buf + is.colorful = colorful + test.F(is) + if len(test.Fail) == 0 && tt.failed { + t.Errorf("shouldn't fail: %s", test.N) + continue + } + if len(test.Fail) > 0 && !tt.failed { + t.Errorf("didn't fail: %s", test.N) + } + if colorful { + // if colorful, we won't check the messages + // this test is run twice, one without colorful + // statements. + // see TestFailures + fmt.Print(buf.String()) + continue + } + output := buf.String() + output = strings.TrimSpace(output) + if !strings.HasSuffix(output, test.Fail) { + t.Errorf("expected `%s` to end with `%s`", output, test.Fail) + } + } +} + +func TestRelaxed(t *testing.T) { + tt := &mockT{} + is := NewRelaxed(tt) + var buf bytes.Buffer + is.out = &buf + is.colorful = false + is.NoErr(errors.New("oops")) + is.True(1 == 2) + actual := buf.String() + if !strings.Contains(actual, `oops`) { + t.Errorf("missing: oops") + } + if !strings.Contains(actual, `1 == 2`) { + t.Errorf("missing: 1 == 2") + } + if !tt.failed { + t.Errorf("didn't fail") + } +} + +func TestLoadComment(t *testing.T) { + comment, ok := loadComment("./testdata/example_test.go", 14) + if !ok { + t.Errorf("loadComment: not ok") + } + if comment != `this comment will be extracted` { + t.Errorf("loadComment: bad comment %s", comment) + } +} + +func TestLoadArguments(t *testing.T) { + arguments, ok := loadArguments("./testdata/example_test.go", 23) + if !ok { + t.Errorf("loadArguments: not ok") + } + if arguments != `a == getB()` { + t.Errorf("loadArguments: bad arguments %s", arguments) + } + + arguments, ok = loadArguments("./testdata/example_test.go", 32) + if !ok { + t.Errorf("loadArguments: not ok") + } + if arguments != `a == getB()` { + t.Errorf("loadArguments: bad arguments %s", arguments) + } + + arguments, _ = loadArguments("./testdata/example_test.go", 28) + if len(arguments) > 0 { + t.Errorf("should be no arguments: %s", arguments) + } +} + +// TestSubtests ensures subtests work as expected. +// https://github.com/matryer/is/issues/1 +func TestSubtests(t *testing.T) { + t.Run("sub1", func(t *testing.T) { + is := New(t) + is.Equal(1+1, 2) + }) +} diff --git a/misc/delicious-failures.png b/misc/delicious-failures.png new file mode 100644 index 0000000..b1e0d01 Binary files /dev/null and b/misc/delicious-failures.png differ diff --git a/testdata/example_test.go b/testdata/example_test.go new file mode 100644 index 0000000..62ce8a8 --- /dev/null +++ b/testdata/example_test.go @@ -0,0 +1,33 @@ +package example + +// CAUTION: DO NOT EDIT +// Tests in this project rely on specific lines numbers +// throughout this file. + +import ( + "testing" + + "github.com/matryer/is" +) + +func TestSomething(t *testing.T) { + // this comment will be extracted +} + +func TestSomethingElse(t *testing.T) { + is := is.New(t) + a, b := 1, 2 + getB := func() int { + return b + } + is.True(a == getB()) // should be the same +} + +func TestSomethingElseTpp(t *testing.T) { + is := is.New(t) + a, b := 1, 2 + getB := func() int { + return b + } + is.True(a == getB()) +}