modtest

package
v0.9.0 Latest Latest
Warning

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

Go to latest
Published: Aug 9, 2026 License: MIT Imports: 7 Imported by: 0

Documentation

Overview

Package modtest provides reusable, composable test helpers that verify a modulex.Module (or a small group of them) against Modulex's lifecycle contract: Init/Start ordering, rollback on failure, cancellation and deadline handling, health/readiness registration, and resource ownership.

A module author calls these helpers from their own *_test.go file, in the standard testing.T idiom — modtest is not a custom test runner or DSL:

func TestBillingModuleLifecycle(t *testing.T) {
    modtest.AssertLifecycleOrder(t, billing.NewModule())
}

Every assertion helper here constructs its own private *modulex.Manager (or otherwise drives the module directly); none of them share state, so they can be called from independent test functions or subtests without interfering with one another.

Genericity

Five of the six lifecycle properties this package covers are fully generic: they work against any modulex.Module without requiring the module under test to expose anything beyond the standard Module/Starter/Stopper interfaces:

  • Lifecycle ordering (AssertLifecycleOrder)
  • Rollback (AssertRollbackOnInitFailure, AssertRollbackOnStartFailure)
  • Cancellation (AssertRespectsCancellation)
  • Deadlines (AssertRespectsDeadline)
  • Health/readiness (AssertHealthCheck, AssertReadinessCheck)

The sixth, resource ownership (AssertResourceOwnership), is NOT fully generic: Modulex's Module interface has no way to introspect what a module acquired during Init/Start, so the caller must supply a ResourceOwner — typically the concrete adapter instance backing the module — that reports whether the resource has been released. See AssertResourceOwnership's doc comment for the exact requirement.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func AssertHealthCheck

func AssertHealthCheck(t TB, mod modulex.Module, name string, wantErr bool)

AssertHealthCheck registers mod on a fresh *modulex.Manager, calls InitModules (where a well-behaved module registers its health checks via modulex.HealthCheckRegisterer.RegisterHealthCheck), then looks up the health check named name and runs it with context.Background().

It fails the test (via t.Fatalf) if no health check named name was registered during Init, or (via t.Errorf) if the check's result does not match wantErr: wantErr == true expects the check to return a non-nil error (an induced-unhealthy scenario); wantErr == false expects it to return nil.

This is fully generic: it requires no cooperation from mod beyond calling reg.RegisterHealthCheck(name, ...) during Init, which is the standard way any module registers a liveness check.

func AssertLifecycleOrder

func AssertLifecycleOrder(t TB, mods ...modulex.Module)

AssertLifecycleOrder registers mods (wrapped with an OrderRecorder) on a fresh *modulex.Manager, drives InitModules, StartModules, and StopModules with context.Background(), and asserts:

  • Every module's Init was recorded.
  • A module's Start (if it implements modulex.Starter) happened after its own Init.
  • A module's Stop (if it implements modulex.Stopper) happened after its own Start (or after its own Init, if it does not implement Starter).
  • For every dependency edge declared via DependsOn, the dependency's Init happened before the dependent's Init (dependency-first startup ordering), and the dependency's Stop happened after the dependent's Stop (reverse, dependent-first, teardown ordering) — whenever both sides recorded that phase.

This is fully generic: it requires no cooperation from mods beyond the standard modulex.Module/Starter/Stopper interfaces. It detects both startup-ordering regressions (a module starting before its dependencies finished initializing) and shutdown-ordering regressions (a dependency torn down before the modules that depend on it).

AssertLifecycleOrder fails the test (via t.Errorf, so all violations are reported rather than stopping at the first) if InitModules, StartModules, or StopModules return an unexpected error, or if any ordering invariant above is violated.

func AssertReadinessCheck

func AssertReadinessCheck(t TB, mod modulex.Module, name string, wantErr bool)

AssertReadinessCheck registers mod on a fresh *modulex.Manager, calls InitModules (where a well-behaved module registers its readiness checks via modulex.ReadinessRegisterer.RegisterReadinessCheck), then looks up the readiness check named name and runs it with context.Background().

It fails the test (via t.Fatalf) if no readiness check named name was registered during Init, or (via t.Errorf) if the check's result does not match wantErr: wantErr == true expects the check to return a non-nil error (an induced-not-ready scenario); wantErr == false expects it to return nil.

This is fully generic: it requires no cooperation from mod beyond calling reg.RegisterReadinessCheck(name, ...) during Init, which is the standard way any module registers a readiness check.

func AssertResourceOwnership

func AssertResourceOwnership(t TB, mod modulex.Module, owner func() ResourceOwner)

AssertResourceOwnership drives mod through Init, Start (if mod implements modulex.Starter), and Stop on a fresh *modulex.Manager, calling owner after each phase to verify the resource mod acquired is released exactly when expected:

  • If owner() already returns a non-nil ResourceOwner before Init runs, it must report Closed() == false (a sanity check on the caller's setup).
  • owner() must return a non-nil ResourceOwner reporting Closed() == false immediately after Init, and again after Start if mod implements modulex.Starter (the resource must stay open while the module is running).
  • mod must implement modulex.Stopper — AssertResourceOwnership fails via t.Fatalf if it does not, since there would be no Stop call to verify released the resource.
  • owner() must return a non-nil ResourceOwner reporting Closed() == true after Stop returns.

owner is a function, rather than a plain ResourceOwner value, because a module very commonly constructs its own adapter lazily inside Init rather than receiving one the caller already holds — the classic hexagonal layout this package's sibling scaffolding tool generates does exactly this (see module.go's Init in a generated module, which builds its adapters.InMemoryRepository internally and exposes it via a Repository() accessor for tests). owner lets AssertResourceOwnership support both styles:

  • An adapter the caller constructs and injects (owner just closes over that pre-existing value): func() modtest.ResourceOwner { return repo }
  • An adapter the module constructs internally during Init, retrieved via an accessor: func() modtest.ResourceOwner { return mod.Repository() } — owner() naturally returns nil before Init runs, in which case the "before Init" sanity check above is skipped.

This is the one helper in modtest that is not fully generic: Modulex has no way to discover a module's owned resources on its own, so the caller must supply owner and whatever it returns must implement Closed() bool.

func AssertRespectsCancellation

func AssertRespectsCancellation(t TB, mod modulex.Module, phase Phase, grace time.Duration)

AssertRespectsCancellation asserts that mod's Init, Start, or Stop (selected by phase) returns promptly once its context is cancelled mid-call.

It drives whatever earlier lifecycle phases are needed (see setupForPhase) with an uncancellable context, then invokes the phase under test in a separate goroutine with a context that is cancelled immediately after the call is made. If the call has not returned within grace after cancellation, AssertRespectsCancellation fails the test via t.Errorf, reporting a likely cancellation regression.

Note: AssertRespectsCancellation drives mod's lifecycle method directly rather than through Manager.InitModules/StartModules/StopModules. This is deliberate: those Manager methods only check ctx.Err() BEFORE invoking each module in sequence, so calling them with an already-cancelled context would abort before ever calling the module under test's method — a false pass that would never actually exercise the module's own cancellation handling. Calling the method directly is the only way to genuinely probe whether a specific module's Init/Start/Stop observes ctx.Done() while it is running.

This is fully generic: it requires no cooperation from mod beyond the standard modulex.Module/Starter/Stopper interfaces, though the module must actually block on something for cancellation to have anything to interrupt — a module whose Init/Start/Stop always returns quickly regardless of ctx trivially "passes" this assertion without ever being meaningfully exercised.

func AssertRespectsDeadline

func AssertRespectsDeadline(t TB, mod modulex.Module, phase Phase, deadline, grace time.Duration)

AssertRespectsDeadline asserts that mod's Init, Start, or Stop (selected by phase) returns promptly once its context's deadline elapses.

It behaves like AssertRespectsCancellation, except the context under test is given a fixed deadline (context.WithTimeout(ctx, deadline)) instead of being cancelled explicitly, and the grace period is measured from when the deadline elapses. See AssertRespectsCancellation's doc comment for why this drives mod's lifecycle method directly rather than through the Manager, and for the same genericity caveat (the module must actually block on something past the deadline for this to be a meaningful check).

func AssertRollbackOnInitFailure

func AssertRollbackOnInitFailure(t TB, modUnderTest modulex.Module)

AssertRollbackOnInitFailure registers modUnderTest alongside a harness-provided module that depends on it and always fails Init, drives InitModules on a fresh *modulex.Manager, and asserts:

  • InitModules returns an error wrapping the induced failure.
  • If modUnderTest implements modulex.Stopper, its Stop was invoked during the resulting rollback (Modulex stops successfully initialized modules in reverse order when a later module's Init fails).

If modUnderTest does not implement modulex.Stopper, there is nothing to verify for cleanup and AssertRollbackOnInitFailure logs that fact via t.Logf rather than failing.

This is fully generic: it requires no cooperation from modUnderTest beyond the standard modulex.Module/Stopper interfaces.

func AssertRollbackOnStartFailure

func AssertRollbackOnStartFailure(t TB, modUnderTest modulex.Module)

AssertRollbackOnStartFailure registers modUnderTest alongside a harness-provided module that depends on it and always fails Start, drives InitModules (expected to succeed) then StartModules (expected to fail) on a fresh *modulex.Manager, and asserts:

  • StartModules returns an error wrapping the induced failure.
  • If modUnderTest implements modulex.Stopper, its Stop was invoked during the resulting rollback (Modulex stops successfully started modules in reverse order when a later module's Start fails; a module that does not implement modulex.Starter still counts as "successfully started" and is stopped like any other).

If modUnderTest does not implement modulex.Stopper, there is nothing to verify for cleanup and AssertRollbackOnStartFailure logs that fact via t.Logf rather than failing.

This is fully generic: it requires no cooperation from modUnderTest beyond the standard modulex.Module/Starter/Stopper interfaces.

func Boot

func Boot(t TB, mods ...modulex.Module) *modulex.Manager

Boot registers mods on a fresh *modulex.Manager, drives InitModules then StartModules with context.Background(), and fails the test immediately (via t.Fatalf) if either phase returns an error. It registers a t.Cleanup that calls StopModules so the manager is always torn down, and returns the running Manager for further inspection (HealthChecks, ReadinessChecks, ExportDAG, ModuleContract, ResolveService, and so on).

Boot is a convenience for ad hoc assertions beyond the six covered by this package's Assert* helpers; those helpers do not use Boot themselves because most of them need finer control over which lifecycle phase runs and when.

func NewFailingModule

func NewFailingModule(name string, deps []string, phase Phase, err error) modulex.Module

NewFailingModule returns a modulex.Module named name, depending on deps, whose Init or Start (selected by phase, which must be PhaseInit or PhaseStart) always returns err. The other lifecycle method is a no-op returning nil. It is exported so callers can build custom rollback scenarios beyond what AssertRollbackOnInitFailure/ AssertRollbackOnStartFailure construct automatically — for example, inducing the failure in a module that depends on more than one module under test at once.

func Wrap

func Wrap(mod modulex.Module, rec *OrderRecorder) modulex.Module

Wrap returns a modulex.Module that delegates to mod but records every Init/Start/Stop call to rec before calling through. The returned module implements exactly the optional lifecycle interfaces (modulex.Starter, modulex.Stopper) that mod itself implements, so wrapping does not change how a Manager treats the module (e.g. Wrap-ing a module that has no Start method does not cause the Manager to call a no-op Start on it).

Wrap is non-invasive: it requires no changes to the module under test's own code.

Types

type Event

type Event struct {
	// Module is the name reported by modulex.Module.Name for the module the
	// event pertains to.
	Module string
	// Phase is the lifecycle method that was invoked ("Init", "Start", or
	// "Stop").
	Phase string
}

Event records that a module's lifecycle method was invoked, in the order the OrderRecorder observed it.

type OrderRecorder

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

OrderRecorder records the order in which wrapped modules' lifecycle methods are invoked by a modulex.Manager. Use Wrap to attach a recorder to a module under test, drive the module through a Manager as usual, then inspect Events or use Index to make assertions about call order.

An OrderRecorder is safe for concurrent use, though Modulex itself invokes Init/Start/Stop sequentially per phase, so concurrent recording is not normally exercised.

func NewOrderRecorder

func NewOrderRecorder() *OrderRecorder

NewOrderRecorder creates an empty OrderRecorder.

func (*OrderRecorder) Events

func (r *OrderRecorder) Events() []Event

Events returns a snapshot of every event recorded so far, in the order they were observed.

func (*OrderRecorder) Index

func (r *OrderRecorder) Index(module, phase string) int

Index returns the position (0-based, in overall recording order) of the first event matching module and phase, or -1 if no such event was recorded. phase should be one of "Init", "Start", or "Stop" (see Phase's String method).

type Phase

type Phase int

Phase identifies one of the three lifecycle methods a modulex.Module may implement: Init (always present), Start (optional, via modulex.Starter), and Stop (optional, via modulex.Stopper).

const (
	// PhaseInit identifies modulex.Module.Init.
	PhaseInit Phase = iota
	// PhaseStart identifies modulex.Starter.Start.
	PhaseStart
	// PhaseStop identifies modulex.Stopper.Stop.
	PhaseStop
)

func (Phase) String

func (p Phase) String() string

String returns a lower-case name for p, or "unknown" for an out-of-range value.

type ResourceOwner

type ResourceOwner interface {
	// Closed reports whether the resource has been released.
	Closed() bool
}

ResourceOwner reports whether a resource has been released. A module's adapter (an in-memory repository, a database handle, a connection pool, ...) implements this — typically alongside its own domain-specific methods — so AssertResourceOwnership can verify it structurally, without the adapter's package importing modtest.

Modulex's modulex.Module interface has no generic way to introspect what a module acquired during Init/Start, so this is the one place in modtest that is NOT fully generic: the module author must expose (or adapt) a Closed() bool on whatever resource they want verified. See AssertResourceOwnership's doc comment for the full requirement.

type TB

type TB interface {
	Helper()
	Errorf(format string, args ...any)
	Fatalf(format string, args ...any)
	Logf(format string, args ...any)
	Cleanup(func())
}

TB is the subset of testing.TB that this package's Assert* helpers and Boot use. Every *testing.T and *testing.B already implements TB, so real callers pass their test's *testing.T exactly as they would to any other helper — TB exists as its own type (rather than these helpers taking testing.TB directly) because testing.TB has an unexported method that only the standard library's own *testing.T/*testing.B can implement, which would make it impossible for modtest's own test suite to verify that a helper correctly reports a failure without a fake recorder. See modtest's internal fakeT (in its test files) for that recorder.

Jump to

Keyboard shortcuts

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