404 lines
8.8 KiB
Go
404 lines
8.8 KiB
Go
|
// 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"
|
||
|
"strconv"
|
||
|
"strings"
|
||
|
)
|
||
|
|
||
|
// 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
|
||
|
|
||
|
helpers map[string]struct{} // functions to be skipped when writing file/line info
|
||
|
}
|
||
|
|
||
|
var noColorFlag bool
|
||
|
|
||
|
func init() {
|
||
|
var envNoColor bool
|
||
|
|
||
|
// prefer https://no-color.org (with any value)
|
||
|
if _, ok := os.LookupEnv("NO_COLOR"); ok {
|
||
|
envNoColor = true
|
||
|
}
|
||
|
|
||
|
if v, ok := os.LookupEnv("IS_NO_COLOR"); ok {
|
||
|
if b, err := strconv.ParseBool(v); err == nil {
|
||
|
envNoColor = b
|
||
|
}
|
||
|
}
|
||
|
|
||
|
flag.BoolVar(&noColorFlag, "nocolor", envNoColor, "turns off colors")
|
||
|
}
|
||
|
|
||
|
// 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, !noColorFlag, map[string]struct{}{}}
|
||
|
}
|
||
|
|
||
|
// 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, !noColorFlag, map[string]struct{}{}}
|
||
|
}
|
||
|
|
||
|
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) {
|
||
|
return
|
||
|
}
|
||
|
if isNil(a) || isNil(b) {
|
||
|
is.logf("%s != %s", is.valWithType(a), is.valWithType(b))
|
||
|
} else if reflect.ValueOf(a).Type() == reflect.ValueOf(b).Type() {
|
||
|
is.logf("%v != %v", a, b)
|
||
|
} else {
|
||
|
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 similar
|
||
|
// 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 T) *I {
|
||
|
return New(t)
|
||
|
}
|
||
|
|
||
|
// NewRelaxed is a method wrapper around the NewRelaxed
|
||
|
// method. It allows you to write subtests using a similar
|
||
|
// pattern:
|
||
|
//
|
||
|
// func Test(t *testing.T) {
|
||
|
// is := is.NewRelaxed(t)
|
||
|
// t.Run("sub", func(t *testing.T) {
|
||
|
// is := is.NewRelaxed(t)
|
||
|
// // TODO: test
|
||
|
// })
|
||
|
// }
|
||
|
func (is *I) NewRelaxed(t T) *I {
|
||
|
return NewRelaxed(t)
|
||
|
}
|
||
|
|
||
|
func (is *I) valWithType(v interface{}) string {
|
||
|
if isNil(v) {
|
||
|
return "<nil>"
|
||
|
}
|
||
|
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.True(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) {
|
||
|
return true
|
||
|
}
|
||
|
if isNil(a) || isNil(b) {
|
||
|
return false
|
||
|
}
|
||
|
if reflect.DeepEqual(a, b) {
|
||
|
return true
|
||
|
}
|
||
|
aValue := reflect.ValueOf(a)
|
||
|
bValue := reflect.ValueOf(b)
|
||
|
return aValue == bValue
|
||
|
}
|
||
|
|
||
|
// 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 {
|
||
|
i++
|
||
|
continue
|
||
|
}
|
||
|
|
||
|
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
|
||
|
}
|
||
|
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 {
|
||
|
i++
|
||
|
continue
|
||
|
}
|
||
|
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
|
||
|
}
|
||
|
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 := is.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)
|
||
|
}
|
||
|
|
||
|
s = escapeFormatString(s)
|
||
|
|
||
|
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(" // ")
|
||
|
comment = escapeFormatString(comment)
|
||
|
buf.WriteString(comment)
|
||
|
if is.colorful {
|
||
|
buf.WriteString(colorNormal)
|
||
|
}
|
||
|
}
|
||
|
buf.WriteString("\n")
|
||
|
return buf.String()
|
||
|
}
|
||
|
|
||
|
// escapeFormatString escapes strings for use in formatted functions like Sprintf.
|
||
|
func escapeFormatString(fmt string) string {
|
||
|
return strings.Replace(fmt, "%", "%%", -1)
|
||
|
}
|
||
|
|
||
|
const (
|
||
|
colorNormal = "\u001b[39m"
|
||
|
colorComment = "\u001b[31m"
|
||
|
colorFile = "\u001b[90m"
|
||
|
colorType = "\u001b[90m"
|
||
|
)
|