diff --git a/pom.xml b/pom.xml index 625050c..cbeb23a 100644 --- a/pom.xml +++ b/pom.xml @@ -33,6 +33,10 @@ 3.2.0 + + + 4.12.0 + 2.10.0 @@ -189,6 +193,13 @@ provided + + com.squareup.okhttp3 + mockwebserver + ${ohttp3.mockwebserver.version} + provided + + org.liquibase liquibase-core diff --git a/src/main/java/org/kiwiproject/test/okhttp3/mockwebserver/RecordedRequests.java b/src/main/java/org/kiwiproject/test/okhttp3/mockwebserver/RecordedRequests.java new file mode 100644 index 0000000..b9136ae --- /dev/null +++ b/src/main/java/org/kiwiproject/test/okhttp3/mockwebserver/RecordedRequests.java @@ -0,0 +1,76 @@ +package org.kiwiproject.test.okhttp3.mockwebserver; + +import static org.assertj.core.api.Assertions.assertThat; + +import lombok.experimental.UtilityClass; +import lombok.extern.slf4j.Slf4j; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.kiwiproject.base.UncheckedInterruptedException; + +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +/** + * Contains test assertions for {@link RecordedRequest} when using {@link MockWebServer}. + */ +@UtilityClass +@Slf4j +public class RecordedRequests { + + /** + * Get the next available {@link RecordedRequest} or throw an exception. + * + * @param mockWebServer the {@link MockWebServer} expected to contain the recorded request + * @return the next available {@link RecordedRequest} + * @throws IllegalStateException if there isn't an available {@link RecordedRequest} + */ + public static RecordedRequest takeRequiredRequest(MockWebServer mockWebServer) { + return takeRequestOrEmpty(mockWebServer) + .orElseThrow(() -> new IllegalStateException("no request is currently available")); + } + + /** + * Get the next available {@link RecordedRequest} or return an empty Optional. + * + * @param mockWebServer the {@link MockWebServer} which may contain the recorded request + * @return Optional containing the next available {@link RecordedRequest}, or an empty Optional + */ + public static Optional takeRequestOrEmpty(MockWebServer mockWebServer) { + return Optional.ofNullable(takeRequestOrNull(mockWebServer)); + } + + /** + * Assert there are not any available {@link RecordedRequest} instances. + * + * @param mockWebServer the {@link MockWebServer} to verify + */ + public static void assertNoMoreRequests(MockWebServer mockWebServer) { + assertThat(takeRequestOrNull(mockWebServer)) + .describedAs("There should not be any more requests, but (at least) one was found") + .isNull(); + } + + /** + * Get the next available {@link RecordedRequest} or return {@code null}. + *

+ * Unlike {@link MockWebServer#takeRequest()}, does not block. Instead, waits + * a brief amount of time (10 milliseconds) for the next request. And unlike + * {@link MockWebServer#takeRequest(long, TimeUnit)}, converts a checked + * InterruptedException to an {@link UncheckedInterruptedException} so that + * tests don't need to constantly declare "throws" clauses. + * + * @param mockWebServer the {@link MockWebServer} which may contain the recorded request + * @return the next available {@link RecordedRequest}, or null if not available + * @throws UncheckedInterruptedException if the call to get the next request throws InterruptedException + */ + public static RecordedRequest takeRequestOrNull(MockWebServer mockWebServer) { + try { + return mockWebServer.takeRequest(10, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + LOG.info("Interrupted waiting to get next request", e); + Thread.currentThread().interrupt(); + throw new UncheckedInterruptedException(e); + } + } +} diff --git a/src/test/java/org/kiwiproject/test/okhttp3/mockwebserver/RecordedRequestsTest.java b/src/test/java/org/kiwiproject/test/okhttp3/mockwebserver/RecordedRequestsTest.java new file mode 100644 index 0000000..025181c --- /dev/null +++ b/src/test/java/org/kiwiproject/test/okhttp3/mockwebserver/RecordedRequestsTest.java @@ -0,0 +1,197 @@ +package org.kiwiproject.test.okhttp3.mockwebserver; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.kiwiproject.test.assertj.KiwiAssertJ.assertPresentAndGet; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.only; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.apache.commons.lang3.RandomStringUtils; +import org.awaitility.Durations; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.kiwiproject.base.UncheckedInterruptedException; +import org.kiwiproject.io.KiwiIO; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse.BodyHandlers; +import java.time.Duration; +import java.util.concurrent.Callable; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +@DisplayName("RecordedRequests") +class RecordedRequestsTest { + + private MockWebServer server; + + @BeforeEach + void setUp() throws IOException { + server = new MockWebServer(); + server.start(); + } + + @AfterEach + void tearDown() { + KiwiIO.closeQuietly(server); + } + + @Nested + class TakeRequiredRequest { + + @Test + void shouldReturnTheAvailableRequest() { + server.enqueue(new MockResponse().setResponseCode(200)); + + var path = randomPath(); + makeRequest(path); + + var recordedRequest = RecordedRequests.takeRequiredRequest(server); + + assertRequest(recordedRequest, path); + } + + @Test + void shouldThrowIllegalState_WhenNoRequestIsAvailable() { + assertThatIllegalStateException() + .isThrownBy(() -> RecordedRequests.takeRequiredRequest(server)) + .withMessage("no request is currently available"); + } + } + + @Nested + class TakeRequestOrEmpty { + + @Test + void shouldReturnTheAvailableRequest() { + server.enqueue(new MockResponse().setResponseCode(202)); + + var path = randomPath(); + makeRequest(path); + + var recordedRequestOpt = RecordedRequests.takeRequestOrEmpty(server); + + var recordedRequest = assertPresentAndGet(recordedRequestOpt); + assertRequest(recordedRequest, path); + } + + @Test + void shouldReturnEmptyOptional_WhenNoRequestIsAvailable() { + assertThat(RecordedRequests.takeRequestOrEmpty(server)).isEmpty(); + } + } + + @Nested + class AssertNoMoreRequests { + + @Test + void shouldPass_WhenThereIsNoRequestAvailable() { + assertThatCode(() -> RecordedRequests.assertNoMoreRequests(server)) + .doesNotThrowAnyException(); + } + + @Test + void shouldFail_WhenAnyRequestIsAvailable() { + server.enqueue(new MockResponse().setResponseCode(202)); + + var path = randomPath(); + makeRequest(path); + + assertThatThrownBy(() -> RecordedRequests.assertNoMoreRequests(server)) + .isNotNull() + .hasMessageContaining( + "There should not be any more requests, but (at least) one was found"); + } + } + + @Nested + class TakeRequestOrNull { + + @Test + void shouldReturnTheAvailableRequest() { + server.enqueue(new MockResponse().setResponseCode(204)); + + var path = randomPath(); + makeRequest(path); + + var recordedRequestOpt = RecordedRequests.takeRequestOrEmpty(server); + + var recordedRequest = assertPresentAndGet(recordedRequestOpt); + assertRequest(recordedRequest, path); + } + + @Test + void shouldReturnNull_WhenNoRequestIsAvailable() { + assertThat(RecordedRequests.takeRequestOrNull(server)).isNull(); + } + + @Test + void shouldThrowUncheckedInterruptedException_IfInterruptedExceptionIsThrown() throws InterruptedException { + var mockMockWebServer = mock(MockWebServer.class); + when(mockMockWebServer.takeRequest(anyLong(), any(TimeUnit.class))) + .thenThrow(new InterruptedException("I interrupt you!")); + + // Execute in separate thread so that the "Thread.currentThread().interrupt()" call + // does not interrupt the test thread. + var executor = Executors.newSingleThreadExecutor(); + try { + Callable callable = () -> RecordedRequests.takeRequestOrNull(mockMockWebServer); + var future = executor.submit(callable); + await().atMost(Durations.FIVE_HUNDRED_MILLISECONDS).until(future::isDone); + + assertThatThrownBy(future::get) + .cause() + .isExactlyInstanceOf(UncheckedInterruptedException.class) + .hasMessageEndingWith("I interrupt you!"); + + verify(mockMockWebServer, only()).takeRequest(10, TimeUnit.MILLISECONDS); + } finally { + executor.shutdownNow(); + } + } + } + + private static String randomPath() { + return "/" + RandomStringUtils.randomAlphabetic(10); + } + + private void makeRequest(String path) { + var httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofMillis(100)) + .build(); + + var url = server.url(path).toString(); + var uri = URI.create(url); + + try { + var request = HttpRequest.newBuilder().GET().uri(uri).build(); + httpClient.send(request, BodyHandlers.ofString()); + } catch (IOException | InterruptedException e) { + throw new RuntimeException(e); + } + } + + private static void assertRequest(RecordedRequest request, String expectedPath) { + assertAll( + () -> assertThat(request.getMethod()).isEqualTo("GET"), + () -> assertThat(request.getPath()).isEqualTo(expectedPath) + ); + } +} diff --git a/src/test/java/org/kiwiproject/test/xmlunit/KiwiXmlAssertTest.java b/src/test/java/org/kiwiproject/test/xmlunit/KiwiXmlAssertTest.java index 5501593..daa3025 100644 --- a/src/test/java/org/kiwiproject/test/xmlunit/KiwiXmlAssertTest.java +++ b/src/test/java/org/kiwiproject/test/xmlunit/KiwiXmlAssertTest.java @@ -50,7 +50,7 @@ void shouldAcceptTestNameAsString() { .withTestName("custom-test-name") .and(otherXml) .areIdentical()) - .isExactlyInstanceOf(AssertionError.class); + .isInstanceOf(AssertionError.class); } @Test @@ -63,7 +63,7 @@ void shouldAcceptTestNameFromTestInfo(TestInfo testInfo) { .withTestNameFrom(testInfo) .and(otherXml) .areIdentical()) - .isExactlyInstanceOf(AssertionError.class); + .isInstanceOf(AssertionError.class); } } @@ -108,7 +108,7 @@ void shouldThrowAssertionErrorWhenXmlIsDifferent() { assertThatThrownBy(() -> KiwiXmlAssert.assertThat(xml).isIdenticalTo(otherXml)) - .isExactlyInstanceOf(AssertionError.class); + .isInstanceOf(AssertionError.class); } } @@ -136,7 +136,7 @@ void shouldThrowAssertionErrorWhenXmlIsDifferent() { assertThatThrownBy(() -> KiwiXmlAssert.assertThat(xml).isIdenticalToIgnoringWhitespace(otherXml)) - .isExactlyInstanceOf(AssertionError.class); + .isInstanceOf(AssertionError.class); } } @@ -165,7 +165,7 @@ void canaryDoesNotIgnoreCommentsBetweenTags() { assertThatThrownBy(() -> KiwiXmlAssert.assertThat(xml).isIdenticalToIgnoringComments(otherXml)) - .isExactlyInstanceOf(AssertionError.class); + .isInstanceOf(AssertionError.class); } } @@ -189,7 +189,7 @@ void shouldThrowAssertionErrorWhenXmlIsDifferent() { assertThatThrownBy(() -> KiwiXmlAssert.assertThat(xml).isIdenticalToIgnoringWhitespaceAndComments(otherXml)) - .isExactlyInstanceOf(AssertionError.class); + .isInstanceOf(AssertionError.class); } } }