Home

Awesome

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)

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 type.