Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

9. Testing the API

A useful test suite for a Maniflex app exercises the HTTP layer, not just the database. The supported maniflextest module starts a real HTTP server, migrates a fresh in-memory SQLite database, and registers all cleanup with the test:

go get github.com/xaleel/maniflex/maniflextest

A test server

The harness takes the same models and setup function as the application. Calls such as POST("/widgets", ...) are relative to the configured API prefix (/api by default).

func TestHarness(t *testing.T) {
	server := maniflextest.New(t, maniflextest.Options{
		Models: []any{Widget{}},
		Setup: func(app *maniflex.Server) {
			app.Pipeline.Auth.Register(requireTestUser)
		},
	})

	server.POST("/widgets", map[string]any{"name": "unauthenticated"}).
		AssertStatus(http.StatusUnauthorized)

	created := server.POST(
		"/widgets",
		map[string]any{"name": "first"},
		maniflextest.As(maniflextest.Human("user-42", "editor")),
	).AssertStatus(http.StatusCreated)

	widget := maniflextest.DecodeData[Widget](created)
	if widget.Name != "first" || widget.ID == "" {
		t.Fatalf("unexpected widget: %+v", widget)
	}
}

maniflextest.New completes the repetitive integration-test work:

  • registers the supplied models and application setup;
  • opens a unique in-memory SQLite database and runs MigrateOnly;
  • starts an httptest.Server;
  • shuts down services, drops test data, and closes the adapter automatically.

Each call to New is isolated, including separate calls in nested t.Run tests. Use server.App() when a worker or background operation needs the underlying *maniflex.Server.

Test principals

maniflextest.As injects a complete maniflex.AuthInfo before the application’s Auth middleware runs:

admin := maniflextest.Human("user-42", "admin")
response := server.POST(
    "/orders",
    map[string]any{"book_id": bookID},
    maniflextest.As(admin),
)
response.AssertStatus(http.StatusCreated)

Use ServiceAccount(id, scopes...) for machine callers. For tenant, claim, or session-specific tests, construct maniflex.AuthInfo directly and pass it to As.

The injected principal is only a test transport; it is not production authentication. Set DisableTestAuth: true and use maniflextest.Bearer when testing a real JWT or API-key middleware end to end.

Typed responses

Responses are buffered so status, headers, raw bytes, envelopes, and typed models can all be asserted:

created := server.POST("/books", input).AssertStatus(http.StatusCreated)
book := maniflextest.DecodeData[models.Book](created)

books := maniflextest.DecodeDataList[models.Book](
    server.GET("/books?sort=title").AssertStatus(http.StatusOK),
)

For error paths, use response.ErrorCode():

response := server.POST("/users", map[string]any{"name": "Alice"})
response.AssertStatus(http.StatusUnprocessableEntity)
if code := response.ErrorCode(); code != "VALIDATION_ERROR" {
    t.Fatalf("error code: got %q", code)
}

Fixtures and factories

Fixtures are ordinary API creates, so they pass through validation, middleware, and hooks instead of inserting a database shape the application could never produce:

fixtures := server.Seed(
    maniflextest.Fixture{
        Name: "alice",
        Path: "/users",
        Body: map[string]any{"name": "Alice", "email": "[email protected]"},
    },
)
aliceID := fixtures.ID(t, "alice")

Use a factory for repeated data:

books := maniflextest.Factory(
    "book",
    "/books",
    10,
    func(i int) map[string]any {
        return map[string]any{"title": fmt.Sprintf("Book %02d", i)}
    },
    maniflextest.As(maniflextest.Human("author-1")),
)
fixtures := server.Seed(books...)
firstID := fixtures.ID(t, "book[0]")

Names are stable (book[0], book[1], and so on), making relationships easy to express without relying on insertion order or hard-coded IDs.

PostgreSQL-specific tests

Most application behaviour should use the fast SQLite default. When SQL dialect, locking, or transaction semantics matter, give the harness a PostgreSQL DSN:

server := maniflextest.New(t, maniflextest.Options{
    Models:   appModels(),
    Setup:    registerApplication,
    Database: maniflextest.Postgres(os.Getenv("MANIFLEX_TEST_PG_DSN")),
})

Every server receives a randomly named schema. Cleanup drops the entire schema, so tests do not share rows and there is no truncation list to maintain. The database user must be allowed to create and drop schemas.

Contention and workers

For a contention test, create one harness server and send concurrent requests through it. Do not call t.Fatal from request goroutines; collect each response’s StatusCode and assert from the test goroutine after they join.

Set StartServices: true when the behaviour under test needs registered services or lifecycle hooks. Prefer a one-shot worker entry point where possible; otherwise make the worker accept a context.Context and stop it before the test returns. Harness cleanup calls Server.Shutdown, so framework background work is drained rather than abandoned.

See Testing Applications for the complete supported surface and customization points.

Coverage strategy

For a typical bookstore-shaped app, a useful split is:

  • per model: happy-path create, read, update, and delete;
  • per mfx: rule: at least one representative rejection;
  • per custom middleware: one accepted and one rejected request;
  • per action: a happy path, a representative failure, and any important contention case;
  • per worker: one event is received, processed, and acknowledged.

The framework’s own end-to-end suite covers adapter and query permutations. Application tests should concentrate on the policies and business behaviour the application adds.

Next

In Part 10 — Deploying to Production we swap SQLite for PostgreSQL, drive configuration from environment variables, enable the health probe, and produce a single binary suitable for a container image.