-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add API contract testing using Testcontainers
Signed-off-by: Paul Balogh <[email protected]>
- Loading branch information
Showing
6 changed files
with
237 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,6 +3,5 @@ | |
.git/ | ||
.gitignore | ||
.idea/ | ||
Dockerfile | ||
bin/ | ||
gorm.db |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}); | ||
}); | ||
|
||
} |