is

package module
v0.1.1 Latest Latest
Warning

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

Go to latest
Published: Jan 23, 2023 License: MIT Imports: 6 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

Usage

import (
       "testing"

       "codeberg.org/japh/is"
       )

func TestSomething( 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 :-(")
}

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
  • use test drive [sic] tests

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)

Notes: all assertion functions have the generic interface:

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

or

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

for obvious checks like is.Nil, is.True, etc.

All is functions return a simple bool value, where true means the expected value was foound, false otherwise.

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

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

Tables

Data-driven tests are also a much nicer way to create tests which are easy to understand, modify, extend etc.

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

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

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

is, however, provides is.TableFromString(), which converts a very simple text table into a 2-dimensional array of strings:

tests, _ := is.TableFromString(`
    | given | then |
    | ----- | ---- |
    | 1 + 2 |    3 |
    | 2 * 5 |   10 |
    `)

for _, tc := range tests.DataRows()[1:] {
    // BDD style input
    given   := tc[0]
    then    := tc[1]

    // testable ints
    got     := mycalc.Evaluate(given)
    want, _ := strconv.Atoi(then)

    // tests
    is.Equal(got, want, "%s => %s", given, then)
}
Example
package main

import (
	"errors"
	"fmt"
	"strconv"
	"strings"

	"codeberg.org/japh/is"
)

var t = &mockTestingT{}

func main() {
	testData, _ := is.TableFromString(`

                A simple example of using a text table to define a group of tests

                +-----+---------+---------+------+-------------------+-------------------------+
                | wip | 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? |
                `)

	testCases, err := testCasesFromTable(testData)

	if err != nil {
		t.Error(err)
		return
	}

	wipOnly := testCases.wipCount > 0

	if wipOnly {
		t.Logf("Running %d of %d tests", testCases.wipCount, len(testCases.tests))
	}

	for _, testCase := range testCases.tests {
		if wipOnly && !testCase.wip {
			continue
		}

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

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

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

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

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

	}

	if testCases.wipCount > 0 {
		t.Errorf("skipped %d non-wip tests", len(testCases.tests)-testCases.wipCount)
	}

}

////////////////////////////////////////////////////////////////////////////////
//
// utiltity code to convert a table ([][]string) into something more practical
//

type testCaseTable struct {
	wipCount int
	tests    []*testCase
}

type testCase struct {
	line    int
	wip     bool
	name    string
	given   map[string]float64
	when    string
	then    float64
	err     error
	comment string
}

func testCasesFromTable(dataTable *is.Table) (*testCaseTable, error) {
	tct := &testCaseTable{}
	rows := dataTable.DataRows()
	lineOfRow := dataTable.RowLineMap()
	colMap := map[string]int{}
	for col, name := range rows[0] {
		colMap[name] = col
	}
	for r, row := range rows[1:] {
		tc := testCaseFromSlice(lineOfRow[r], row, colMap)
		tct.tests = append(tct.tests, tc)
		if tc.wip {
			tct.wipCount++
		}
	}
	return tct, nil
}

func testCaseFromSlice(line int, cell []string, col map[string]int) *testCase {
	tc := &testCase{}
	tc.line = line
	tc.wip = cell[col["wip"]] != ""
	tc.when = cell[col["when"]]
	tc.then, _ = strconv.ParseFloat(cell[col["then"]], 64)
	tc.comment = cell[col["comment"]]

	tc.name = cell[col["when"]]

	// 'given' is a little more complicated:
	//      split into space separated fields
	//      then split each field into a 'name' and 'value'
	tc.given = map[string]float64{}
	for _, f := range strings.Fields(cell[col["given"]]) {
		nv := strings.Split(f, ":")
		tc.given[nv[0]], _ = strconv.ParseFloat(nv[1], 64)
	}

	if err := cell[col["error"]]; err != "" {
		tc.err = errors.New(err)
	}

	return tc
}

////////////////////////////////////////////////////////////////////////////////
//
// 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:


Running 2 of 7 tests
=== RUN line 13: sqrt(a)
    set a = 4
    run sqrt(a)
    got 2 (error: <nil>)
=== RUN line 14: sqrt(a)
    # need imaginary numbers?
    set a = -1
    run sqrt(a)
    got 0 (error: undefined)
skipped 5 non-wip tests

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type ErrorReporter

type ErrorReporter interface {
	Error(...any)
	Log(...any)
	FailNow()
}

ErrorReporter is a minimal testing.T interface (derived from go's standard testing package).

This is basically dependency inversion, so that we can define our own (mock) testing package(s) as needed.

Wherever you see ErrorReporter mentioned in this package, you should mentally replace it with a testing.T instance.

type Is

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

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

func New

func New(r ErrorReporter) *Is

New creates a new 'is' checker, which can then be used to simply check is.{condition}({got}, {want}, {message}, {param}...)

func (*Is) Equal

func (c *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 (c *Is) Error(err error, message ...any) bool

Error checks for a non-nil error

func (*Is) Fail

func (c *Is) Fail(message ...any)

Fail simply fails with a message

func (*Is) False

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

False compares a boolean value against false

func (*Is) Nil

func (c *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 (c *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 (c *Is) NotError(err error, message ...any) bool

NotError is an optimised Nil() check for errors

func (*Is) NotNil

func (c *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 (c *Is) NotZero(got any, message ...any) bool

func (*Is) TableFromString

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

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

This is only to help with situations like:

func TestSomething(t *testing.T) {
	is := is.New(T)
	is.TableFromString(...)	// access the 'is' package TableFromString() via the 'is' struct
	...
}

error is always null - only for compatibility with psv.TableFromString()

func (*Is) True

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

True compares a boolean value against true

func (*Is) Zero

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

type Table

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

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

In contrast to psv's Table, this table only provides access to the [][]string array of split data cells.

Example
package main

import (
	"fmt"

	"codeberg.org/japh/is"
)

func main() {

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

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

             `)

	if err != nil {
		fmt.Print(err)
		return
	}

	rows := tbl.DataRows()
	fmt.Printf("Got a table with %d rows and the columns:\n", len(rows))
	if len(rows) > 0 {
		for _, col := range rows[0] {
			fmt.Printf("    %s\n", col)
		}
		// fmt.Printf("\nPretty-Printed Table:\n%s\n", tbl)
	}

}
Output:


Got a table with 3 rows and the columns:
    name
    hobby

func TableFromString

func TableFromString(input string) (*Table, error)

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

This is a minimal, dependency free, drop-in replacement for my more extensive codeberg.org/japh/psv package. If you need more features, you can use that instead.

Table parsing rules:

  1. only parse lines that begin with a | (after an optional indent)
  2. columns/cells are separated by a single '|' character
  3. a trailing '|' is recommeded per line, but not required
  4. lines that look like rulers are ignored (e.g. |---|, | === | or +---+)
  5. any other lines are ignored

Known Issues:

  1. it is not possible to include a | in a cell's data. - e.g. | "|" | => | " | " | => two cells containing '"' instead of 1 cell containing '|' | \| | => | \ | | => '\' and ” instead of '|'

error is always null - only for compatibility with psv.TableFromString()

func (*Table) DataRows

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

DataRows returns the [][]string of data in the table. The rows returned are guaranteed to all have the same number of columns

tbl := psv.TableFromString(...).DataRows()
	for _, row := range tbl {
		for _, cell := range row {
			...
		}
	}
}

func (*Table) RowLineMap

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

Jump to

Keyboard shortcuts

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