diff --git a/crnk-documentation/src/docs/asciidoc/index.adoc b/crnk-documentation/src/docs/asciidoc/index.adoc index 8af6a78aa..91b178a80 100644 --- a/crnk-documentation/src/docs/asciidoc/index.adoc +++ b/crnk-documentation/src/docs/asciidoc/index.adoc @@ -63,6 +63,7 @@ include::resource.adoc[] include::repositories.adoc[] include::client.adoc[] include::format.adoc[] +include::testkit.adoc[] include::reactive.adoc[] include::security.adoc[] include::dataaccess.adoc[] diff --git a/crnk-documentation/src/docs/asciidoc/testkit.adoc b/crnk-documentation/src/docs/asciidoc/testkit.adoc new file mode 100644 index 000000000..72424e3d0 --- /dev/null +++ b/crnk-documentation/src/docs/asciidoc/testkit.adoc @@ -0,0 +1,31 @@ +anchor:testkit[] + +# TestKit + +With `crnk-testkit` is in the early stages of providing utilities to facilitate testing. +`RandomWalkLinkChecker` is a class that performs a random walk on an API endpoint by following +all the links as specified by JSON:API. It will verify that each links provides a valid +`2xx` response code. The setup looks like: + +[source,java] +---- +CrnkClient client = ... +HttpAdapter httpAdapter = client.getHttpAdapter(); + +RandomWalkLinkChecker linkChecker = new RandomWalkLinkChecker(httpAdapter); +linkChecker.setWalkLength(100); +linkChecker.addStartUrl(...) + +linkChecker.performCheck(); +---- + +More possibilities for automation in the future are to add checks for: + +- paging +- filtering +- sorting +- security +- field sets +- ... + + diff --git a/crnk-testkit/build.gradle b/crnk-testkit/build.gradle new file mode 100644 index 000000000..57b988798 --- /dev/null +++ b/crnk-testkit/build.gradle @@ -0,0 +1,11 @@ +apply plugin: 'java' + +dependencies { + compile project(':crnk-core') + compile project(':crnk-client') + testCompile project(':crnk-test') + testCompile project(':crnk-home') + + compile 'org.assertj:assertj-core:3.9.1' + compile 'org.apache.httpcomponents:httpclient:4.5.2' +} diff --git a/crnk-testkit/src/main/java/io/crnk/testkit/RandomWalkLinkChecker.java b/crnk-testkit/src/main/java/io/crnk/testkit/RandomWalkLinkChecker.java new file mode 100644 index 000000000..721789356 --- /dev/null +++ b/crnk-testkit/src/main/java/io/crnk/testkit/RandomWalkLinkChecker.java @@ -0,0 +1,143 @@ +package io.crnk.testkit; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.Set; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.crnk.client.http.HttpAdapter; +import io.crnk.client.http.HttpAdapterRequest; +import io.crnk.client.http.HttpAdapterResponse; +import io.crnk.core.engine.http.HttpMethod; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Performs a random walk across an API endpoint by following JSON:API links. + */ +public class RandomWalkLinkChecker { + + private static final Logger LOGGER = LoggerFactory.getLogger(RandomWalkLinkChecker.class); + + private final HttpAdapter httpAdapter; + + private int walkLength = 1000; + + private Set visited = new HashSet<>(); + + private List upcoming = new ArrayList<>(); + + private ObjectMapper mapper = new ObjectMapper(); + + private String currentUrl; + + public RandomWalkLinkChecker(HttpAdapter httpAdapter) { + this.httpAdapter = httpAdapter; + } + + public void addStartUrl(String url) { + upcoming.add(url); + } + + public void setWalkLength(int walkLength) { + this.walkLength = walkLength; + } + + public int getWalkLength() { + return walkLength; + } + + public Set performCheck() { + Random random = new Random(); + + int index = 0; + while (index < walkLength && !upcoming.isEmpty()) { + + int nextIndex = random.nextInt(upcoming.size()); + + currentUrl = upcoming.remove(nextIndex); + if (!visited.contains(currentUrl)) { + visited.add(currentUrl); + index++; + visit(currentUrl); + } + } + return visited; + } + + private void visit(String url) { + try { + LOGGER.info("visiting {}", url); + HttpAdapterRequest request = httpAdapter.newRequest(url, HttpMethod.GET, null); + HttpAdapterResponse response = request.execute(); + int code = response.code(); + if (code >= 300) { + throw new IllegalStateException("expected endpoint to return success status code, got " + code + " from " + url); + } + String body = response.body(); + if (body == null) { + throw new IllegalStateException("expected a body to be returned from " + url); + } + + JsonNode jsonNode = mapper.reader().readTree(body); + findLinks(jsonNode); + } catch (IOException e) { + throw new IllegalStateException("failed to visit " + url, e); + } + } + + private void findLinks(JsonNode jsonNode) { + if (jsonNode instanceof ArrayNode) { + ArrayNode arrayNode = (ArrayNode) jsonNode; + for (int i = 0; i < arrayNode.size(); i++) { + JsonNode childNode = arrayNode.get(i); + findLinks(childNode); + } + } else if (jsonNode instanceof ObjectNode) { + ObjectNode objectNode = (ObjectNode) jsonNode; + Iterator> fields = objectNode.fields(); + while (fields.hasNext()) { + Map.Entry entry = fields.next(); + String name = entry.getKey(); + if (name.equals("links")) { + JsonNode linksValue = entry.getValue(); + if(!(linksValue instanceof ObjectNode)){ + throw new IllegalStateException("illegal use of links field in " + currentUrl + ": " + linksValue); + } + collectLinks((ObjectNode) linksValue); + } else { + findLinks(entry.getValue()); + } + } + } + } + + private void collectLinks(ObjectNode linksNode) { + Iterator> iterator = linksNode.fields(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + JsonNode link = entry.getValue(); + String url = link.asText(); + if (url == null || !url.startsWith("http")) { + try { + String linkName = entry.getKey(); + throw new IllegalStateException( + "expected link `" + linkName + "` from " + currentUrl + " to contain a valid link, got " + url + " from " + mapper.writer().writeValueAsString(linksNode)); + } catch (JsonProcessingException e) { + throw new IllegalStateException(e); + } + } else if (!visited.contains(url)) { + upcoming.add(url); + } + } + } +} diff --git a/crnk-testkit/src/test/java/io/crnk/testkit/RandomWalkLinkCheckerTest.java b/crnk-testkit/src/test/java/io/crnk/testkit/RandomWalkLinkCheckerTest.java new file mode 100644 index 000000000..f5bdb6e2d --- /dev/null +++ b/crnk-testkit/src/test/java/io/crnk/testkit/RandomWalkLinkCheckerTest.java @@ -0,0 +1,42 @@ +package io.crnk.testkit; + + +import java.util.Set; + +import io.crnk.client.http.inmemory.InMemoryHttpAdapter; +import io.crnk.core.boot.CrnkBoot; +import io.crnk.core.module.SimpleModule; +import io.crnk.core.repository.InMemoryResourceRepository; +import io.crnk.home.HomeModule; +import io.crnk.test.mock.models.BulkTask; +import io.crnk.test.mock.models.HistoricTask; +import org.junit.Assert; +import org.junit.Test; + +public class RandomWalkLinkCheckerTest { + + @Test + public void test() { + SimpleModule testModule = new SimpleModule("test"); + testModule.addRepository(new InMemoryResourceRepository<>(BulkTask.class)); + testModule.addRepository(new InMemoryResourceRepository<>(HistoricTask.class)); + + CrnkBoot boot = new CrnkBoot(); + boot.addModule(HomeModule.create()); + boot.addModule(testModule); + boot.boot(); + + String baseUrl = "http://localhost"; + InMemoryHttpAdapter adapter = new InMemoryHttpAdapter(boot, baseUrl); + + RandomWalkLinkChecker checker = new RandomWalkLinkChecker(adapter); + checker.addStartUrl(baseUrl + "/"); + Set visited = checker.performCheck(); + Assert.assertTrue(visited.contains("http://localhost/tasks/history")); + Assert.assertTrue(visited.contains("http://localhost/")); + Assert.assertTrue(visited.contains("http://localhost/bulkTasks")); + Assert.assertTrue(visited.contains("http://localhost/tasks/")); + Assert.assertEquals(4, visited.size()); + } + +} diff --git a/settings.gradle b/settings.gradle index 26f32de18..a72e19a48 100644 --- a/settings.gradle +++ b/settings.gradle @@ -2,6 +2,7 @@ include ':crnk-core' include ':crnk-reactive' include ':crnk-home' include ':crnk-test' +include ':crnk-testkit' include ':crnk-client' include ':crnk-meta' include ':crnk-validation'