Testing & BDD
Write API tests in plain Gherkin using Ginboot's Godog integration and its built-in step definitions.
Ginboot integrates with Godog, so you can write API tests in
Gherkin and have Ginboot's built-in step definitions run them against your application — in-process
through the router, or over HTTP against a running server (BaseURL).
The TestSuite
TestSuite wires up your gin.Engine, the identities requests are sent as, and your database seeders.
package tests
import (
"net/http"
"testing"
"github.com/klass-lk/ginboot"
dbMongo "github.com/klass-lk/ginboot/db/mongo"
"github.com/klass-lk/ginboot/parity"
"github.com/my-project/api"
)
func TestAPI(t *testing.T) {
app, db := api.SetupApp()
suite := &ginboot.TestSuite{
Router: app.Engine(),
// Seeds and clears collections used by `has the following JSON`.
Adapter: &dbMongo.MongoAdapter{DB: db},
// Who "I am authenticated as" can be. Names missing from the map are
// passed to PrincipalProvider, so tokens can be minted on demand.
Principals: map[string]parity.Principal{
"admin": {Header: http.Header{"Authorization": {"Bearer " + mintToken("admin")}}},
},
PrincipalProvider: func(name string) (parity.Principal, error) {
return parity.Principal{Header: http.Header{"Authorization": {"Bearer " + mintToken(name)}}}, nil
},
Paths: []string{"features"}, // the default
}
ginboot.TestFeatures(t, suite)
}TestFeatures fails the test when any scenario fails, and every built-in step returns an error
rather than only logging, so a failed assertion stops its scenario.
Writing features
Feature: Courses
Background:
Given document "courses" has the following JSON:
"""
[{"_id": "c1", "name": "Maths", "createdAt": {"$date": "2026-01-02T00:00:00Z"}, "tags": ["a", "b"]}]
"""
Scenario: An admin reads a course
Given I am authenticated as "admin"
When I send a GET request to "/api/courses/c1"
Then the response status should be 200
And the response "$.name" should be "Maths"
And the response "$.tags" should have 2 items
And the response "$.deletedAt" should not exist
And the response "id" field is stored as "courseId"
When I send a GET request to "/api/courses/{{courseId}}/sessions"
Then the response should match snapshot "courses/c1-sessions"
Scenario: Anonymous callers are refused
Given I am not authenticated
When I send a GET request to "/api/courses/c1"
Then the response status should be 401Paths in assertions use the JSONPath subset described in
Comparing two services; a path without $ names a top-level field.
{{name}} in a path, value, header, cookie or body is replaced by a value stored earlier.
Available step definitions
Data
Given document "<collection>" has the following items+ table — fills structs through a registeredGenericDBSeeder. Strings, numbers and booleans are read directly; other field types are parsed as JSON.Given document "<collection>" has the following JSON:+ doc string — one document or an array, inserted as-is. Withdb/mongo'sMongoAdapterthis is MongoDB Extended JSON, so{"$date": …}and{"$oid": …}work.Then document "<collection>" should have <n> items/... should have <n> items matching JSON:+ an Extended JSON filter — counts what is stored, e.g. after aPOST. Needs anAdapterimplementingDocumentReader(MongoAdapterdoes).Then document "<collection>" should contain an item with JSON:+ doc string — some stored item contains every field given, with that value; nested objects compare the same way and arrays element by element."<any>"matches any present value, for generated ids and timestamps. Stored dates read as{"$date": "…"}.TestSuite.ResetCollectionslists collections cleared before every scenario besides the seeded ones — those the requests under test write.
Seeded collections are cleared before the next scenario.
Who and how
Given I am authenticated as "<principal>"/Given I am not authenticatedGiven I set the request header "<name>" to "<value>"Given I set the cookie "<name>" to "<value>"
Requests
When I send a GET|DELETE|HEAD|POST|PUT|PATCH request to "<path>"— no bodyWhen I send a POST|PUT|PATCH request to "<path>" with JSON:+ doc stringWhen I send a GET|HEAD|DELETE|POST|PUT|PATCH request to "<path>" with raw body:+ doc string — sent as written, valid JSON or not (an empty doc string sends an empty body), asapplication/jsonunless aContent-Typeheader was set; for testing how bad bodies are refusedWhen I send a POST|PUT request to "<path>" with body+ one-row tableWhen I send an authenticated GET request to "<path>"— uses the token stored asauthToken
Assertions
Then the response status should be <code>Then the response body should be emptyThen the response "<path>" should be "<value>"— scalars compare as text; arrays and objects compare as JSON ("[\"a\",\"b\"]")Then the response "<path>" should be null/should exist/should not existThen the response "<path>" should have <n> itemsThen the response header "<name>" should be "<value>"Then the response "<path>" field is stored as "<key>"Then the response should contain an item with+ table — top-level fieldsThen the response should match snapshot "<name>"— compares withtestdata/snapshots/<name>.json(SnapshotDir); run withUPDATE_SNAPSHOTS=1to write it.SnapshotRuleslists values allowed to differ.
Comparing with another service — see Comparing two services
Given the reference service is at "<url>"(or setReferenceURL)When I send a GET|HEAD|DELETE|POST|PUT|PATCH request to "<path>" to both services— no bodyWhen I send a POST|PUT|PATCH request to "<path>" to both services with JSON:+ doc stringWhen I send a GET|HEAD|DELETE|POST|PUT|PATCH request to "<path>" to both services with raw body:+ doc stringThen both responses should match/Then both responses should match ignoring:+ table of pathsThen both responses should match with lists in any order:+ table of array paths (and optionally the key that pairs their elements), for lists a service builds in no particular orderThen both responses should match under these rules:+ one rule per row, to combine kinds:| ignore | $.id |,| unordered | $.ranks | student.id |,| ignore header | Content-Type |(a response header left out of the comparison; Content-Type is compared by default)Then document "<collection>" should match in both databases/... ignoring:+ table of paths rooted at the array ($[*].createdAt) — the collection's items in the two databases, compared as multisets underParityRules.ReferenceSettlemakes it retry a differing comparison for that long, for a service that writes some documents after it answers.
Writes are refused unless ReferenceAllowWrites is set: point it only at a disposable reference
(a local copy of the old service over its own test database), never a shared one. Set
ReferenceAdapter to that database so every seeding step and ResetCollections apply to both, and
each scenario starts from the same data. Table rows are mirrored through a GenericDBSeeder's
constructors; JSON seeding needs an adapter that implements JSONInserter.
Tag scenarios that need the reference @parity. Without a ReferenceURL, TestFeatures skips them,
so the same feature files run as plain tests of the new service. Tags adds a godog tag filter of
your own.
suite := &ginboot.TestSuite{
Router: router,
Adapter: &mongo.MongoAdapter{DB: db},
ReferenceURL: os.Getenv("REFERENCE_URL"), // empty: @parity scenarios are skipped
ReferenceAdapter: &mongo.MongoAdapter{DB: referenceDB},
ReferenceAllowWrites: true, // a local, disposable reference only
ResetCollections: []string{"announcements"},
}Your own steps
Register application steps with Steps. They can send requests with suite.Send and read the last
response with suite.Lookup, so they behave like the built-in ones:
suite.Steps = func(ctx *godog.ScenarioContext) {
ctx.Step(`^the course "([^"]*)" is published$`, func(id string) error {
if err := suite.Send(http.MethodGet, "/api/courses/"+id, nil); err != nil {
return err
}
v, err := suite.Lookup("$.published")
if err != nil {
return err
}
if v != true {
return fmt.Errorf("course %s is not published", id)
}
return nil
})
}