Skip to content

Commit

Permalink
Creating API contract test
Browse files Browse the repository at this point in the history
  • Loading branch information
javaducky committed Mar 8, 2024
1 parent 87b55e0 commit 9176f31
Show file tree
Hide file tree
Showing 5 changed files with 206 additions and 1 deletion.
3 changes: 2 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,9 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-r2dbc'
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'org.springframework.cloud:spring-cloud-starter'
implementation platform('org.testcontainers:testcontainers-bom:1.19.4')
implementation platform('org.testcontainers:testcontainers-bom:1.19.7')

testImplementation('org.testcontainers:k6')
testImplementation('org.testcontainers:postgresql')
testImplementation('org.testcontainers:r2dbc')
testImplementation 'io.projectreactor:reactor-test'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import io.weesvc.springboot.weesvc.domain.PlacesRepository;
import io.weesvc.springboot.weesvc.domain.model.Place;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
Expand All @@ -18,6 +19,7 @@

@RestController
@RequestMapping("/api/places")
@Slf4j
public class PlacesRestController {

@Autowired
Expand All @@ -30,6 +32,7 @@ public Flux<Place> getPlaces() {

@PostMapping
public Mono<Place> addPlace(@RequestBody Place place) {
log.info("Adding place named {}", place.getName());
final Instant now = Instant.now();
place.setCreatedAt(now);
place.setUpdatedAt(now);
Expand All @@ -38,13 +41,15 @@ public Mono<Place> addPlace(@RequestBody Place place) {

@GetMapping("{id}")
public Mono<Place> getPlaceById(@PathVariable Long id) {
log.info("Retrieving place with ID {}", id);
return placesRepository.findById(id)
.switchIfEmpty(Mono.error(new PlaceNotFoundException()));
}

@PatchMapping("{id}")
public Mono<Place> updatePlaceById(@PathVariable Long id,
@RequestBody Place updates) {
log.info("Updating place with ID {}", id);
return placesRepository.findById(id)
.switchIfEmpty(Mono.error(new PlaceNotFoundException()))
.flatMap(p -> {
Expand All @@ -68,6 +73,7 @@ public Mono<Place> updatePlaceById(@PathVariable Long id,

@DeleteMapping("{id}")
public Mono<Void> deletePlaceById(@PathVariable Long id) {
log.info("Deleting place with ID {}", id);
return placesRepository.findById(id)
.switchIfEmpty(Mono.error(new PlaceNotFoundException()))
.then(placesRepository.deleteById(id));
Expand Down
5 changes: 5 additions & 0 deletions src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,8 @@ r2dbc:
migrate:
resources-paths:
- classpath:/db/migration/*.sql

logging:
level:
root: INFO
io.weesvc.springboot.weesvc.api: DEBUG
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package io.weesvc.springboot.weesvc.api;

import io.weesvc.springboot.weesvc.WeesvcApplication;
import io.weesvc.springboot.weesvc.domain.PlacesRepository;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.context.ActiveProfiles;
import org.testcontainers.Testcontainers;
import org.testcontainers.containers.output.WaitingConsumer;
import org.testcontainers.k6.K6Container;
import org.testcontainers.utility.MountableFile;

import java.util.concurrent.TimeUnit;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest(
classes = { WeesvcApplication.class, PlacesRestController.class, PlacesRepository.class },
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = { "spring.r2dbc.url=r2dbc:tc:postgresql:///testdb?TC_IMAGE_TAG=15.3-alpine" }
)
@ActiveProfiles("test")
public class APIContractTests {

@LocalServerPort
private Integer port;

@Test
public void validateAPIContract() throws Exception {

Testcontainers.exposeHostPorts(port);
try (
K6Container container = new K6Container("grafana/k6:0.49.0")
.withTestScript(MountableFile.forClasspathResource("api-compliance.js"))
.withScriptVar("HOST", "host.testcontainers.internal")
.withScriptVar("PORT", port.toString())
.withCmdOptions("--no-usage-report")
) {
container.start();

WaitingConsumer consumer = new WaitingConsumer();
container.followOutput(consumer);

// Wait for test script results to be collected
consumer.waitUntil(
frame -> frame.getUtf8String().contains("iteration_duration"),
1,
TimeUnit.MINUTES
);

assertThat(container.getLogs()).doesNotContain("thresholds on metrics 'checks' have been crossed");
// Force a "passing" test to fail to see output results from k6 Test
// assertThat(container.getLogs()).contains("thresholds on metrics 'checks' have been crossed");

}

}

}
133 changes: 133 additions & 0 deletions src/test/resources/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 9176f31

Please sign in to comment.