BDD test suite in Go - API Testing

Part two in the series of using BDD in Go to setup a pizza ordering application.

BDD test suite in Go - API Testing

In the previous article we setup a BDD test suite using godog. In this article, we are going to add a new feature and we are going to test it via the API interface.

Recall our previous file structure You should now have this as your file structure:

test/
├── features/
│   ├── godogs.feature
│   ├── nodogs.feature
├── godogs_test.go
├── go.mod
├── main.go

Starting with the end in mind

Let's start by writing out the feature file. This will be used to guide only the exact technical implementation required to make the feature test to pass.

Create the new feature file under a new directory shop

mkdir -p test/features/shop
touch test/features/shop/StoreLocator.feature

Write the feature file StoreLocator.feature

Feature: Store Locator
  In order to allow a customer to order online, the customer
  needs to select from stores in proximity

  Scenario: Getting the list of stores
    Given all stores
    When getting stores
    Then all stores are yielded

This is our north star for implementing the feature.

Setup initial step definitions template

Let's define the steps of the feature. Create the step definitions test file storelocator_test.go

touch test/features/shop/storelocator_test.go

Write our initial template in storelocator_test.go:

package shop

import (
	"context"
	"github.com/cucumber/godog"
	"testing"
)

type apiFeature struct {
}

func (a *apiFeature) givenAllStores(ctx context.Context) error {
	return godog.ErrPending
}

func (a *apiFeature) whenGettingStores(ctx context.Context) error {
	return godog.ErrPending
}

func (a *apiFeature) thenAllStoresAreYielded(ctx context.Context) error {
	return godog.ErrPending
}

func TestStoreLocatorFeature(t *testing.T) {
	suite := godog.TestSuite{
		ScenarioInitializer: InitializeStoreLocator,
		Options: &godog.Options{
			Format:   "pretty",
			Paths:    []string{"."},
			TestingT: t,
		},
	}

	if suite.Run() != 0 {
		t.Fatal("non-zero status returned, failed to run feature tests")
	}
}

func InitializeStoreLocator(sc *godog.ScenarioContext) {
	api := &apiFeature{}
	sc.Given(`all stores`, api.givenAllStores)
	sc.When(`getting stores`, api.whenGettingStores)
	sc.Then(`all stores are yielded`, api.thenAllStoresAreYielded)
}

Let's run the test to ensure we didn't miss anything. You should see the following:

➜  go-bdd git:(01a8a6d) ✗ go test -v ./test/features/shop/...
=== RUN   TestStoreLocatorFeature
Feature: Store Locator
  In order to allow a customer to order online, the customer
  needs to select from stores in proximity
=== RUN   TestStoreLocatorFeature/Getting_the_list_of_stores

  Scenario: Getting the list of stores # StoreLocator.feature:5
    Given all stores                   # storelocator_test.go:57 -> *apiFeature
      TODO: write pending definition
    When getting stores                # storelocator_test.go:58 -> *apiFeature
    Then all stores are yielded        # storelocator_test.go:59 -> *apiFeature

1 scenarios (1 pending)
3 steps (1 pending, 2 skipped)
322.75µs
--- PASS: TestStoreLocatorFeature (0.00s)
    --- PASS: TestStoreLocatorFeature/Getting_the_list_of_stores (0.00s)
PASS
ok      go-bdd/test/features/shop       0.273s

This indicates that godog is processing our step definitions as defined in our feature file. Based on the message TODO: write pending definition, however, we need to now define the steps. This is where we will implement the technical details. You will noticed that when a pending step is encountered, subsequent steps are automatically skipped.

Fill in specific step definition details

Fill in the step definitions, with some refactorings along the way:

package shop

import (
	"context"
	"errors"
	"fmt"
	"github.com/cucumber/godog"
	"github.com/gin-gonic/gin"
	"go-bdd/internal/features/shop"
	"go-bdd/test"
	"net/http"
	"net/http/httptest"
	"testing"
)

type apiFeature struct {
}

type routerCtxKey struct{}
type responseCtxKey struct{}

func (a *apiFeature) givenAllStores(ctx context.Context) context.Context {
	router := test.Router()
	shop.NewController().RegisterRoutes(router)

	// Return context is required
	return context.WithValue(ctx, routerCtxKey{}, router)
}

func (a *apiFeature) whenGettingStores(ctx context.Context) context.Context {
	w := httptest.NewRecorder()
	req, _ := http.NewRequest("GET", "/stores", nil)
	router := ctx.Value(routerCtxKey{}).(*gin.Engine)
	router.ServeHTTP(w, req)
	return context.WithValue(ctx, responseCtxKey{}, w)
}

func (a *apiFeature) thenAllStoresAreYielded(ctx context.Context) error {
	resp, ok := ctx.Value(responseCtxKey{}).(*httptest.ResponseRecorder)
	if !ok {
		return errors.New("response is nil")
	}
	if resp.Code != 200 {
		return fmt.Errorf("expected response code to be 200, but actual is %d", resp.Code)
	}
	return nil
}

func TestFeature(t *testing.T) {
	suite := godog.TestSuite{
		ScenarioInitializer: InitializeScenarios,
		Options: &godog.Options{
			Format:   "pretty",
			Paths:    []string{"."},
			TestingT: t,
		},
	}

	if suite.Run() != 0 {
		t.Fatal("non-zero status returned, failed to run feature tests")
	}
}

func InitializeScenarios(sc *godog.ScenarioContext) {
	api := &apiFeature{}

	sc.Given(`all stores`, api.givenAllStores)
	sc.When(`getting stores`, api.whenGettingStores)
	sc.Then(`all stores are yielded`, api.thenAllStoresAreYielded)
}

Implement the feature

There will be syntax errors as we haven't filled out the technical details yet. We now shift to implementing the feature in our app.

Create the Shop controller shop_controller.go

mkdir -p internal/features/shop
touch internal/features/shop/shop_controller.go

Fill in the details of the controller shop_controller.go

package shop

import "github.com/gin-gonic/gin"

// this is where you can add dependencies of the controller
type Controller struct {
}

func NewController() *Controller {
	return &Controller{}
}

func (c *Controller) RegisterRoutes(router *gin.Engine) {
	router.GET("/stores", func(c *gin.Context) {
		c.JSON(200, gin.H{
			"stores": []string{"store1", "store2"},
		})
	})
}

Now, run the tests

➜  go-bdd git:(ft/apiIntegration) go test -v ./test/features/shop/...
=== RUN   TestFeature
Feature: Store Locator
  In order to allow a customer to order online, the customer
  needs to select from stores in proximity
=== RUN   TestFeature/Getting_the_list_of_stores
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] GET    /stores                   --> go-bdd/internal/features/shop.(*Controller).RegisterRoutes.func1 (3 handlers)

  Scenario: Getting the list of stores # StoreLocator.feature:5
    Given all stores                   # storelocator_test.go:67 -> *apiFeature
[GIN] 2025/08/18 - 13:56:46 | 200 |     306.833µs |                 | GET      "/stores"
    When getting stores                # storelocator_test.go:68 -> *apiFeature
    Then all stores are yielded        # storelocator_test.go:69 -> *apiFeature

1 scenarios (1 passed)
3 steps (3 passed)
1.026125ms
--- PASS: TestFeature (0.00s)
    --- PASS: TestFeature/Getting_the_list_of_stores (0.00s)
PASS

Our tests are now passing. We can verify this by changing the status code in the implementation and ensuring that the test reports a failure. We now have automated test confidence.

Verifying the response

Currently, our test only checks the status code. We need to make sure the api is returning the response we expect.

Update our test to validate the response from the api call in storelocator_test.go:

package shop

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"github.com/cucumber/godog"
	"github.com/gin-gonic/gin"
	"github.com/go-playground/validator/v10"
	"go-bdd/internal/features/shop"
	"go-bdd/internal/features/shop/domain"
	"go-bdd/test"
	"net/http"
	"net/http/httptest"
	"testing"
)

type apiFeature struct {
}

type routerCtxKey struct{}
type responseCtxKey struct{}

// Global validator instance
var validate = validator.New()

// Helper function to validate JSON structure and business rules using validator
func validateJSONStructure[T any](resp *httptest.ResponseRecorder, expectedCode int) (*T, error) {
	if resp.Code != expectedCode {
		return nil, fmt.Errorf("expected response code %d, got %d", expectedCode, resp.Code)
	}

	var result T
	if err := json.Unmarshal(resp.Body.Bytes(), &result); err != nil {
		return nil, fmt.Errorf("invalid JSON structure: %v", err)
	}

	// Validate the struct using validator tags
	if err := validate.Struct(result); err != nil {
		if validationErrors, ok := err.(validator.ValidationErrors); ok {
			var errorMessages []string
			for _, fieldError := range validationErrors {
				errorMessages = append(errorMessages, fmt.Sprintf("Field '%s' failed validation: %s",
					fieldError.Field(), getValidationErrorMessage(fieldError)))
			}
			return nil, fmt.Errorf("validation failed: %v", errorMessages)
		}
		return nil, fmt.Errorf("validation failed: %v", err)
	}

	return &result, nil
}

// Helper function to provide user-friendly validation error messages
func getValidationErrorMessage(fe validator.FieldError) string {
	switch fe.Tag() {
	case "required":
		return "is required"
	case "min":
		return fmt.Sprintf("must be at least %s", fe.Param())
	case "max":
		return fmt.Sprintf("must be at most %s", fe.Param())
	case "len":
		return fmt.Sprintf("must be exactly %s characters", fe.Param())
	case "e164":
		return "must be a valid international phone number"
	case "oneof":
		return fmt.Sprintf("must be one of: %s", fe.Param())
	case "gte":
		return fmt.Sprintf("must be greater than or equal to %s", fe.Param())
	case "lte":
		return fmt.Sprintf("must be less than or equal to %s", fe.Param())
	case "dive":
		return "contains invalid items"
	default:
		return fmt.Sprintf("failed %s validation", fe.Tag())
	}
}

func (a *apiFeature) givenAllStores(ctx context.Context) context.Context {
	router := test.Router()
	shop.NewController().RegisterRoutes(router)

	// Return context is required
	return context.WithValue(ctx, routerCtxKey{}, router)
}

func (a *apiFeature) whenGettingStores(ctx context.Context) context.Context {
	w := httptest.NewRecorder()
	req, _ := http.NewRequest("GET", "/shop/stores", nil)
	router := ctx.Value(routerCtxKey{}).(*gin.Engine)
	router.ServeHTTP(w, req)
	return context.WithValue(ctx, responseCtxKey{}, w)
}

func (a *apiFeature) thenAllStoresAreYielded(ctx context.Context) error {
	resp, ok := ctx.Value(responseCtxKey{}).(*httptest.ResponseRecorder)
	if !ok {
		return errors.New("response is nil")
	}

	_, err := validateJSONStructure[domain.ShopsResponse](resp, 200)
	if err != nil {
		return err
	}

	return nil
}

func TestFeature(t *testing.T) {
	suite := godog.TestSuite{
		ScenarioInitializer: InitializeScenarios,
		Options: &godog.Options{
			Format:   "pretty",
			Paths:    []string{"."},
			TestingT: t,
		},
	}

	if suite.Run() != 0 {
		t.Fatal("non-zero status returned, failed to run feature tests")
	}
}

func InitializeScenarios(sc *godog.ScenarioContext) {
	api := &apiFeature{}

	sc.Given(`all stores`, api.givenAllStores)
	sc.When(`getting stores`, api.whenGettingStores)
	sc.Then(`all stores are yielded`, api.thenAllStoresAreYielded)
}

This contains go-playground/validator to validate our response. Add this repository to your go project.

Create the shop types

Next, create a domain folder for the store type.

mkdir -p internal/features/shop/domain
touch internal/features/shop/domain/PizzaShop.go

Then, add the types in PizzaShop.go:

package domain

// PizzaShop represents a pizza shop location
type PizzaShop struct {
	ID       string   `json:"id" validate:"required,min=1"`
	Name     string   `json:"name" validate:"required,min=1"`
	Address  Address  `json:"address" validate:"required"`
	Phone    string   `json:"phone" validate:"required""`
	Hours    Hours    `json:"hours" validate:"required"`
	Services []string `json:"services" validate:"required,min=1,dive,oneof=dine-in takeout delivery"`
	Status   string   `json:"status" validate:"required,oneof=open closed temporarily_closed"`
}

// Address represents the shop's physical address
type Address struct {
	Street     string  `json:"street" validate:"required,min=1"`
	City       string  `json:"city" validate:"required,min=1"`
	State      string  `json:"state" validate:"required,len=2"`
	PostalCode string  `json:"postal_code" validate:"required,min=5"`
	Country    string  `json:"country" validate:"required,len=3"`
	Latitude   float64 `json:"latitude,omitempty" validate:"omitempty,gte=-90,lte=90"`
	Longitude  float64 `json:"longitude,omitempty" validate:"omitempty,gte=-180,lte=180"`
}

// Hours represents operating hours
type Hours struct {
	Monday    string `json:"monday" validate:"required"`
	Tuesday   string `json:"tuesday" validate:"required"`
	Wednesday string `json:"wednesday" validate:"required"`
	Thursday  string `json:"thursday" validate:"required"`
	Friday    string `json:"friday" validate:"required"`
	Saturday  string `json:"saturday" validate:"required"`
	Sunday    string `json:"sunday" validate:"required"`
}

// ShopsResponse represents the API response for multiple shops
type ShopsResponse struct {
	Shops []PizzaShop `json:"shops" validate:"required,min=1,dive"`
	Total int         `json:"total" validate:"required,min=1"`
}

// ShopResponse represents the API response for a single shop
type ShopResponse struct {
	Shop PizzaShop `json:"shop" validate:"required"`
}

Update the controller

Then update the shop_controller.go to support the new type and return mock data on new endpoints /shop/stores and /shop/store/:id

package shop

import (
	"github.com/gin-gonic/gin"
	"go-bdd/internal/features/shop/domain"
)

// Add dependencies here (service, repository, etc.)
type Controller struct {
}

func NewController() *Controller {
	return &Controller{}
}

func (c *Controller) RegisterRoutes(router *gin.Engine) {
	// https://gin-gonic.com/en/docs/examples/grouping-routes/
	group := router.Group("/shop")
	{
		group.GET("/stores", c.getStores)
		group.GET("/stores/:id", c.getStore)
	}
}

func (c *Controller) getStores(ctx *gin.Context) {
	shops := c.getMockShops()

	response := domain.ShopsResponse{
		Shops: shops,
		Total: len(shops),
	}

	ctx.JSON(200, response)
}

func (c *Controller) getStore(ctx *gin.Context) {
	shopID := ctx.Param("id")

	var foundShop *domain.PizzaShop
	shops := c.getMockShops() // Helper method

	for _, shop := range shops {
		if shop.ID == shopID {
			foundShop = &shop
			break
		}
	}

	if foundShop == nil {
		ctx.JSON(404, gin.H{"error": "Shop not found"})
		return
	}

	response := domain.ShopResponse{
		Shop: *foundShop,
	}

	ctx.JSON(200, response)
}

// Mock data, for now. Will add a repository/db later.
func (c *Controller) getMockShops() []domain.PizzaShop {
	return []domain.PizzaShop{
		{
			ID:   "shop1",
			Name: "Mario's Pizza Palace",
			Address: domain.Address{
				Street:     "123 Main St",
				City:       "New York",
				State:      "NY",
				PostalCode: "10001",
				Country:    "USA",
				Latitude:   40.7831,
				Longitude:  -73.9712,
			},
			Phone: "+1-555-0123",
			Hours: domain.Hours{
				Monday:    "9:00-22:00",
				Tuesday:   "9:00-22:00",
				Wednesday: "9:00-22:00",
				Thursday:  "9:00-22:00",
				Friday:    "9:00-23:00",
				Saturday:  "10:00-23:00",
				Sunday:    "10:00-21:00",
			},
			Services: []string{"dine-in", "takeout", "delivery"},
			Status:   "open",
		},
		{
			ID:   "shop2",
			Name: "Luigi's Pizzeria",
			Address: domain.Address{
				Street:     "456 Broadway",
				City:       "New York",
				State:      "NY",
				PostalCode: "10002",
				Country:    "USA",
				Latitude:   40.7589,
				Longitude:  -73.9851,
			},
			Phone: "+1-555-0124",
			Hours: domain.Hours{
				Monday:    "10:00-21:00",
				Tuesday:   "10:00-21:00",
				Wednesday: "10:00-21:00",
				Thursday:  "10:00-21:00",
				Friday:    "10:00-22:00",
				Saturday:  "11:00-22:00",
				Sunday:    "11:00-20:00",
			},
			Services: []string{"dine-in", "takeout"},
			Status:   "open",
		},
	}
}

You should now have this as your file structure:

test/
├── features/
│   ├── shop
│       ├── StoreLocator.feature
│       ├── storelocator_test.go
│   ├── godogs.feature
│   ├── nodogs.feature
internal/
├──  features/
│    ├──  shop/
│         ├── domain/
│             ├── PizzaShop.go
│         ├── shop_controller.go
├── godogs_test.go
├── go.mod
├── main.go

Run the final test

Now, lets run the test suite to make sure the tests are passing.

➜  go-bdd git:(ft/apiIntegration) go test -v ./test/features/shop/...       
=== RUN   TestFeature
Feature: Store Locator
  As a customer
  I want to find pizza shops near me
  So that I can order from the closest location
=== RUN   TestFeature/Getting_all_pizza_shops
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] GET    /shop/stores              --> go-bdd/internal/features/shop.(*Controller).getStores-fm (3 handlers)
[GIN-debug] GET    /shop/stores/:id          --> go-bdd/internal/features/shop.(*Controller).getStore-fm (3 handlers)

  Scenario: Getting all pizza shops # StoreLocator.feature:6
    Given all stores                # storelocator_test.go:129 -> *apiFeature
[GIN] 2025/08/18 - 15:21:12 | 200 |         453µs |                 | GET      "/shop/stores"
    When getting stores             # storelocator_test.go:130 -> *apiFeature
    Then all stores are yielded     # storelocator_test.go:131 -> *apiFeature

1 scenarios (1 passed)
3 steps (3 passed)
1.242334ms
--- PASS: TestFeature (0.00s)
    --- PASS: TestFeature/Getting_all_pizza_shops (0.00s)
PASS
ok      go-bdd/test/features/shop       0.332s

We also check that the validation is working by removing a random field from the response and ensuring the test reports the expected failure.

Conclusion

To run the test suite for the whole app you can use:

go test -v ./test/...

This will scan all files that match *_test.go. That gives you the flexibility to organize tests into their own namespace. That gives the proper boundaries away from unrelated features and prevents naming conflicts.

We have refactored quite a bit to keep things separate. We have matched the test suite file structure to match that of the production file structure.

In conclusion, we now have an app that uses gin as an http framework that allows us to have an API server for our pizza delivery shop. In addition, we have leveraged bdd to drive the development efforts. We started with the application behavior in plain english terms and we transcribed the backing implementation in go. We have proven how to test api endpoints. This allows us to achieve shared domain knowledge and business expecations while bolstering application quality. In future articles we will need to setup a repository layer and establish a proper way to ensure proper integration with the database.