Advanced & Operations

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 401

Paths 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 registered GenericDBSeeder. 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. With db/mongo's MongoAdapter this 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 a POST. Needs an Adapter implementing DocumentReader (MongoAdapter does).
  • 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.ResetCollections lists 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 authenticated
  • Given 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 body
  • When I send a POST|PUT|PATCH request to "<path>" with JSON: + doc string
  • When 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), as application/json unless a Content-Type header was set; for testing how bad bodies are refused
  • When I send a POST|PUT request to "<path>" with body + one-row table
  • When I send an authenticated GET request to "<path>" — uses the token stored as authToken

Assertions

  • Then the response status should be <code>
  • Then the response body should be empty
  • Then 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 exist
  • Then the response "<path>" should have <n> items
  • Then 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 fields
  • Then the response should match snapshot "<name>" — compares with testdata/snapshots/<name>.json (SnapshotDir); run with UPDATE_SNAPSHOTS=1 to write it. SnapshotRules lists values allowed to differ.

Comparing with another service — see Comparing two services

  • Given the reference service is at "<url>" (or set ReferenceURL)
  • When I send a GET|HEAD|DELETE|POST|PUT|PATCH request to "<path>" to both services — no body
  • When I send a POST|PUT|PATCH request to "<path>" to both services with JSON: + doc string
  • When I send a GET|HEAD|DELETE|POST|PUT|PATCH request to "<path>" to both services with raw body: + doc string
  • Then both responses should match / Then both responses should match ignoring: + table of paths
  • Then 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 order
  • Then 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 under ParityRules. ReferenceSettle makes 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
	})
}

On this page