Skip to content

Testing

app := dygotest.New(AppModule).
OverrideValue(dygo.TypeOf[users.Repo](), &fakeRepo{}).
Compile(t) // fails the test on boot error, shuts down on cleanup

See examples/worker for all of this working together.

Dygo apps are tested with the standard testing package. The dygotest package boots a module the way dygo.Create does, with overrides, and shuts it down when the test ends.

A provider is a constructor, so the simplest test does not need Dygo at all:

func TestPricing(t *testing.T) {
svc := orders.NewPricing(fakeRates{"EUR": 1.1})
if got := svc.Total(order); got != 110 {
t.Fatalf("total = %d", got)
}
}

Reach for dygotest when you want the wiring as well.

func TestWiring(t *testing.T) {
app := dygotest.New(orders.Module).
OverrideValue(dygo.TypeOf[orders.Repo](), &fakeRepo{}).
Compile(t) // fails the test on a boot error, closes the app on cleanup
svc := dygo.MustGet[*orders.Service](app)
// ...
}

Compile reports every wiring problem at once, with the module path, just as the real boot would.

CompileHTTP gives a small client for the app’s handler, with no network:

func TestCreateOrder(t *testing.T) {
srv := dygotest.New(orders.Module).
With(validate.Use(validate.Methods())). // the same options as main
CompileHTTP(t)
var o orders.Order
srv.POST("/orders").JSON(map[string]any{"sku": "pen", "qty": 2}).
Do(t).Status(t, 201).JSON(t, &o)
srv.GET("/orders/" + o.ID).Do(t).Status(t, 200)
srv.POST("/orders").JSON(map[string]any{"qty": -1}).Do(t).Status(t, 400)
}

Keep the app’s options in one function (AppOptions() in a dygo new -t modular project) and pass them with With(...), so tests and main use the same global prefix, pipes and guards.

Need Use
replace a provider with a fixed value OverrideValue(dygo.TypeOf[T](), v)
replace it with another constructor OverrideProvider(dygo.TypeOf[T](), dygo.Provide(NewFake))
replace a named token OverrideValue(myToken, v)
test a few providers without a module dygotest.Module([]dygo.Provider{...}, imports...)
fixed config config.Env(map[string]string{...}) in config.Load or config.ForRoot

An override replaces a token that some module already provides. It cannot add one. When a feature module depends on a global module that the app’s root module imports (a database, config, auth), the test root must import it too:

root := &dygo.Module{Name: "test", Imports: []*dygo.Module{database.Module, users.Module}}
srv := dygotest.New(root).
OverrideValue(dygo.TypeOf[*database.Config](), &database.Config{}).
OverrideValue(dygo.TypeOf[*database.DB](), &database.DB{}).
CompileHTTP(t)

Every singleton is built at boot, including the ones nothing uses, so overriding a provider does not skip its config: override the config as well, as above. Overridden values still get their lifecycle hooks.

They are interfaces with one method, so call them directly with a small fake ExecutionContext that embeds the interface and implements only what the enhancer uses:

type fakeEC struct {
dygo.ExecutionContext
ctx context.Context
req *http.Request
}
func (f *fakeEC) Context() context.Context { return f.ctx }
func (f *fakeEC) SetContext(ctx context.Context) { f.ctx = ctx }
func (f *fakeEC) HTTP() dygo.HTTPHost { return f }
func (f *fakeEC) Request() *http.Request { return f.req }
func (f *fakeEC) Response() http.ResponseWriter { return httptest.NewRecorder() }

For anything that reads route metadata, test through a route with CompileHTTP instead: metadata is attached at registration.

dygo check boots the application’s wiring without starting servers and fails with the same messages as a real boot. Add it next to the tests:

Terminalsh
go vet ./... && go test -race ./... && dygo check

The app_test.go that dygo new writes boots the whole AppModule with test config. Keep one test like it, so a broken import in any module fails CI and not the deploy.

Bind errors are part of the contract: a bad path, query or body must be a 400, never a 500. A Go fuzz test against a route finds the cases you did not think of:

func FuzzCreateOrder(f *testing.F) {
srv := dygotest.New(orders.Module).CompileHTTP(f)
f.Add(`{"sku":"pen","qty":2}`)
f.Fuzz(func(t *testing.T, body string) {
res := srv.POST("/orders").Body(strings.NewReader(body), "application/json").Do(t)
if res.StatusCode >= 500 {
t.Fatalf("%d for %q", res.StatusCode, body)
}
})
}

This page is about testing Dygo itself: where tests and benchmarks live, how they are split, and the make targets that run them. The helpers it uses, dygotest.Integration, RequireEnv and RequireCommand, work the same way in an app built with Dygo.

Tests sit next to the code they test, as Go expects. Nothing lives in a separate tests/ tree.

What Where
Unit and integration tests *_test.go next to the code
Micro benchmarks bench_test.go next to the code
Shared test data testdata/ in the package (CLI golden files: cmd/dygo/testdata/golden)
Test helpers for apps dygotest/ (dygotest.New, the HTTP helpers, the integration helpers)
Test helpers inside one module an internal/ package, such as sql/internal/suite
Comparison with other frameworks the benchmarks/ module
Saved benchmark results benchmarks/results/
Services for integration tests compose.test.yml

Unit tests are fast and need nothing outside the process. Most tests are unit tests. They run with go test -short.

Integration tests need something from outside: a database, a redis-server, or a go build of a project the CLI generated. Each one starts with a helper from dygotest, so it skips itself under -short and when what it needs is missing:

func TestPostgres(t *testing.T) {
url := dygotest.RequireEnv(t, "DYGO_TEST_POSTGRES_URL") // skips if unset
// ...
}
func TestServiceOverRedis(t *testing.T) {
bin := dygotest.RequireCommand(t, "redis-server") // skips if not on PATH
// ...
}
func TestNewProject(t *testing.T) {
dygotest.Integration(t) // builds real programs: skipped with -short
// ...
}

Integration tests are plain tests, not hidden behind build tags, so go vet, the linter and your editor always see them.

The environment variables are:

Variable Used by Value from compose.test.yml
DYGO_TEST_POSTGRES_URL sql/postgres, sql/migrate, cmd/dygo postgres://postgres:dygo@localhost:5432/dygo?sslmode=disable
DYGO_TEST_MYSQL_URL sql/mysql, sql/migrate, cmd/dygo mysql://root:[email protected]:3306/dygo
DYGO_TEST_REDIS_ADDR queue/asynq, micro/redis 127.0.0.1:6379 (without it, the tests start a redis-server if one is installed)
Terminalsh
make test-unit # unit tests only, every module: fast, no services
make test # everything, skipping integration tests whose services are missing
make test-integration # starts compose.test.yml, then everything, nothing skipped
make services-down # stops the services and drops their data

To run one package or one test, use go test directly:

Terminalsh
go test -short ./cache
go test . -run TestDurableProvidersPerTenant
cd sql && DYGO_TEST_POSTGRES_URL=... go test ./postgres
go test ./cmd/dygo -update # rewrite the CLI golden files after a template change

CI runs make test-unit on two Go versions, and make cover (all tests, with coverage) with Postgres, MySQL and Redis on two database versions. The coverage goes to Coveralls. Run make cover locally for the same total.

  • Black box first. Test files use package foo_test and the public API. A test that needs unexported code goes in foo_internal_test.go with package foo.
  • Table driven. Cases that differ only by input and expected output go in a slice of structs, run with t.Run(tt.name, ...).
  • Apps through dygotest. Build a test app with dygotest.New(module), and use CompileHTTP with srv.GET(...).Do(t).Status(t, 200) for HTTP.
  • One topic per file. Name test files after what they test (writers_test.go, response_test.go), not after a coverage goal.

make cover prints the total for every module together. The project keeps it above 80%, and new or changed code should be covered more than 80% too.

Generated code and helpers that only tests use are left out of the total: graphql/internal/testgraph (made by gqlgen) and sql/internal/suite (the shared database test suite), as well as examples/.

Micro benchmarks measure one piece of Dygo: the pipeline, the cache interceptor, a WebSocket broadcast. They live in a bench_test.go file next to the code. Add one when you change a hot path, and use b.Loop() and b.ReportAllocs().

The comparison in benchmarks/ runs the same four requests on Dygo, bare net/http, chi, Gin and Huma. It is its own Go module, so those frameworks never become dependencies of Dygo. See the benchmarks README.

Terminalsh
make bench # every micro benchmark in every module, once
make bench BENCH=Pipeline # only the ones matching a regexp
make bench-save # 10 runs, saved to benchmarks/results/dygo-<date>-<go>.txt
make bench-diff # 10 runs now, compared with the newest saved file (benchstat)
make bench-compare # Dygo vs the other frameworks, saved as compare-<date>-<go>.txt

The usual way to check a change for speed:

  1. On main, run make bench-save (or keep the file saved last time).
  2. On your branch, run make bench-diff and read the vs base column.
  3. If the change is meant to move the numbers, commit a new make bench-save result with it.

Benchmark numbers only compare well on the same machine, so compare runs from one machine, and close other programs while they run.

CI runs make bench-smoke: every benchmark once, so a broken benchmark fails the build. It does not check speed.