Skip to content

Commit

Permalink
Add API contract testing using Testcontainers
Browse files Browse the repository at this point in the history
Signed-off-by: Paul Balogh <[email protected]>
  • Loading branch information
javaducky committed Jan 22, 2024
1 parent abdde5f commit 9f6bd2e
Show file tree
Hide file tree
Showing 6 changed files with 237 additions and 3 deletions.
1 change: 0 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,5 @@
.git/
.gitignore
.idea/
Dockerfile
bin/
gorm.db
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ deps:
go mod tidy

## test: Runs unit tests for the application.
test: deps
go test -cover ./...
test:
go test -test.short -cover ./...

## imports: Organizes imports within the codebase.
imports:
Expand Down
99 changes: 99 additions & 0 deletions api/api_contract_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package api

import (
"context"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/testcontainers/testcontainers-go/modules/k6"

"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)

type serviceContainer struct {
testcontainers.Container
Host string
Port string
}

// TestAPIContract runs the API compliance script against our service to ensure contracts.
func TestAPIContract(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration tests")
}
t.Parallel()

ctx := context.Background()

// Build the image and start a container with our service.
service, err := buildServiceContainer(ctx, t)
if err != nil {
t.Fatal(err)
}

// Start a k6 container to run our API compliance test.
k6c, err := k6.RunContainer(
ctx,
k6.WithCache(),
// TODO Temporarily holding script in repository until k6 container can retrieve from a remote url.
k6.WithTestScript("../testhelpers/api-compliance.js"),
// k6.WithTestScript("https://raw.githubusercontent.com/weesvc/workbench/main/scripts/api-compliance.js"),
k6.SetEnvVar("HOST", service.Host),
k6.SetEnvVar("PORT", service.Port),
)
assert.NoError(t, err)

t.Cleanup(func() {
if kerr := k6c.Terminate(ctx); kerr != nil {
t.Fatalf("failed to terminate k6 container: %s", kerr)
}
})
}

// buildServiceContainer will build and start our service within a container based on current source.
func buildServiceContainer(ctx context.Context, t *testing.T) (*serviceContainer, error) {
container, err := testcontainers.GenericContainer(
ctx,
testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{
FromDockerfile: testcontainers.FromDockerfile{
Context: "..",
Dockerfile: "Dockerfile",
PrintBuildLog: false,
KeepImage: false,
},
ExposedPorts: []string{"9092/tcp"},
Cmd: []string{"/bin/sh", "-c", "/app/weesvc migrate; /app/weesvc serve"},
WaitingFor: wait.ForHTTP("/api/hello").WithStartupTimeout(10 * time.Second),
},
Started: true,
},
)
if err != nil {
return nil, err
}

t.Cleanup(func() {
if terr := container.Terminate(ctx); terr != nil {
t.Fatalf("failed to terminate ServiceContainer: %s", terr)
}
})

ip, err := container.Host(ctx)
if err != nil {
return nil, err
}

mappedPort, err := container.MappedPort(ctx, "9092")
if err != nil {
return nil, err
}

return &serviceContainer{
Container: container,
Host: ip,
Port: mappedPort.Port(),
}, nil
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ require (
github.com/spf13/viper v1.18.2
github.com/stretchr/testify v1.8.4
github.com/testcontainers/testcontainers-go v0.27.0
github.com/testcontainers/testcontainers-go/modules/k6 v0.27.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.27.0
)

Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,8 @@ github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSW
github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
github.com/testcontainers/testcontainers-go v0.27.0 h1:IeIrJN4twonTDuMuBNQdKZ+K97yd7VrmNGu+lDpYcDk=
github.com/testcontainers/testcontainers-go v0.27.0/go.mod h1:+HgYZcd17GshBUZv9b+jKFJ198heWPQq3KQIp2+N+7U=
github.com/testcontainers/testcontainers-go/modules/k6 v0.27.0 h1:+tldnlvUc7fi/HR6KSvBFZEGkiazNAqNn3hTFKKHzfs=
github.com/testcontainers/testcontainers-go/modules/k6 v0.27.0/go.mod h1:mpjX06btzZjjcKQJ7pNUnkKyAswNThJcRXqIil48/Uc=
github.com/testcontainers/testcontainers-go/modules/postgres v0.27.0 h1:gbA/HYjBIwOwhE/t4p3kIprfI0qsxCk+YVW7P9XFOus=
github.com/testcontainers/testcontainers-go/modules/postgres v0.27.0/go.mod h1:VFrFKUUgET2hNXStdtaC7uOIJWviFUrixhKeaVw/4F4=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
Expand Down
133 changes: 133 additions & 0 deletions testhelpers/api-compliance.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { check, fail, group } from 'k6';
import http from 'k6/http';

export const options = {
vus: 1,
thresholds: {
// Ensure we have 100% compliance on API tests
checks: [{ threshold: 'rate == 1.0', abortOnFail: true }],
},
};

var targetProtocol = "http"
if (__ENV.PROTOCOL !== undefined) {
targetProtocol = __ENV.PROTOCOL
}
var targetHost = "localhost"
if (__ENV.HOST !== undefined) {
targetHost = __ENV.HOST
}
var targetPort = "80"
if (__ENV.PORT !== undefined) {
targetPort = __ENV.PORT
}
const BASE_URL = `${targetProtocol}://${targetHost}:${targetPort}`;

export default () => {
const params = {
headers: {
'Content-Type': 'application/json',
},
};

let testId = -1;
const testName = `k6-${Date.now()}`;
const testDesc = 'API Compliance Test';
const testLat = 35.4183;
const testLon = 76.5517;

group('Initial listing check', function () {
const placesRes = http.get(`${BASE_URL}/api/places`)
check(placesRes, {
'fetch returns appropriate status': (resp) => resp.status === 200,
});

// Confirm we do not have a place having the testName
let places = placesRes.json();
for (var i = 0; i < places.length; i++) {
if (places[i].name === testName) {
fail(`Test named "${testName}" already exists`);
}
}
});

group('Create a new place', function () {
const createRes = http.post(`${BASE_URL}/api/places`, JSON.stringify({
name: testName,
description: testDesc,
latitude: testLat,
longitude: testLon,
}), params);
check(createRes, {
'create returns appropriate status': (resp) => resp.status === 200,
'and successfully creates a new place': (resp) => resp.json('id') !== '',
});
testId = createRes.json('id');
});

group('Retrieving a place', function () {
const placeRes = http.get(`${BASE_URL}/api/places/${testId}`);
check(placeRes, {
'retrieving by id is successful': (resp) => resp.status === 200,
});
check(placeRes.json(), {
'response provides attribute `id`': (place) => place.id === testId,
'response provides attribute `name`': (place) => place.name === testName,
'response provides attribute `description`': (place) => place.description === testDesc,
'response provides attribute `latitude`': (place) => place.latitude === testLat,
'response provides attribute `longitude`': (place) => place.longitude === testLon,
'response provides attribute `created_at``': (place) => place.created_at !== undefined && place.created_at !== '',
'response provides attribute `updated_at`': (place) => place.updated_at !== undefined && place.updated_at !== '',
});
// console.log("POST CREATE");
// console.log(JSON.stringify(placeRes.body));

// Ensure the place is returned in the list
const placesRes = http.get(`${BASE_URL}/api/places`)
let places = placesRes.json();
let found = false;
for (var i = 0; i < places.length; i++) {
if (places[i].id === testId) {
found = true;
break;
}
}
if (!found) {
fail('Test place was not returned when retrieving all places');
}
});

group('Update place by id', function () {
const patchRes = http.patch(`${BASE_URL}/api/places/${testId}`, JSON.stringify({
description: testDesc + " Updated"
}), params);
check(patchRes, {
'update returns appropriate status': (resp) => resp.status === 200,
});
check(patchRes.json(), {
'response provides attribute `id`': (place) => place.id === testId,
'response provides attribute `name`': (place) => place.name === testName,
'response provides modified attribute `description`': (place) => place.description === testDesc + " Updated",
'response provides attribute `latitude`': (place) => place.latitude === testLat,
'response provides attribute `longitude`': (place) => place.longitude === testLon,
'response provides attribute `created_at``': (place) => place.created_at !== undefined && place.created_at !== '',
'response provides attribute `updated_at`': (place) => place.updated_at !== undefined && place.updated_at !== '',
'update changes modification date': (place) => place.updated_at !== place.created_at,
});
// console.log("POST UPDATE");
// console.log(JSON.stringify(patchRes.body));
});

group('Delete place by id', function () {
const deleteRes = http.del(`${BASE_URL}/api/places/${testId}`)
check(deleteRes, {
'delete returns appropriate status': (resp) => resp.status === 200,
});
// Confirm that the place has been removed
const placeRes = http.get(`${BASE_URL}/api/places/${testId}`)
check(placeRes, {
'deleted place no longer available': (resp) => resp.status === 404,
});
});

}

0 comments on commit 9f6bd2e

Please sign in to comment.