ntest

package module
v0.3.0 Latest Latest
Warning

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

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

README

ntest - dependency-injection test helpers for testing with nject

GoDoc unit tests report card codecov

Install:

go get github.com/memsql/ntest

Ntest is a collection of a few functions that aid in writing tests using nject.

Testing with nject

Nject operates by being given a list of functions. The last function in the list gets called. Other functions may also be called in order to create the types that the last function takes as parameters.

An example:


func TestExample(t *testing.T) {
	type databaseName string
	ntest.RunTest(t,
		context.Background,	// returns context.Contex
		getLogger,		// a function that returns a Logger
		func() databaseName {
			return databaseName(os.Getenv("APP_DATABASE"))
		},
		func (t *testing.T, dbName databaseName, logger Logger) *sql.DB {
			pool, err := sql.Open("msysql", databaseName)
			require.NoError(t, err, "open db")
			t.Cleanup(func() {
				err := pool.Close()
				assert.NoError(t, err, "close db")
			})
			return pool
		},
		func (ctx context.Context, conn *sql.DB) {
			// do a test with conn
		},
	)
}

In the example above, every function will be called because every function is needed to provide the parameters for the final function.

The framework connects everything together. If there was another function in the list, for example:

type retries int

func() retries {
	return 2
}

It would not be called because a retries isn't needed to invoke the final function.

The key things to note are:

  1. everything is based on types,
  2. only the functions that produce types that are used get called*
  3. the final function (probably your test) always gets called
  4. only one thing of each type is available (you can use Extra() to get more)
  • functions that produce nothing get called if they can be called

How to use

The suggested way to use ntest is to build test injectors on top of it.

Create your own test package. For example, "test/di".

In that package, import this package and then alias some types and functions so that test writers just use the package you provide.

import "github.com/memsql/ntest"

type T = ntest.T

var (
	Extra             = ntest.Extra
	RunMatrix         = ntest.RunMatrix
	RunParallelMatrix = ntest.RunParallelMatrix
	RunTest           = ntest.RunTest
)

Then in your package build a library of injectors for things that your tests might need that are specific to your application.

Use nject.Sequence to bundle sets of injectors together.

If you have a "standard" bundle then make new test runner functions that pre-inject your standard sets.

For example:

var IntegrationSequence = nject.Sequence("integration,
	injector1,
	injector2,
	...
)

func IntegrationTest(t T, chain ...interface{}) {
	RunTest(t,
		integrationSequence,
		nject.Sequence("chain", chain...))
}

Additional suggestions for how to use nject to write tests

Library of injectors

The first, and primary step is to simply build a bunch of injectors to build things that are needed for tests. If these things do not require configuration, then that's straightforward.

Easy examples are database connections, clients for services, etc.

Sequences of injectors

Package up the injectors into collections that are used together so that when the types needed to create an existing type change, the additional injector is included in everyone's code without needeing to make any per-test changes.

Cleanup

Any injector that provides something that must be cleaned up afterwards should arrange for the cleanup itself.

This is easily handled with t.Cleanup()

Abort vs nject.TerminalError

If the injection chains used in tests are only used in tests, then when something goes wrong in an injector, it can simply abort (t.FailNow()) the test.

If the injection chains are shared with non-test code, then instead of aborting, injectors can return nject.TerminalError to abort the test that way.

Override-default pattern

The easiest pattern to follow for allowing the defaults to be overridden some of the time is to provide the defaults with a named injector and then provide an override function that replaces it.

For example, providing a database DSN:

type databaseDSN string

var Database = nject.Sequence("open database",
	nject.Provide("default-dsn", func() databaseDSN { return "test:pass@/example" }),
	func(t ntest.T, dsn databaseDSN) *sql.DB {
		db, err := sql.Open("mysql", string(dsn))
		require.NoErrorf(t, err, "open database %s", dsn)
		return db
	},
)

func OverrideDSN(dsn string) nject.Provider {
	return nject.ReplaceNamed("default-dsn",
		func() databaseDSN {
			return databaseDSN(dsn)
		})
}

With that, Database is all you need to get an *sql.DB injected. If you want a different DSN for your test, you can use OverrideDSN in the injection chain. This allows Database to be included in default chains that are always placed before test-specific chains.

Inserting Extra in the middle of an injection sequence

As mentioned in the docs for Extra, sometimes you need to insert the call to Extra at specific spots in your injection chain.

For example, suppose you have a pattern where you are build something complicated with several injectors and you want extras created with variants.

Without extra:

var Chain := nject.Sequence("chain",
	func () int { return 438 },
	func (n int) typeA { return typeA(strings.Itoa(rand.Intn(n))) },
	func (a typeA) typeB { return typeB(a) },
	func (b typeB) typeC { return typeC(b) },
)

Now, if you wanted an extra couple of type Bs that each come from distinct typeAs, you'll have to rebuild your chain.

First name your injectors:

var N = nject.Provide("N", func () int { return 438 })
var A = nject.Provide("A", func (n int) typeA { return typeA(strings.Itoa(rand.Intn(n))) })
var B = nject.Provide("B", func (a typeA) typeB { return typeB(a) })
var C = nject.Provide("C", func (b typeB) typeC { return typeC(b) })
var Chain = nject.Sequence("chain", N, A, B, C)

Now when you can get extras easily enough:

func TestSomething(t *testing.T) {
	var extraB1 typeB
	var extraB2 typeB
	ntest.RunTest(t, Chain,
		nject.InsertBeforeNamed("A", ntest.Extra(A, B, &extraB1)),
		nject.InsertBeforeNamed("A", ntest.Extra(A, B, &extraB2)),
		func(b typeB, c typeC) {
			// b, extraB1, extraB2 are all different (probably)
		},
	)
}

Custom sequence pattern

The custom sequence pattern works well when there is no default value and thus you cannot include builders in the standard injector sequences.

Customer sequences are also appropriate for situations where you're supporting just one or two tests.

The basic idea is to build a sequence that includes customization:

func createSomethingForMyTest(parameter1 type1, parameter2 type2, etc) *nject.Collection {
	return nject.Sequence("createSomething",
		injector(s),
		func(stuff, from chain) {
			// code that uses parameter1, parameter2, etc
		},
		moreInjector(s),
	)
}

The custom sequence pattern is also useful for pre-built sequences for Extra. In that case, the parameters are pointers. In the example below, the parameters to customize the extra thing are injectors.

func ExtraThing(tp *Thing, overrides ...any) nject.Provider
	return nject.InsertAfterNamed("some-injector",
		ntest.Extra(
			nject.Required(nject.Sequence("extra-thing-overrides", overrides...)),
			tp))

func TestSomething(t *testing.T) {
	var thing1 Thing
	var thing2 Thing
	ntest.RunTest(t, standardInjectorChain,
		ExtraThing(&thing1, thingParameterType("thing1")),
		ExtraThing(&thing1, thingParameterType("thing2")),
		func(stuff, from chain) {
			testWith(stuff, and, thing1, and, thing2)
		},
	)

Passing functions around

Nject does not allow anonymous functions to be arguments or returned from injectors.

Most of the time, this does not matter because you generally do not need to pass functions around inside an injection chain. Just let functions run.

If you do need to pass a function, you still can, but you have to give it a named typed.

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Extra

func Extra(pointersAndInjectors ...interface{}) nject.Provider

Extra is a means to obtain more than one of something needed for a test.

The extra bits may be need to be created in the middle of a pre-made injection sequence. The easiest way to handle that is to use nject.Provide() to name the injectors in the injection chain. Then you can use nject.InsertAfterNamed() to wrap the Extra() to "move" the effective location of the Extra.

Alternatively, you can avoid the pre-made injection sequences so that you explicitly add Extra in the middle.

The arguments to Extra can be two different kinds of things. First put pointers to variables that you want extra of. Then put any additional injectors that might be needed to create those things. You can nest calls to Extra inside call to Extra if you want to share some comment components.

func RunMatrix

func RunMatrix(t *testing.T, chain ...any)

RunMatrix uses t.Run() separate execution for each sub-test before any chains are evaluated. This forces the chains to share nothing between them. RunMatrix does not provide any default injectors other than a *testing.T that comes from a named provider (named "testing.T")

A matrix is a specific type: map[string]nject.Provider. Add those to the chain to trigger matrix testing.

Matrix values must be direct arguments to RunMatrix -- they will not be extracted from nject.Sequences. RunMatrix will fail if there is no matrix provided.

func RunParallelMatrix

func RunParallelMatrix(t *testing.T, chain ...any)

RunParallelMatrix uses t.Run() to fork into multiple threads of execution for each sub-test before any chains are evaluated. This forces the chains to share nothing between them. RunParallelMatrix does not provide any default injectors other than a *testing.T that comes from a named provider (named "testing.T")

A matrix is a specific type: map[string]nject.Provider. Add those to the chain to trigger matrix testing.

t.Parallel() is used for each t.Run()

A warning about t.Parallel(): inner tests wait until outer tests finish. See https://go.dev/play/p/ZDaw054HeIN

Matrix values must be direct arguments to RunMatrix -- they will not be extracted from nject.Sequences. RunParallelMatrix will fail if there is no matrix provided.

func RunTest

func RunTest(t T, chain ...interface{})

RunTest provides the basic framework for running a test.

If running a testing.T test, pass that. If running a Ginkgo test, pass ginkgo.GinkgoT().

Types

type T

type T interface {
	Cleanup(func())
	Setenv(key, value string)
	Error(args ...interface{})
	Errorf(format string, args ...interface{})
	FailNow()
	Failed() bool
	Fatal(args ...interface{})
	Fatalf(format string, args ...interface{})
	Helper()
	Log(args ...interface{})
	Logf(format string, args ...interface{})
	Name() string
	Skip(args ...interface{})
	Skipf(format string, args ...interface{})
	Skipped() bool
}

T is subset of what testing.T provides and is also a subset of of what ginkgo.GinkgoT() provides. This interface is probably richer than strictly required so more could be removed from it (or more added).

func ExtraDetailLogger added in v0.3.0

func ExtraDetailLogger(t T, prefix string) T

ExtraDetailLogger creates a T that wraps the logger to add both a prefix and a timestamp to each line that is logged.

func ReplaceLogger added in v0.3.0

func ReplaceLogger(t T, logger func(string)) T

ReplaceLogger creates a T that is wrapped so that the logger is overridden with the provided function.

Jump to

Keyboard shortcuts

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