is

package module
v0.2.10 Latest Latest
Warning

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

Go to latest
Published: Mar 7, 2024 License: MIT Imports: 7 Imported by: 0

README

is

A very minimal, highly simplified testing framework for golang 1.18 and above.

Features

  • 1-line test descriptions (no need for if/else)
  • positive as well as negative results ("passed ...")
    • go test ./... => shows failed tests only
    • go test -v ./... => show passed and failed tests
  • consistent output formatting
    • easier to read and comprehend test results
      • errors produce 3 lines:
        • your message
        • the received value
        • the expected value

Usage

import (
       "testing"

       "codeberg.org/japh/is"
       )

func TestGreetings( t *testing.T ) {
    is := is.New(t)

    a := "hello"
    is.Equal(a, "hello", "a pleasant greeting")
    is.NotEqual(a, "hi", "a pleasant greeting")

    b := &struct{
        name string
        pet  *Pet
    }{
        name:"Jenny",
    }
    is.NotNil(b, "be should not be nil")
    is.Equal(b.name, "Jenny", "is the name 'Jenny'")
    is.Nil(b.pet, "Jenny does not have any pets :-(")
}

Support For Data Driven Test Cases

The is package also provides a simple way to provide tables of test data.

e.g.

func TestGreetingCategorizer( t *testing.T ) {
    is := is.New(t)

    const (
        greetCol = iota
        categoryCol
        descCol
        )
    
    testCases := is.TableFromString(`
        | greeting     | category | description                                 |
        | ------------ | -------- | ------------------------------------------- |
        | Good Evening | formal   | greeting for evenings, e.g. at a restaurant |
        | Hello        | informal | typical greeting                            |
        | Hi           | casual   | only among friends                          |
        | Salutations  | archaic  | where did you hear this?                    |
        `)

    for _, tc := testCases.DataRows() {
        givenGreeting := tc[greetCol]
        wantCategory := tc[categoryCol]
        description := tc[descCol]

        gotCategory := categoryOfWord(greeting)

        is.Equal(gotCategory, wantCategory, description)
    }
}

Background

There are plenty of feature-rich testing frameworks for go, incuding go's own testing package, however, I haven't found any that fit my testing philosophy:

  • go's testing package assumes that all tests ran and only reports errors.

    In my experience, saying "nothing broke" is not the same as "everyting worked", so I want positive feedback as well.

  • other packages, such as testify, go in the other direction, providing feature-rich API's with everything you could possible want, at the expense of additional complexity.

... but I just want to check simple yes/no expectations, with equally simple, readable output. And this is it :-)

  • and then I saw Mat Ryer's is | github
    • which was a bit simpler
    • but a bit too minimalistic
    • so this version of is was created
    • differences
      • uses a per-test instance to simplify the interface

2021, Stephen Riehm, japh-codeberg@opensauce.de

Documentation

Overview

Package is simplifies and normalises unit test definitions and results.

Features

'is' provides assert-like functions, so that:

  • each test can be written in a single line, no need for if/else blocks

  • passing tests are logged, as well as failing tests.

    "nothing failed" and "everything passed" are NOT the same!

  • failing tests show received and wanted values aligned vertically under each other, making differences easier to identify

Inspriation

I find the default formatting of t.Errorf() and the "%v" fmt-directive impossible to read.

https://www.arp242.net/go-testing-style.html give's a few 'style hints', and this package just takes some of those ideas one step further (while only providing a minimal set of comparitors), specifically (in my order of preference):

  • make "what is being tested" clear
  • use "got" and "want", in that order
  • add useful, vertically aligned information

Getting Started

An example setup might be:

import (
    "testing"
    "codeberg.org/japh/is"
)

func TestOne(t *testing.T) {
    is := is.New(t)
    greeting := "hello"
    is.Equal(greeting, "hello", "%q - a pleasant greeting", greeting)
    is.Equal(greeting, "hi", "%q - an informal greeting", greeting)
}

Which should produce the output:

go test -v

=== RUN   TestOne
    one_test.go:8: pass: "hello" - a pleasant greeting
    one_test.go:9: fail: "hello" - an informal greeting
        got:  string(hello)
        want: string(hi)
--- FAIL: TestOne (0.00s)

All assertion functions have the same signature:

is.<comparison>(got, want interface{}, message ...any) bool

or, without an expected value, for self-contained checks like is.Nil, is.True, etc:

is.<comparison>(got interface{}, message ...any) bool

All assertion functions return true when the expected value was found and false otherwise.

func TestOne( t *testing.T ) {
    result, err := doSomething()
    if !is.Null(err, "something failed badly") {
        return
    }

    is.True(result.OK,"looks good")
}

Data-Driven Testing

Data-driven tests are also a much nicer way to create tests which are easier to understand and modify.

go's style for ad-hoc test arrays looks something like this:

tests := []struct{
    given string
    then string
}{
    {"1 + 2", "3"},
    {"2 * 5", "10"},
    ...
}

for _, tc := range tests {
    ...

The is package, however, provides is.TableFromString(), which converts a very simple text table into a [][]string array:

tests := is.TableFromString(`

    My calculator should be able to perform simple arithmetic

    | given | then |
    | ----- | ---- |
    | 1 + 2 |    3 |
    | 2 * 5 |   10 |

    Any lines which do not begin with a '|' character are ignored

    `)

for _, tc := range tests.DataRows() {
    // tc = []string{"1 + 2","3"}
    ...
}
Example (DataDrivenTesting)
package main

import (
	"fmt"
	"strconv"
	"strings"

	"codeberg.org/japh/is"
)

var t = &mockTestingT{}

func main() {

	const (
		givenCol = iota
		whenCol
		thenCol
		errCol
		commentCol
	)

	// testCases.DataRows() [][]string returns the data from the table, with
	// leading and trailing spaces trimmed.
	//
	// Notes:
	//	- indenting is ignored
	//	- lines that do not begin with a | are ignored
	//	- lines that only contain the characters |, +, = or - are ignored
	testCases := is.TableFromString(`

                An example of using a text table to define a group of tests.

                +---------+---------+------+-------------------+-------------------------+
                | given   | when    | then | error             | comment                 |
                | ------- | ------- | ---- | ----------------- | ----------------------- |
                |         |         |      | empty expression  |                         |
                |         | 1 + 2   | 3    |                   |                         |
                |         | a + b   |      | a is not a number |                         |
                | a:1 b:2 | a + b   | 3    |                   |                         |
                | ------- | ------- | ---- | ----------------- | ----------------------- |
                | a:1     | sqrt(a) | 1    |                   |                         |
                | a:4     | sqrt(a) | 2    |                   |                         |
                | a:-1    | sqrt(a) |      | undefined         | need imaginary numbers? |
                +---------+---------+------+-------------------+-------------------------+

                `)

	for line, tc := range testCases.DataRows() {
		when := tc[whenCol]
		then, _ := strconv.ParseFloat(tc[thenCol], 64)
		errStr := tc[errCol]
		comment := tc[commentCol]
		name := when
		if name == "" {
			name = errStr
		}

		// 'given' is a little more complicated:
		//      split into space separated fields
		//      then split each field into a 'name' and 'value'
		given := map[string]float64{}
		givenNames := []string{} // need a separate slice to keep keys in order
		for _, f := range strings.Fields(tc[givenCol]) {
			nv := strings.Split(f, ":")
			name := nv[0]
			value := nv[1]
			given[name], _ = strconv.ParseFloat(value, 64)
			givenNames = append(givenNames, name)
		}

		t.Run(
			fmt.Sprintf("line %d: %s", line, name),
			func(t *mockTestingT /* t *testing.T */) {

				// a little extra context
				if comment != "" {
					t.Logf("# %s", comment)
				}

				// given <preconditions>
				for _, name := range givenNames {
					value := given[name]
					t.Logf("set %s = %v", name, value)
				}

				// when <action>
				t.Logf("run %s ...", when)

				// then <expectations>
				t.Logf("got %v (error: %v)", then, errStr)
			},
		)
	}

}

////////////////////////////////////////////////////////////////////////////////
//
// Mock testing.T for demonstration purposes only
//

// Normal test functions use their testing.T parameters to report errors.
// Since Example() must be defined without parameters, we define a little
// mock 't' variable to assume the role of testing.T
type mockTestingT struct {
	indent string
}

func (t *mockTestingT) Run(name string, testFn func(*mockTestingT)) {
	fmt.Println("=== RUN", name)
	t.indent = "    "
	testFn(t)
	t.indent = ""
}

func (t *mockTestingT) Error(args ...interface{}) {
	fmt.Printf("%s%s\n", t.indent, fmt.Sprint(args...))
}

func (t *mockTestingT) Errorf(msgFmt string, args ...interface{}) {
	fmt.Printf("%s%s\n", t.indent, fmt.Sprintf(msgFmt, args...))
}

func (t *mockTestingT) Log(args ...interface{}) {
	fmt.Printf("%s%s\n", t.indent, fmt.Sprint(args...))
}

func (t *mockTestingT) Logf(msgFmt string, args ...interface{}) {
	fmt.Printf("%s%s\n", t.indent, fmt.Sprintf(msgFmt, args...))
}
Output:

=== RUN line 0: empty expression
    run  ...
    got 0 (error: empty expression)
=== RUN line 1: 1 + 2
    run 1 + 2 ...
    got 3 (error: )
=== RUN line 2: a + b
    run a + b ...
    got 0 (error: a is not a number)
=== RUN line 3: a + b
    set a = 1
    set b = 2
    run a + b ...
    got 3 (error: )
=== RUN line 4: sqrt(a)
    set a = 1
    run sqrt(a) ...
    got 1 (error: )
=== RUN line 5: sqrt(a)
    set a = 4
    run sqrt(a) ...
    got 2 (error: )
=== RUN line 6: sqrt(a)
    # need imaginary numbers?
    set a = -1
    run sqrt(a) ...
    got 0 (error: undefined)

Index

Examples

Constants

This section is empty.

Variables

View Source
var SelfTest = false

Functions

func Equal added in v0.2.6

func Equal(r reporter, got, want any, message ...any) bool

Equal compares a value against an expected value

Special care is taken to compare interfaces, functions, pointers to structs etc. properly If the values are the same, the message is logged via testing.Log() and true is returned Otherwise, testing.Error() is used to report the error and false is returned

func Error added in v0.2.6

func Error(r reporter, got error, message ...any) bool

Error checks for a non-nil error

To check for a specific Error, use is.Equal() or is.ErrorMatching()

func ErrorMatching added in v0.2.6

func ErrorMatching(r reporter, got error, want string, message ...any) bool

ErrorMatching passes if

the pattern is empty and err is nil
or the pattern is not empty and the err matches the pattern

func Fail added in v0.2.6

func Fail(r reporter, message ...any) bool

Fail logs a message and returns false

func False added in v0.2.6

func False(r reporter, got bool, message ...any) bool

False passes if a boolean value is false

func Fatal added in v0.2.10

func Fatal(r reporter, message ...any)

Fatal logs a message and terminates testing by calling testing.FailNow()

func Logf added in v0.2.6

func Logf(r reporter, message ...any)

Logf logs a message via testing.Logf

func Nil added in v0.2.6

func Nil(r reporter, got any, message ...any) bool

Nil compares a value against nil

Special care is taken to compare interfaces, functions, pointers to structs etc. properly If the value is nil, the message is logged via testing.Log() and true is returned Otherwise, testing.Error() is used to report the error and false is returned

func NotEqual added in v0.2.6

func NotEqual(r reporter, got, want any, message ...any) bool

NotEqual compares a value against a value which is not expected

Special care is taken to compare interfaces, functions, pointers to structs etc. properly If the values are not the same, the message is logged via testing.Log() and true is returned Otherwise, testing.Error() is used to report the error and false is returned

func NotError added in v0.2.6

func NotError(r reporter, got error, message ...any) bool

NotError is an optimised Nil() check for errors

func NotNil added in v0.2.6

func NotNil(r reporter, got any, message ...any) bool

NotNil compares a value against nil

Special care is taken to compare interfaces, functions, pointers to structs etc. properly If the value is not nil, the message is logged via testing.Log() and true is returned Otherwise, testing.Error() is used to report the error and false is returned

func NotZero added in v0.2.6

func NotZero(r reporter, got any, message ...any) bool

func Pass added in v0.2.6

func Pass(r reporter, message ...any) bool

Pass prints a message and returns true

func True added in v0.2.6

func True(r reporter, got bool, message ...any) bool

True passes if a boolean value is true

func Zero added in v0.2.6

func Zero(r reporter, got any, message ...any) bool

Types

type Is

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

Is wraps a *testing.T struct for reporting the results of tests.

Typical use:

func TestSomething(t *testing.T) {
    is := is.New(t)
    ...
    is.Equal(got, want, message)
}

func New

func New(t reporter) *Is

New creates a new 'is' checker, which can then be used to define assertions in a manner closer to plain english:

is.{expectation}( {got}, {want}, {message}, {param}... )

func (*Is) Equal

func (is *Is) Equal(got, want any, message ...any) bool

Equal compares a value against an expected value

Special care is taken to compare interfaces, functions, pointers to structs etc. properly If the values are the same, the message is logged via t.Log() and true is returned Otherwise, t.Error() is used to report the error and false is returned

func (*Is) Error

func (is *Is) Error(got error, message ...any) bool

Error checks for a non-nil error

To check for a specific Error, use is.Equal() or is.ErrorMatching()

func (*Is) ErrorMatching added in v0.1.2

func (is *Is) ErrorMatching(got error, want string, message ...any) bool

ErrorMatching passes if

the pattern is empty and err is nil
or the pattern is not empty and the err matches the pattern

func (*Is) Fail

func (is *Is) Fail(message ...any) bool

Fail logs a message and returns false

func (*Is) False

func (is *Is) False(got bool, message ...any) bool

False passes if a boolean value is false

func (*Is) Fatal added in v0.2.10

func (is *Is) Fatal(message ...any)

Fatal logs a message and terminates testing by calling t.FailNow()

func (*Is) Logf added in v0.2.6

func (is *Is) Logf(message ...any)

Logf logs a message via testing.Logf

func (*Is) MessagePrefix added in v0.2.6

func (is *Is) MessagePrefix(prefix ...any)

MessagePrefix sets a common prefix for all messages.

The first parameter may be a format, as defined by fmt.Sprintf.

func (*Is) New added in v0.2.3

func (is *Is) New(t reporter) *Is

Is.New creates a new 'is' checker even if 'is' has already been overridden

func (*Is) Nil

func (is *Is) Nil(got any, message ...any) bool

Nil compares a value against nil

Special care is taken to compare interfaces, functions, pointers to structs etc. properly If the value is nil, the message is logged via t.Log() and true is returned Otherwise, t.Error() is used to report the error and false is returned

func (*Is) NotEqual

func (is *Is) NotEqual(got, want any, message ...any) bool

NotEqual compares a value against a value which is not expected

Special care is taken to compare interfaces, functions, pointers to structs etc. properly If the values are not the same, the message is logged via t.Log() and true is returned Otherwise, t.Error() is used to report the error and false is returned

func (*Is) NotError

func (is *Is) NotError(got error, message ...any) bool

NotError is an optimised Nil() check for errors

func (*Is) NotNil

func (is *Is) NotNil(got any, message ...any) bool

NotNil compares a value against nil

Special care is taken to compare interfaces, functions, pointers to structs etc. properly If the value is not nil, the message is logged via t.Log() and true is returned Otherwise, t.Error() is used to report the error and false is returned

func (*Is) NotZero

func (is *Is) NotZero(got any, message ...any) bool

func (*Is) Pass added in v0.2.0

func (is *Is) Pass(message ...any) bool

Pass prints a message and returns true

func (*Is) TableFromString

func (c *Is) TableFromString(input string) *Table

TableFromString parses a pipe-separated-values table into a [][]string slice.

e.g.

func TestSomething(t *testing.T) {
    is := is.New(t)
    tbl := is.TableFromString(`
        | °C   | message       |
        | ---- | ------------- |
        | -275 | impossible    |
        | -273 | absolute zero |
        | 0    | freezing      |
        | 25   | comfortable   |
        | 40   | hot           |
        | 100  | boiling       |
        `)
    for _, row := tbl.DataRows() {
        // e.g. row: []string{"-275","impossible"}
        ...
    }
}

See also description of the Table type.

func (*Is) True

func (is *Is) True(got bool, message ...any) bool

True passes if a boolean value is true

func (*Is) Zero

func (is *Is) Zero(got any, message ...any) bool

type Table

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

Table represents a 2-dimensional array of strings, intended for test definitions.

Tables of data can be represented as pipe-separated-values (PSV).

Table Parsing Rules

  • table rows must begin with a '|' character (after an optional indent)
  • columns / cells are separated by a single '|' character and surrounding whitespace
  • all rows do not need to have the same number of columns
  • the resulting data slices will, however, all have the same length
  • a trailing '|' is recommeded per line, but not required
  • lines that look like rulers are ignored (e.g. |---|, | === | or +---+)
  • completely empty rows are ignored (e.g. | | | |)
  • any other lines are ignored

Exampes

tbl := is.TableFromString(`
    | animal  | legs | wings |
    | ------- | ---- | ----- |
    | cat     | 4    |       |
    | chicken | 2    | 2     |
    `)
for _, row := range tbl.DataRows() {
    // here we get:
    //  []string{"cat","4",""}
    //  []string{"chicken","2","2"}
}

Known Issues

It is not possible to include a '|' character within a cell's data.

is.TableFromString(`| "|" |`) => []string{"\"","\""}
is.TableFromString(`| \|  |`) => []string{"\\",""}

is.Table is a drop-in-subset-replacement for codeberg.org/japh/psv's Table struct

See codeberg.org/japh/psv if you want to format or sort data etc.

Example (CreatingAndUsingTables)
package main

import (
	"fmt"

	"codeberg.org/japh/is"
)

func main() {
	tbl := is.TableFromString(`

            Tables should be introduced with an explanation.

            This table has been made a bit more fancy to demonstrate that there may
            be multiple header row(s). Data starts after the first horizontal ruler
            that appears after a row with some kind of data.

            +---------+------+-------+--------+
            | animal  | legs | wings | weight |
            | ======= | ==== | ===== | ====== |
            | cat     | 4    |       | 3      |
            | chicken | 2    | 2     | 1      |
            +---------+------+-------+--------+
            `)

	fmt.Println("All Rows:")
	for _, row := range tbl.AllRows() {
		fmt.Printf("  %#v\n", row)
	}

	fmt.Println("header Row:")
	fmt.Printf("  %#v\n", tbl.HeaderRow())

	fmt.Println("Data Rows:")
	for _, row := range tbl.DataRows() {
		fmt.Printf("  %#v\n", row)
	}

}
Output:

All Rows:
  []string{"animal", "legs", "wings", "weight"}
  []string{"cat", "4", "", "3"}
  []string{"chicken", "2", "2", "1"}
header Row:
  []string{"animal", "legs", "wings", "weight"}
Data Rows:
  []string{"cat", "4", "", "3"}
  []string{"chicken", "2", "2", "1"}
Example (RowLines)
package main

import (
	"fmt"
	"strings"

	"codeberg.org/japh/is"
)

func main() {
	tblStr := `

            A demonstration of line numbering

            +---------+------+-------+--------+
            | animal  | legs | wings | weight |
            | ======= | ==== | ===== | ====== |
            | cat     | 4    |       | 3      |
            | chicken | 2    | 2     | 1      |
            +---------+------+-------+--------+
            `
	tbl := is.TableFromString(tblStr)

	fmt.Println("Input:")
	for l, line := range strings.Split(tblStr, "\n") {
		fmt.Printf("  %2d:%s\n", l+1, strings.Trim(line, " "))
	}

	fmt.Println("All Rows:")
	rowLines := tbl.AllRowLines()
	for r, row := range tbl.AllRows() {
		fmt.Printf("  %2d: %#v\n", rowLines[r], row)
	}

	fmt.Println("Header Row:")
	fmt.Printf("  %2d: %#v\n", tbl.HeaderRowLine(), tbl.HeaderRow())

	fmt.Println("Data Rows:")
	rowLines = tbl.DataRowLines()
	for r, row := range tbl.DataRows() {
		fmt.Printf("  %2d: %#v\n", rowLines[r], row)
	}

}
Output:

Input:
   1:
   2:
   3:A demonstration of line numbering
   4:
   5:+---------+------+-------+--------+
   6:| animal  | legs | wings | weight |
   7:| ======= | ==== | ===== | ====== |
   8:| cat     | 4    |       | 3      |
   9:| chicken | 2    | 2     | 1      |
  10:+---------+------+-------+--------+
  11:
All Rows:
   6: []string{"animal", "legs", "wings", "weight"}
   8: []string{"cat", "4", "", "3"}
   9: []string{"chicken", "2", "2", "1"}
Header Row:
   6: []string{"animal", "legs", "wings", "weight"}
Data Rows:
   8: []string{"cat", "4", "", "3"}
   9: []string{"chicken", "2", "2", "1"}

func TableFromString

func TableFromString(input string) *Table

TableFromString parses a multi-line string representation of a table, and returns a Table object containing a [][]string slice.

This implementation does not return any errors. The assumption is that test tables are always used in a controlled, static, testing environment.

func (*Table) AllRowLines added in v0.2.0

func (tbl *Table) AllRowLines() []int

AllRowLines returns a slice that correlates each row returned by AllRows with a line in the original input string.

e.g.

tbl := is.TableFromString(...)
rows := tbl.AllRows()
lines := tbl.AllRowLines()
for r := range rows {
    fmt.Printf( "row #%d was found on line #%d\n", r, lines[r] )
}

func (*Table) AllRows added in v0.2.0

func (tbl *Table) AllRows() [][]string

AllRows returns the [][]string of data from the entire table, including an optional header row.

The rows returned are guaranteed to all have the same number of columns.

tbl := is.TableFromString(`
    | animal  | legs | wings |
    | ------- | ---- | ----- |
    | cat     | 4    |       |
    | chicken | 2    | 2     |
    `)
for _, row := range tbl.AllRows() {
    // here we get:
    //  []string{"animal","legs","wings"}
    //  []string{"cat","4",""}
    //  []string{"chicken","2","2"}
}

func (*Table) DataRowLines added in v0.2.0

func (tbl *Table) DataRowLines() []int

DataRowLines returns a slice of line numbers for the data rows only

func (*Table) DataRows

func (tbl *Table) DataRows() [][]string

DataRows returns the [][]string of data from the table, WITHOUT an optional header row.

Example
package main

import (
	"fmt"

	"codeberg.org/japh/is"
)

func main() {

	const (
		nameCol = iota
		hobbyCol
	)

	tbl := is.TableFromString(`
             Just a quick table of people's hobbies:

             | name | hobby
             | ---- | -----
             | Jane | hiking
             | Max  | knitting

             `)

	for _, row := range tbl.DataRows() {
		fmt.Printf("%s likes %s\n", row[nameCol], row[hobbyCol])
	}

}
Output:

Jane likes hiking
Max likes knitting

func (*Table) HeaderRow added in v0.2.7

func (tbl *Table) HeaderRow() []string

HeaderRow returns the first row of the table.

func (*Table) HeaderRowLine added in v0.2.7

func (tbl *Table) HeaderRowLine() int

HeaderRowLine returns the line numbers of the first row in the table.

Jump to

Keyboard shortcuts

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