ntest

package module
v0.8.0 Latest Latest
Warning

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

Go to latest
Published: Jun 30, 2025 License: MIT Imports: 12 Imported by: 1

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.

Logging utilities

Ntest provides several utilities for controlling test logging behavior:

ReplaceLogger

ReplaceLogger creates a wrapped T that overrides the logging function. This is useful for capturing or modifying log output.

Important: For accurate line number reporting in log output, always call t.Helper() at the beginning of your logger function:

logger := ntest.ReplaceLogger(t, func(s string) {
    t.Helper() // Mark this function as a helper for accurate line numbers
    t.Log("PREFIX: " + s)
})
BufferedLogger

BufferedLogger creates a logger that buffers log output and only displays it if the test fails. This helps keep passing test output clean while still providing debugging information when tests fail.

buffered := ntest.BufferedLogger(t)
buffered.Log("This will only appear if the test fails")
ExtraDetailLogger

ExtraDetailLogger creates a logger wrapper that adds a prefix and timestamp to each log line. It automatically calls t.Helper() for accurate line number reporting.

detailed := ntest.ExtraDetailLogger(t, "MyTest")
detailed.Log("message") // Output: "MyTest 15:04:05 message"

These utilities can be combined. For example, you can create a buffered logger with extra detail:

buffered := ntest.BufferedLogger(t)
detailed := ntest.ExtraDetailLogger(buffered, "MyTest")
Run function

The Run function provides a convenient way to run subtests that works with both *testing.T and *testing.B, automatically handling ReWrap logic for logger wrappers:

success := ntest.Run(t, "subtest", func(subT ntest.T) {
    subT.Log("This works with both testing.T and testing.B")
    // subT will automatically be rewrapped if t was a wrapped logger
})

This is particularly useful when you have wrapped loggers (like BufferedLogger or ExtraDetailLogger) and want to run subtests while preserving the logger behavior. The Run function automatically calls ReWrap on logger wrappers to ensure the subtest gets a properly wrapped logger instance.

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.Fail()) 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.

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 MustParallel added in v0.8.0

func MustParallel(t T)

MustParallel calls .Parallel() on the underlying T if it supports .Parallel. If not, it fails the test. If the input T is a ReWrapper then it will be unwrapped to find a T that supports Parallel.

func Parallel added in v0.8.0

func Parallel(t T)

Parallel calls .Parallel() on the underlying T if it supports .Parallel. If not, it logs a warning and continues without Parallel. If the input T is a ReWrapper then it will be unwrapped to find a T that supports Parallel.

func Run added in v0.8.0

func Run(t T, name string, f func(T)) bool

Run is a helper that runs a subtest and automatically handles ReWrap logic. This should be used instead of calling t.Run directly when using logger wrappers like ReplaceLogger, BufferedLogger, or ExtraDetailLogger that support the ReWrapper interface.

Key benefits: - Works with both *testing.T and *testing.B (they have different Run signatures) - Automatically rewraps logger wrappers for subtests via ReWrap interface - Can be used with any T, whether wrapped or not

Example:

logger := ntest.BufferedLogger(t)
ntest.Run(logger, "subtest", func(subT ntest.T) {
    // subT is automatically a properly wrapped BufferedLogger
    subT.Log("This will be buffered correctly")
})

func RunMatrix

func RunMatrix(t 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.

The provided T must support Run()

func RunParallelMatrix

func RunParallelMatrix(t 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"). That injector is only present if the RunT argument was actually a *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.

The provided T must support Run()

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 Cancel added in v0.4.0

type Cancel func()

Cancel is the injected type for the function type that will cancel a Context that has been augmented with AutoCancel.

func AutoCancel added in v0.4.0

func AutoCancel(ctx context.Context, t T) (context.Context, Cancel)

AutoCancel adjusts context.Context so that it will be cancelled when the test finishes. It can be cancelled early by calling the returned Cancel function.

type ReWrapper added in v0.7.0

type ReWrapper interface {
	T
	// ReWrap must return a T that is wrapped (with the current class) compared to it's input
	// This is re-applying the wrapping to get back to the type of the ReWrapper.
	// ReWrap only needs to care about it's own immediate wrapping. It does not need to
	// check if it's underlying type implements ReWrapper.
	ReWrap(T) T
	// Unwrap must return a T that is unwrapped compared to the ReWrapper.
	// This is providing access to the inner-T
	Unwrap() T
}

ReWrapper allows types that wrap T to recreate themselves from fresh T This, combined with Run() and Parallel(), allows proper subtest handling in tests that wrap T.

type T

type T interface {
	Cleanup(func())
	Setenv(key, value string)
	Error(args ...interface{})
	Errorf(format string, args ...interface{})
	Fail()
	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.

It is missing:

.Run - not present in ginkgo.GinkgoT()
.Parallel - not present in *testing.B

func BufferedLogger added in v0.6.0

func BufferedLogger[ET T](t ET) T

BufferedLogger creates a logger wrapper that buffers all log output and only outputs it during test cleanup if the test failed. Each log entry includes the filename and line number where the log was called. The purpose of this is for situations where go tests are defaulting to -v but output should be suppressed anyway.

If the environment variable NTEST_BUFFERING is set to "false", buffering will be turned off and the original T will be returned directly.

One advantage of using BufferedLogger over using "go test" (without -v) is that you can see the skipped tests with BufferedLogger whereas non-v go test hides the skips.

func ExtraDetailLogger added in v0.3.0

func ExtraDetailLogger[ET T](t ET, prefix string) T

ExtraDetailLogger creates a logger wrapper that adds both a prefix and a timestamp to each line that is logged. A space after the prefix is also added.

func ReplaceLogger added in v0.3.0

func ReplaceLogger[ET T](t ET, logger func(string)) T

ReplaceLogger creates a wrapped T that overrides the logging function. For accurate line number reporting in log output, call t.Helper() at the beginning of your logger function to mark it as a helper function.

Example:

logger := ntest.ReplaceLogger(t, func(s string) {
    t.Helper() // Mark this function as a helper for accurate line numbers
    t.Log("PREFIX: " + s)
})

Jump to

Keyboard shortcuts

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