Running tests with Docker Compose on Cloud Build

Running tests with Docker Compose on Cloud Build

·

5 min read

Why run tests with Docker Compose

Running integration testing is very important in software development. It gives you confidence in the correctness of the software system. Most of the time, the software system is required to communicate to third-party services, for example, databases. Common approaches would be writing mocks, and these could lead to another problem, as in not testing on the real service. To avoid this, it is advisable to test against a real database, start fresh before testing, and destroy it after testing. Docker Compose is the perfect system for running tests as you can spin up containers in a few seconds and kill them when the test completes.

Libraries like dockertest provide easy-to-use commands for spinning up Docker containers and using them for your tests. In this blog, we will use Docker Compose only, and demonstrate to run the tests on Cloud Build, which applies to other CI systems as well.

Full source code available at cncf-demo/hello-testing

Write minimum API code

Code shown is incomplete, for reference to get the rough idea. Complete example please refer to the source code repo

Create an API server with Echo and connect to MongoDB

package main

import (
    "context"
    "github.com/labstack/echo/v4"
    "go.mongodb.org/mongo-driver/mongo/readpref"
    "log"
    "os"
    "time"

    "go.mongodb.org/mongo-driver/mongo"
    "go.mongodb.org/mongo-driver/mongo/options"
)


func run() (*Handler, *echo.Echo, error) {
    c, d, err := connectToDB()
    if err != nil {
        return nil, nil, err
    }

    handler := Handler{
        mongoClient: c,
        db:          d,
    }

    e = echo.New()
    e.GET("/", handler.HelloWorld)
    e.GET("/items", handler.GetItems)
    e.POST("/items", handler.AddItem)

    return &handler, e, nil
}

func main() {
    log.SetFlags(log.LstdFlags | log.Llongfile)
    _, ee, err := run()
    if err != nil {
        panic(err)
    }
    ee.Logger.Fatal(ee.Start(":1323"))
}

Write some handlers

package main

import (
    "github.com/labstack/echo/v4"
    "go.mongodb.org/mongo-driver/bson"
    "go.mongodb.org/mongo-driver/bson/primitive"
    "go.mongodb.org/mongo-driver/mongo"
    "net/http"
)
type Item struct {
    ObjectID primitive.ObjectID `json:"-" bson:"_id,omitempty"`
    Name     string             `json:"name" bson:"name"`
    Quantity int                `json:"quantity" bson:"quantity"`
}

type Handler struct {
    mongoClient *mongo.Client
    db          *mongo.Database
}


func (h *Handler) HelloWorld(c echo.Context) error {
    return c.String(http.StatusOK, "Hello, World!")
}

func (h *Handler) GetItems(c echo.Context) error {
    ctx := c.Request().Context()
    cur, err := h.db.Collection(itemCol).Find(ctx, bson.M{})
    if err != nil {
        if err == mongo.ErrNoDocuments {
            return echo.NewHTTPError(http.StatusNotFound)
        }
        return echo.NewHTTPError(http.StatusInternalServerError, err)
    }

    var items []*Item

    for cur.Next(ctx) {
        var item Item
        if itemErr := cur.Decode(&item); itemErr != nil {
            return echo.NewHTTPError(http.StatusInternalServerError, itemErr)
        }
        items = append(items, &item)
    }
    if cErr := cur.Close(ctx); cErr != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, cErr)
    }
    return c.JSON(http.StatusOK, items)
}

func (h *Handler) AddItem(c echo.Context) error {
    i := new(Item)
    if err := c.Bind(i); err != nil {
        return echo.NewHTTPError(http.StatusBadRequest, err.Error())
    }

    ctx := c.Request().Context()

    _, err := h.db.Collection(itemCol).InsertOne(ctx, i)
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
    }
    return c.NoContent(http.StatusNoContent)
}

Write tests

Write tests for the handlers

package main

import (
    "github.com/labstack/echo/v4"
    "github.com/stretchr/testify/assert"
    "net/http"
    "net/http/httptest"
    "os"
    "strings"
    "testing"
)

var h *Handler
var ee *echo.Echo

func TestMain(m *testing.M) {
    // Write code here to run before tests
    if os.Getenv("MONGO_URI") == "" {
        _ = os.Setenv("MONGO_URI", "mongodb://admin:apple123@localhost:27017")
    }
    handler, e, err := run()
    if err != nil {
        panic(err)
    }

    h = handler
    ee = e
    // Run tests
    exitVal := m.Run()

    // Write code here to run after tests
    stop()
    // Exit with exit value from tests
    os.Exit(exitVal)
}

func TestAddItem(t *testing.T) {

    body := `{"name":"item 2","quantity":2}`

    req := httptest.NewRequest(
        http.MethodPost, "/items", strings.NewReader(body))
    req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
    rec := httptest.NewRecorder()
    c := ee.NewContext(req, rec)

    // Assertions
    if assert.NoError(t, h.AddItem(c)) {
        assert.Equal(t, http.StatusNoContent, rec.Code)
    }
}

func TestGetItems(t *testing.T) {

    req := httptest.NewRequest(http.MethodGet, "/items", nil)
    rec := httptest.NewRecorder()
    c := ee.NewContext(req, rec)

    if assert.NoError(t, h.GetItems(c)) {
        assert.Equal(t, http.StatusOK, rec.Code)
    }
}

Create init script to seed data for MongoDB

db.items.insertOne(
    {
        name: 'item 1',
        quantity: 10,
    }
);

Create Docker Compose file

version: '3'
services:
  mongodb:
    image: mongo:5
    ports:
      - '27017:27017'
    environment:
      - MONGO_INITDB_ROOT_USERNAME=admin
      - MONGO_INITDB_ROOT_PASSWORD=apple123
      - MONGO_INITDB_DATABASE=store
    volumes:
      - ./mongo/init.js:/docker-entrypoint-initdb.d/init.js

Run tests locally

Start docker compose

docker-compose up

Run test on another terminal

go test -v .

Note that the initialised script only will be executed on creation. When you stop docker-compose process, the volume will not be deleted, thus it will not be reinitialised again. To start a fresh DB and initialise again, run the following command, where the -v removes the volumes defined in the volume section.

docker-compose down -v

Run tests on Cloud Build

Cloud build attached a named Docker network cloudbuild on each build steps, so that we need to add the network on our Docker Compose file

Add the additional network section

You may create another docker-compose file with an additional network section. Or you will need to create docker network cloudbuild to run the test locally

version: '3'
services:
  mongodb:
    image: mongo:5
    ports:
      - '27017:27017'
    environment:
      - MONGO_INITDB_ROOT_USERNAME=admin
      - MONGO_INITDB_ROOT_PASSWORD=apple123
      - MONGO_INITDB_DATABASE=store
    volumes:
      - ./mongo/init.js:/docker-entrypoint-initdb.d/init.js

networks:
  default:
    external:
      name: cloudbuild

Write Cloud Build Config

steps:
  - name: 'docker/compose'
    id: 'compose-up'
    args: [ '-f', 'docker-compose.ci.yml', 'up', '-d' ]

  - name: 'gcr.io/cloud-builders/gcloud'
    id: 'wait-for-mongo'
    entrypoint: bash
    args:
      - '-c'
      - |
        ./wait-for-it.sh -t 0 mongodb:27017 -- echo 'up'
  - name: 'golang'
    id: 'go-test'
    entrypoint: 'bash'
    waitFor:
      - 'wait-for-mongo'
    env:
      - 'MONGO_URI=mongodb://admin:apple123@mongodb:27017'
    args:
      - -c
      - |
        go test -v .
  - name: 'docker/compose'
    id: 'compose-down'
    args: [ '-f', 'docker-compose.ci.yml', 'down' ]

Note that Cloud Build steps are executed in docker environment, so using localhost will not able to connect to the running service. For example, use mongodb://admin:apple123@**mongodb**:27017 instead of mongodb://admin:apple123@**localhost**:27017

  • Step 1: Start Docker Compose in the background with -d flag
  • Step 2: Wait until connection is ready, with wait-for-it script
  • Step 3: Run tests
  • Step 4: Optionally stop the Docker Compose

Submit to Cloud Build

Assume you have gcloud installed and configured

gcloud builds submit .

Example of passed testing image.png

Conclusion

The example shown in the blog is not limited to Cloud Build only. It can apply to other CI systems as well. The purpose of using Docker Compose is to run tests against real servers, run on fresh every time and destroy after testing.

Full source code available at cncf-demo/hello-testing

Did you find this article valuable?

Support Wei Lun by becoming a sponsor. Any amount is appreciated!