Adds an example for an E2E system test that leverages Kafka for JUnit

This commit is contained in:
Markus Günther
2021-02-19 16:50:10 +01:00
parent 4fd05cfbc7
commit a4474364d0
10 changed files with 392 additions and 15 deletions

View File

@@ -14,6 +14,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
import java.net.URI;
import java.util.concurrent.CompletableFuture;
/**
@@ -37,9 +38,11 @@ public class ItemsCommandResource {
log.info("Received a create item request with data {}.", createItem);
ItemCommand createItemCommand = commandsFor(createItem);
return commandHandler
.onCommand(commandsFor(createItem))
.thenApply(dontCare -> ResponseEntity.accepted().build())
.onCommand(createItemCommand)
.thenApply(dontCare -> ResponseEntity.created(itemUri(createItemCommand.getItemId())).build())
.exceptionally(e -> {
log.warn("Caught an exception at the service boundary.", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
@@ -49,4 +52,8 @@ public class ItemsCommandResource {
private ItemCommand commandsFor(final CreateItemRequest createItem) {
return new CreateItem(createItem.getDescription());
}
private URI itemUri(final String itemId) {
return URI.create("/items/" + itemId);
}
}

88
gtd-e2e-tests/pom.xml Normal file
View File

@@ -0,0 +1,88 @@
<?xml version="1.0"?>
<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"
xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>net.mguenther.gtd</groupId>
<artifactId>gtd-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
<artifactId>gtd-e2e-tests</artifactId>
<version>0.1.0-SNAPSHOT</version>
<name>gtd-e2e-tests</name>
<dependencies>
<!-- Intra-project dependencies -->
<dependency>
<groupId>net.mguenther.gtd</groupId>
<artifactId>gtd-common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>net.mguenther.gtd</groupId>
<artifactId>gtd-codec</artifactId>
<version>${project.version}</version>
</dependency>
<!-- HTTP client -->
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-core</artifactId>
</dependency>
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-httpclient</artifactId>
</dependency>
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-slf4j</artifactId>
</dependency>
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-jackson</artifactId>
</dependency>
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-jaxrs</artifactId>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>${httpclient.version}</version>
<exclusions>
<exclusion>
<groupId>commons-logging</groupId>
<artifactId>commons-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- Testing -->
<dependency>
<groupId>net.mguenther.kafka</groupId>
<artifactId>kafka-junit</artifactId>
<version>${kafka.junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>${junit.jupiter.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>${junit.jupiter.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.hamcrest</groupId>
<artifactId>hamcrest-core</artifactId>
<version>${hamcrest.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,24 @@
package net.mguenther.gtd.client;
/**
* @author Markus Günther (markus.guenther@gmail.com)
*/
public class CreateItem {
private final String description;
public CreateItem(final String description) {
this.description = description;
}
public String getDescription() {
return description;
}
@Override
public String toString() {
return "CreateItemRequest{" +
"description='" + description + '\'' +
'}';
}
}

View File

@@ -0,0 +1,27 @@
package net.mguenther.gtd.client;
import feign.Headers;
import feign.Param;
import feign.RequestLine;
import feign.Response;
import java.util.List;
public interface GettingThingsDone {
@RequestLine("POST /items")
@Headers("Content-Type: application/json")
Response createItem(CreateItem payload);
@RequestLine("PUT /items/{itemId}")
@Headers("Content-Type: application/json")
Response updateItem(@Param("itemId") String itemId, UpdateItem payload);
@RequestLine("GET /items/{itemId}")
@Headers("Accept: application/json")
Item getItem(@Param("itemId") String itemId);
@RequestLine("GET /items")
@Headers("Accept: application/json")
List<Item> getItems();
}

View File

@@ -0,0 +1,53 @@
package net.mguenther.gtd.client;
import java.util.Collections;
import java.util.Date;
import java.util.List;
/**
* @author Markus Günther (markus.guenther@gmail.com)
*/
public class Item {
private String id;
private String description;
private int requiredTime;
private Date dueDate;
private List<String> tags;
private String associatedList;
private boolean done;
public String getId() {
return id;
}
public boolean isDone() {
return done;
}
public String getDescription() {
return description;
}
public int getRequiredTime() {
return requiredTime;
}
public Date getDueDate() {
return dueDate;
}
public List<String> getTags() {
return Collections.unmodifiableList(tags);
}
public String getAssociatedList() {
return associatedList;
}
}

View File

@@ -0,0 +1,59 @@
package net.mguenther.gtd.client;
import java.util.List;
/**
* @author Markus Günther (markus.guenther@gmail.com)
*/
public class UpdateItem {
private Long dueDate;
private Integer requiredTime;
private List<String> tags;
private String associatedList;
public Long getDueDate() {
return dueDate;
}
public void setDueDate(long dueDate) {
this.dueDate = dueDate;
}
public Integer getRequiredTime() {
return requiredTime;
}
public void setRequiredTime(int requiredTime) {
this.requiredTime = requiredTime;
}
public List<String> getTags() {
return tags;
}
public void setTags(List<String> tags) {
this.tags = tags;
}
public String getAssociatedList() {
return associatedList;
}
public void setAssociatedList(String associatedList) {
this.associatedList = associatedList;
}
@Override
public String toString() {
return "UpdateItemRequest{" +
"dueDate=" + dueDate +
", requiredTime=" + requiredTime +
", tags=" + tags +
", associatedList='" + associatedList + '\'' +
'}';
}
}

View File

@@ -0,0 +1,74 @@
package net.mguenther.gtd;
import feign.Feign;
import feign.Logger;
import feign.Response;
import feign.httpclient.ApacheHttpClient;
import feign.jackson.JacksonDecoder;
import feign.jackson.JacksonEncoder;
import feign.slf4j.Slf4jLogger;
import net.mguenther.gtd.client.CreateItem;
import net.mguenther.gtd.client.GettingThingsDone;
import net.mguenther.gtd.domain.event.ItemCreated;
import net.mguenther.gtd.kafka.serialization.AvroItemEvent;
import net.mguenther.gtd.kafka.serialization.ItemEventConverter;
import net.mguenther.gtd.kafka.serialization.ItemEventDeserializer;
import net.mguenther.kafka.junit.ExternalKafkaCluster;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.concurrent.TimeUnit;
import static net.mguenther.kafka.junit.ObserveKeyValues.on;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;
public class EventPublicationTest {
private static final String URL = "http://localhost:8765/api";
private final ItemEventConverter converter = new ItemEventConverter();
@Test
public void anItemCreatedEventShouldBePublishedAfterCreatingNewItem() throws Exception {
ExternalKafkaCluster kafka = ExternalKafkaCluster.at("http://localhost:9092");
GettingThingsDone gtd = createGetthingThingsDoneClient();
String itemId = extractItemId(gtd.createItem(new CreateItem("I gotta do my homework!")));
List<AvroItemEvent> publishedEvents = kafka
.observeValues(on("topic-getting-things-done", 1, AvroItemEvent.class)
.observeFor(10, TimeUnit.SECONDS)
.with(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, ItemEventDeserializer.class)
.filterOnKeys(aggregateId -> aggregateId.equals(itemId)));
ItemCreated itemCreatedEvent = publishedEvents.stream()
.findFirst()
.map(converter::to)
.map(e -> (ItemCreated) e)
.orElseThrow(AssertionError::new);
assertThat(itemCreatedEvent.getItemId(), equalTo(itemId));
assertThat(itemCreatedEvent.getDescription(), equalTo("I gotta do my homework!"));
}
private GettingThingsDone createGetthingThingsDoneClient() {
return Feign.builder()
.client(new ApacheHttpClient())
.encoder(new JacksonEncoder())
.decoder(new JacksonDecoder())
.logger(new Slf4jLogger(GettingThingsDone.class))
.logLevel(Logger.Level.FULL)
.target(GettingThingsDone.class, URL);
}
private String extractItemId(final Response response) {
return response.headers()
.get("Location")
.stream()
.findFirst()
.map(s -> s.replace("/items/", ""))
.orElseThrow(AssertionError::new);
}
}

View File

@@ -30,7 +30,7 @@ public class ItemQueryResource {
this.itemView = itemView;
}
/*@RequestMapping(path = "/items/{itemId}", method = RequestMethod.GET, produces = "application/json")
@RequestMapping(path = "/items/{itemId}", method = RequestMethod.GET, produces = "application/json")
public CompletableFuture<ResponseEntity<Item>> showItem(@PathVariable("itemId") String itemId) {
log.info("Received a show item request for item with ID {}.", itemId);
@@ -38,5 +38,5 @@ public class ItemQueryResource {
return itemView.getItem(itemId)
.thenApply(optionalItem -> optionalItem.map(ResponseEntity::ok).orElse(ResponseEntity.notFound().build()))
.exceptionally(e -> ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build());
}*/
}
}

View File

@@ -42,13 +42,5 @@ public class ItemsQueryResource {
.exceptionally(e -> ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build());
}
@RequestMapping(path = "/items/{itemId}", method = RequestMethod.GET, produces = "application/json")
public CompletableFuture<ResponseEntity<Item>> showItem(@PathVariable("itemId") String itemId) {
log.info("Received a show item request for item with ID {}.", itemId);
return itemView.getItem(itemId)
.thenApply(optionalItem -> optionalItem.map(ResponseEntity::ok).orElse(ResponseEntity.notFound().build()))
.exceptionally(e -> ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build());
}
}

59
pom.xml
View File

@@ -21,6 +21,7 @@
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<!-- Managed dependency versions -->
<kafka.junit.version>2.7.0</kafka.junit.version>
<junit.jupiter.version>5.7.0</junit.jupiter.version>
<junit.vintage.version>5.7.0</junit.vintage.version>
<junit.version>4.13.1</junit.version>
@@ -31,6 +32,8 @@
<spring-cloud.version>2020.0.0</spring-cloud.version>
<avro.version>1.8.1</avro.version>
<commons-lang3.version>3.5</commons-lang3.version>
<openfeign.version>11.0</openfeign.version>
<httpclient.version>4.5.13</httpclient.version>
<!-- Plugin versions -->
<plugin.avro.version>1.10.1</plugin.avro.version>
<plugin.compiler.version>3.5</plugin.compiler.version>
@@ -44,6 +47,7 @@
<module>gtd-query-side</module>
<module>gtd-api-gateway</module>
<module>gtd-discovery-service</module>
<module>gtd-e2e-tests</module>
</modules>
<build>
@@ -152,11 +156,60 @@
<artifactId>log4j-over-slf4j</artifactId>
<version>${slf4j.version}</version>
</dependency>
<!-- HTTP client -->
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-core</artifactId>
<version>${openfeign.version}</version>
</dependency>
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-httpclient</artifactId>
<version>${openfeign.version}</version>
</dependency>
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-slf4j</artifactId>
<version>${openfeign.version}</version>
</dependency>
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-jackson</artifactId>
<version>${openfeign.version}</version>
</dependency>
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-jaxrs</artifactId>
<version>${openfeign.version}</version>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>${httpclient.version}</version>
<exclusions>
<exclusion>
<groupId>commons-logging</groupId>
<artifactId>commons-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- Testing -->
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>${junit.version}</version>
<groupId>net.mguenther.kafka</groupId>
<artifactId>kafka-junit</artifactId>
<version>${kafka.junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>${junit.jupiter.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>${junit.jupiter.version}</version>
<scope>test</scope>
</dependency>
<dependency>