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, usemongodb://admin:apple123@**mongodb**:27017
instead ofmongodb://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
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