onpar

package module
v0.3.3 Latest Latest
Warning

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

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

README

onpar

docs gha

Parallel testing framework for Go

Goals

  • Provide structured testing, with per-spec setup and teardown.
  • Discourage using closure state to share memory between setup/spec/teardown functions.
    • Sharing memory between the steps of a spec by using closure state means that you're also sharing memory with other tests. This often results in test pollution.
  • Run tests in parallel by default.
    • Most of the time, well-written unit tests are perfectly capable of running in parallel, and sometimes running tests in parallel can uncover extra bugs. This should be the default.
  • Work within standard go test functions, simply wrapping standard t.Run semantics.
    • onpar should not feel utterly alien to people used to standard go testing. It does some extra work to allow structured tests, but for the most part it isn't hiding any complicated logic - it mostly just calls t.Run.

Onpar provides a BDD style of testing, similar to what you might find with something like ginkgo or goconvey. The biggest difference between onpar and its peers is that a BeforeEach function in onpar may return a value, and that value will become the parameter required in child calls to Spec, AfterEach, and BeforeEach.

This allows you to write tests that share memory between BeforeEach, Spec, and AfterEach functions without sharing memory with other tests. When used properly, this makes test pollution nearly impossible and makes it harder to write flaky tests.

Running

After constructing a top-level *Onpar, defer o.Run().

If o.Run() is never called, the test will panic during t.Cleanup. This is to prevent false passes when o.Run() is accidentally omitted.

Assertions

OnPar provides an expectation library in the expect sub-package. Here is some more information about Expect and some of the matchers that are available:

However, OnPar is not opinionated - any assertion library or framework may be used within specs.

Specs

Test assertions are done within a Spec() function. Each Spec has a name and a function with a single argument. The type of the argument is determined by how the suite was constructed: New() returns a suite that takes a *testing.T, while BeforeEach constructs a suite that takes the return type of the setup function.

Each Spec is run in parallel (t.Parallel() is invoked for each spec before calling the given function).

func TestSpecs(t *testing.T) {
    type testContext struct {
        t *testing.T
        a int
        b float64
    }

    o := onpar.BeforeEach(onpar.New(t), func(t *testing.T) testContext {
        return testContext{t: t, a: 99, b: 101.0}
    })
    defer o.Run()

    o.AfterEach(func(tt testContext) {
            // ...
    })

    o.Spec("something informative", func(tt testContext) {
        if tt.a != 99 {
            tt.t.Errorf("%d != 99", tt.a)
        }
    })
}
Serial Specs

While onpar is intended to heavily encourage running specs in parallel, we recognize that that's not always an option. Sometimes proper mocking is just too time consuming, or a singleton package is just too hard to replace with something better.

For those times that you just can't get around the need for serial tests, we provide SerialSpec. It works exactly the same as Spec, except that onpar doesn't call t.Parallel before running it.

Grouping

Groups are used to keep Specs in logical place. The intention is to gather each Spec in a reasonable place. Each Group may construct a new child suite using BeforeEach.

func TestGrouping(t *testing.T) {
    type topContext struct {
        t *testing.T
        a int
        b float64
    }

    o := onpar.BeforeEach(onpar.New(t), func(t *testing.T) topContext {
        return topContext{t: t, a: 99, b: 101}
    }
    defer o.Run()

    o.Group("some-group", func() {
        type groupContext struct {
            t *testing.T
            s string
        }
        o := onpar.BeforeEach(o, func(tt topContext) groupContext {
            return groupContext{t: tt.t, s: "foo"}
        })

        o.AfterEach(func(tt groupContext) {
            // ...
        })

        o.Spec("something informative", func(tt groupContext) {
            // ...
        })
    })
}
Run Order

Each BeforeEach() runs before any Spec in the same Group. It will also run before any sub-group Specs and their BeforeEaches. Any AfterEach() will run after the Spec and before parent AfterEaches.

func TestRunOrder(t *testing.T) {
    type topContext struct {
        t *testing.T
        i int
        s string
    }
    o := onpar.BeforeEach(onpar.New(t), func(t *testing.T) topContext {
        // Spec "A": Order = 1
        // Spec "B": Order = 1
        // Spec "C": Order = 1
        return topContext{t: t, i: 99, s: "foo"}
    })
    defer o.Run()

    o.AfterEach(func(tt topContext) {
        // Spec "A": Order = 4
        // Spec "B": Order = 6
        // Spec "C": Order = 6
    })

    o.Group("DA", func() {
        o.AfterEach(func(tt topContext) {
            // Spec "A": Order = 3
            // Spec "B": Order = 5
            // Spec "C": Order = 5
        })

        o.Spec("A", func(tt topContext) {
            // Spec "A": Order = 2
        })

        o.Group("DB", func() {
            type dbContext struct {
                t *testing.T
                f float64
            }
            o.BeforeEach(func(tt topContext) dbContext {
                // Spec "B": Order = 2
                // Spec "C": Order = 2
                return dbContext{t: tt.t, f: 101}
            })

            o.AfterEach(func(tt dbContext) {
                // Spec "B": Order = 4
                // Spec "C": Order = 4
            })

            o.Spec("B", func(tt dbContext) {
                // Spec "B": Order = 3
            })

            o.Spec("C", func(tt dbContext) {
                // Spec "C": Order = 3
            })
        })

        o.Group("DC", func() {
            o.BeforeEach(func(tt topContext) *testing.T {
                // Will not be invoked (there are no specs)
            })

            o.AfterEach(func(t *testing.T) {
                // Will not be invoked (there are no specs)
            })
        })
    })
}

Avoiding Closure

Why bother with returning values from a BeforeEach? To avoid closure of course! When running Specs in parallel (which they always do), each variable needs a new instance to avoid race conditions. If you use closure, then this gets tough. So onpar will pass the arguments to the given function returned by the BeforeEach.

The BeforeEach is a gatekeeper for arguments. The returned values from BeforeEach are required for the following Specs. Child Groups are also passed what their direct parent BeforeEach returns.

Documentation

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Onpar

type Onpar[T, U any] struct {
	// contains filtered or unexported fields
}

Onpar stores the state of the specs and groups

func BeforeEach

func BeforeEach[T, U, V any](parent *Onpar[T, U], setup func(U) V) *Onpar[U, V]

BeforeEach creates a new child Onpar suite with the requested function as the setup function for all specs. It requires a parent Onpar.

The top level Onpar *must* have been constructed with New, otherwise the suite will not run.

BeforeEach should be called only once for each level (i.e. each group). It will panic if it detects that it is overwriting another BeforeEach call for a given level.

func New

func New[T TestRunner](t T, opts ...Opt) *Onpar[*testing.T, *testing.T]

New creates a new Onpar suite. The top-level onpar suite must be constructed with this. Think `context.Background()`.

It's normal to construct the top-level suite with a BeforeEach by doing the following:

o := BeforeEach(New(t), setupFn)

func (*Onpar[T, U]) AfterEach

func (o *Onpar[T, U]) AfterEach(f func(U))

AfterEach is used to cleanup anything from the specs or BeforeEaches. AfterEach may only be called once for each *Onpar value constructed.

func (*Onpar[T, U]) Group

func (o *Onpar[T, U]) Group(name string, f func())

Group is used to gather and categorize specs. Inside of each group, a new child *Onpar may be constructed using BeforeEach.

func (*Onpar[T, U]) Run

func (o *Onpar[T, U]) Run()

Run runs all of o's tests. Typically this will be called in a `defer` immediately after o is defined:

o := onpar.BeforeEach(onpar.New(t), setupFn)
defer o.Run()

func (*Onpar[T, U]) SerialSpec

func (o *Onpar[T, U]) SerialSpec(name string, f func(U))

SerialSpec is a test that runs synchronously (i.e. onpar will not call `t.Parallel`). While onpar is primarily a parallel testing suite, we recognize that sometimes a test just can't be run in parallel. When that is the case, use SerialSpec.

func (*Onpar[T, U]) Spec

func (o *Onpar[T, U]) Spec(name string, f func(U))

Spec is a test that runs in parallel with other specs.

type Opt

type Opt func(prefs) prefs

Opt is an option type to pass to onpar's constructor.

type Table added in v0.3.2

type Table[T, U, V any] struct {
	// contains filtered or unexported fields
}

Table is an entry to be used in table tests.

func TableSpec added in v0.3.2

func TableSpec[T, U, V any](parent *Onpar[T, U], spec func(U, V)) Table[T, U, V]

TableSpec returns a Table type which may be used to declare table tests. The spec argument is the test that will be run for each entry in the table.

This is effectively syntactic sugar for looping over table tests and calling `parent.Spec` for each entry in the table.

func (Table[T, U, V]) Entry added in v0.3.2

func (t Table[T, U, V]) Entry(name string, entry V) Table[T, U, V]

Entry adds an entry to t using entry as the value for this table entry.

Example
package main

import (
	"testing"

	"github.com/poy/onpar"
)

func main() {
	var t *testing.T
	o := onpar.New(t)
	defer o.Run()

	type table struct {
		input          string
		expectedOutput string
	}
	f := func(in string) string {
		return in + "world"
	}
	onpar.TableSpec(o, func(t *testing.T, tt table) {
		output := f(tt.input)
		if output != tt.expectedOutput {
			t.Fatalf("expected %v to produce %v; got %v", tt.input, tt.expectedOutput, output)
		}
	}).
		Entry("simple output", table{"hello", "helloworld"}).
		Entry("with a space", table{"hello ", "hello world"}).
		Entry("and a comma", table{"hello, ", "hello, world"})
}
Output:

func (Table[T, U, V]) FnEntry added in v0.3.2

func (t Table[T, U, V]) FnEntry(name string, setup func(U) V) Table[T, U, V]

FnEntry adds an entry to t that calls setup in order to get its entry value. The value from the BeforeEach will be passed to setup, and then both values will be passed to the table spec.

Example
package main

import (
	"bytes"
	"testing"

	"github.com/poy/onpar"
)

func main() {
	var t *testing.T
	o := onpar.New(t)
	defer o.Run()

	type table struct {
		input          string
		expectedOutput string
	}
	f := func(in string) string {
		return in + "world"
	}
	onpar.TableSpec(o, func(t *testing.T, tt table) {
		output := f(tt.input)
		if output != tt.expectedOutput {
			t.Fatalf("expected %v to produce %v; got %v", tt.input, tt.expectedOutput, output)
		}
	}).
		FnEntry("simple output", func(t *testing.T) table {
			var buf bytes.Buffer
			if _, err := buf.WriteString("hello"); err != nil {
				t.Fatalf("expected buffer write to succeed; got %v", err)
			}
			return table{input: buf.String(), expectedOutput: "helloworld"}
		})
}
Output:

type TestRunner

type TestRunner interface {
	Run(name string, fn func(*testing.T)) bool
	Failed() bool
	Cleanup(func())
}

TestRunner matches the methods in *testing.T that the top level onpar (returned from New) needs in order to work.

Directories

Path Synopsis
str
samples

Jump to

Keyboard shortcuts

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