From 9f6bd2e5f386c03d4630c314925eb967f4f69f02 Mon Sep 17 00:00:00 2001 From: Paul Balogh Date: Mon, 22 Jan 2024 15:28:39 -0600 Subject: [PATCH] Add API contract testing using Testcontainers Signed-off-by: Paul Balogh --- .dockerignore | 1 - Makefile | 4 +- api/api_contract_test.go | 99 +++++++++++++++++++++++++ go.mod | 1 + go.sum | 2 + testhelpers/api-compliance.js | 133 ++++++++++++++++++++++++++++++++++ 6 files changed, 237 insertions(+), 3 deletions(-) create mode 100644 api/api_contract_test.go create mode 100644 testhelpers/api-compliance.js diff --git a/.dockerignore b/.dockerignore index 08c3d33..d046090 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,5 @@ .git/ .gitignore .idea/ -Dockerfile bin/ gorm.db diff --git a/Makefile b/Makefile index 43f58c4..3358e8a 100644 --- a/Makefile +++ b/Makefile @@ -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: diff --git a/api/api_contract_test.go b/api/api_contract_test.go new file mode 100644 index 0000000..87140df --- /dev/null +++ b/api/api_contract_test.go @@ -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 +} diff --git a/go.mod b/go.mod index 5bcc6d4..d41bc0e 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index cb43756..26b1c3a 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/testhelpers/api-compliance.js b/testhelpers/api-compliance.js new file mode 100644 index 0000000..5f621a0 --- /dev/null +++ b/testhelpers/api-compliance.js @@ -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, + }); + }); + +}