Testing
app := dygotest.New(AppModule). OverrideValue(dygo.TypeOf[users.Repo](), &fakeRepo{}). Compile(t) // fails the test on boot error, shuts down on cleanupSee 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.
Unit test a provider
Section titled “Unit test a provider”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.
Test a module’s wiring
Section titled “Test a module’s wiring”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.
Test HTTP routes
Section titled “Test HTTP routes”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.
Overrides
Section titled “Overrides”| 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.
Test guards, interceptors and pipes alone
Section titled “Test guards, interceptors and pipes alone”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.
Catch wiring errors in CI
Section titled “Catch wiring errors in CI”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:
go vet ./... && go test -race ./... && dygo checkEnd to end
Section titled “End to end”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.
Fuzz your inputs
Section titled “Fuzz your inputs”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) } })}Testing and benchmarks
Section titled “Testing and benchmarks”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.
Where things live
Section titled “Where things live”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 |
Two levels of tests
Section titled “Two levels of tests”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) |
Running tests
Section titled “Running tests”make test-unit # unit tests only, every module: fast, no servicesmake test # everything, skipping integration tests whose services are missingmake test-integration # starts compose.test.yml, then everything, nothing skippedmake services-down # stops the services and drops their dataTo run one package or one test, use go test directly:
go test -short ./cachego test . -run TestDurableProvidersPerTenantcd sql && DYGO_TEST_POSTGRES_URL=... go test ./postgresgo test ./cmd/dygo -update # rewrite the CLI golden files after a template changeCI 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.
How tests are written
Section titled “How tests are written”- Black box first. Test files use
package foo_testand the public API. A test that needs unexported code goes infoo_internal_test.gowithpackage 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 withdygotest.New(module), and useCompileHTTPwithsrv.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.
Coverage
Section titled “Coverage”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/.
Two kinds of performance tests
Section titled “Two kinds of performance tests”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.
Running benchmarks
Section titled “Running benchmarks”make bench # every micro benchmark in every module, oncemake bench BENCH=Pipeline # only the ones matching a regexpmake bench-save # 10 runs, saved to benchmarks/results/dygo-<date>-<go>.txtmake 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>.txtThe usual way to check a change for speed:
- On
main, runmake bench-save(or keep the file saved last time). - On your branch, run
make bench-diffand read thevs basecolumn. - If the change is meant to move the numbers, commit a new
make bench-saveresult 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.