Add testing-no-containers module: WireMock, MockWebServer, @MockitoBean/@MockitoSpyBean (post #43)
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
package com.ankurm.testingnc;
|
||||
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class Application {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(Application.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.ankurm.testingnc;
|
||||
|
||||
public interface GreetingService {
|
||||
String greet(String name);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.ankurm.testingnc;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class GreetingServiceImpl implements GreetingService {
|
||||
@Override
|
||||
public String greet(String name) {
|
||||
return "Hello, " + name + "!";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.ankurm.testingnc;
|
||||
|
||||
public record Quote(long id, String text, String author) {
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.ankurm.testingnc;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder;
|
||||
import org.springframework.boot.http.client.HttpClientSettings;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* A deliberately simple HTTP client, built with the timeout wired so a test can actually
|
||||
* trigger it -- see the fault-injection section of the post for why a 300ms read timeout
|
||||
* matters. Boot 4.1 replaced the old {@code ClientHttpRequestFactorySettings}/
|
||||
* {@code ClientHttpRequestFactories} pair (Boot 3.x) with {@link ClientHttpRequestFactoryBuilder}
|
||||
* and the {@link HttpClientSettings} record -- a tutorial written against Boot 3 will not
|
||||
* compile here; see the post.
|
||||
*/
|
||||
@Component
|
||||
public class QuoteClient {
|
||||
|
||||
private final RestClient restClient;
|
||||
|
||||
public QuoteClient(@Value("${quotes.api.base-url}") String baseUrl) {
|
||||
var settings = HttpClientSettings.defaults().withReadTimeout(Duration.ofMillis(300));
|
||||
this.restClient = RestClient.builder()
|
||||
.baseUrl(baseUrl)
|
||||
.requestFactory(ClientHttpRequestFactoryBuilder.detect().build(settings))
|
||||
.build();
|
||||
}
|
||||
|
||||
public Quote getQuote(long id) {
|
||||
return restClient.get()
|
||||
.uri("/quotes/{id}", id)
|
||||
.retrieve()
|
||||
.body(Quote.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
spring.application.name=testing-no-containers
|
||||
quotes.api.base-url=http://localhost:9999
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.ankurm.testingnc;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Deliberately has the exact same @MockitoBean override as {@link GreetingMockitoBeanBTest}:
|
||||
* same type, same (default) name, same reset mode. That identical override set is the context
|
||||
* cache key, and the point of this pair is to prove, with a real captured log line, that the
|
||||
* second class reuses the first class's ApplicationContext instead of building a new one.
|
||||
*/
|
||||
@SpringBootTest
|
||||
class GreetingMockitoBeanATest {
|
||||
|
||||
@MockitoBean
|
||||
GreetingService greetingService;
|
||||
|
||||
@Test
|
||||
void mockedGreetingIsReturned() {
|
||||
when(greetingService.greet("Ada")).thenReturn("yo Ada");
|
||||
|
||||
assertThat(greetingService.greet("Ada")).isEqualTo("yo Ada");
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.ankurm.testingnc;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@SpringBootTest
|
||||
class GreetingMockitoBeanBTest {
|
||||
|
||||
@MockitoBean
|
||||
GreetingService greetingService;
|
||||
|
||||
@Test
|
||||
void mockedGreetingIsReturnedHere_too() {
|
||||
when(greetingService.greet("Linus")).thenReturn("yo Linus");
|
||||
|
||||
assertThat(greetingService.greet("Linus")).isEqualTo("yo Linus");
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.ankurm.testingnc;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
/**
|
||||
* A @MockitoSpyBean override is a different override set than @MockitoBean (A and B above),
|
||||
* even though both target GreetingService -- a spy wraps the real bean, a mock replaces it
|
||||
* entirely. Different override set, different cache key: this class should cost a brand new
|
||||
* ApplicationContext, a real, captured cache miss.
|
||||
*/
|
||||
@SpringBootTest
|
||||
class GreetingMockitoSpyBeanCTest {
|
||||
|
||||
@MockitoSpyBean
|
||||
GreetingServiceImpl greetingService;
|
||||
|
||||
@Test
|
||||
void realMethodRunsAndCanStillBeVerified() {
|
||||
String result = greetingService.greet("Grace");
|
||||
|
||||
assertThat(result).isEqualTo("Hello, Grace!");
|
||||
verify(greetingService).greet("Grace");
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package com.ankurm.testingnc;
|
||||
|
||||
import mockwebserver3.MockResponse;
|
||||
import mockwebserver3.MockWebServer;
|
||||
import mockwebserver3.RecordedRequest;
|
||||
import mockwebserver3.SocketEffect;
|
||||
import mockwebserver3.junit5.StartStop;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.client.ResourceAccessException;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/**
|
||||
* Same QuoteClient, same scenarios, a different stub server. MockWebServer is a real OkHttp
|
||||
* TCP server too -- not a mock of the HTTP client -- but it is lower-level than WireMock: no
|
||||
* named "fault" API, so a reset connection or a slow response is built from the raw socket
|
||||
* primitives OkHttp's MockResponse exposes.
|
||||
*/
|
||||
class QuoteClientMockWebServerTest {
|
||||
|
||||
@StartStop
|
||||
MockWebServer server = new MockWebServer();
|
||||
|
||||
@Test
|
||||
void returnsTheStubbedQuote() throws InterruptedException {
|
||||
server.enqueue(new MockResponse.Builder()
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.body("{\"id\":42,\"text\":\"Talk is cheap. Show me the code.\",\"author\":\"Linus Torvalds\"}")
|
||||
.build());
|
||||
|
||||
var client = new QuoteClient(server.url("/").toString());
|
||||
Quote quote = client.getQuote(42);
|
||||
|
||||
assertThat(quote.text()).isEqualTo("Talk is cheap. Show me the code.");
|
||||
|
||||
RecordedRequest recorded = server.takeRequest(1, TimeUnit.SECONDS);
|
||||
assertThat(recorded).isNotNull();
|
||||
assertThat(recorded.getTarget()).isEqualTo("/quotes/42");
|
||||
}
|
||||
|
||||
@Test
|
||||
void socketClosedMidResponseSurfacesAsResourceAccessException() {
|
||||
// No named "connection reset" fault here, unlike WireMock -- it's built from the raw
|
||||
// primitive: MockResponse.Builder.onResponseStart(SocketEffect) runs the effect right as
|
||||
// the server begins writing the response. SocketEffect.ShutdownConnection tears down the
|
||||
// TCP connection at that point, so the client never receives a complete (or even
|
||||
// well-formed) response -- this is a genuine severed connection, not merely truncated
|
||||
// JSON that still parses as a body. (An earlier draft of this test just sent a short,
|
||||
// invalid JSON body and got a RestClientException -- a real response the client could
|
||||
// read and fail to parse, which is a different failure mode than the connection actually
|
||||
// dying. See the post.)
|
||||
server.enqueue(new MockResponse.Builder()
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.onResponseStart(SocketEffect.ShutdownConnection.INSTANCE)
|
||||
.build());
|
||||
|
||||
var client = new QuoteClient(server.url("/").toString());
|
||||
|
||||
assertThatThrownBy(() -> client.getQuote(99))
|
||||
.isInstanceOf(ResourceAccessException.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.ankurm.testingnc;
|
||||
|
||||
import com.github.tomakehurst.wiremock.client.WireMock;
|
||||
import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
import org.springframework.web.client.ResourceAccessException;
|
||||
|
||||
import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
|
||||
import static com.github.tomakehurst.wiremock.client.WireMock.get;
|
||||
import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/**
|
||||
* No Spring context here at all -- QuoteClient only needs a base URL string, so this is a
|
||||
* plain JUnit 5 test against a real WireMock server. "Without containers" in this post means
|
||||
* no Docker, not no Spring; this test also happens to need no Spring.
|
||||
*/
|
||||
class QuoteClientWireMockTest {
|
||||
|
||||
@RegisterExtension
|
||||
static WireMockExtension wireMock = WireMockExtension.newInstance().build();
|
||||
|
||||
@Test
|
||||
void returnsTheStubbedQuote() {
|
||||
wireMock.stubFor(get(urlEqualTo("/quotes/42")).willReturn(aResponse()
|
||||
.withHeader("Content-Type", "application/json")
|
||||
.withBody("{\"id\":42,\"text\":\"Talk is cheap. Show me the code.\",\"author\":\"Linus Torvalds\"}")));
|
||||
|
||||
var client = new QuoteClient(wireMock.baseUrl());
|
||||
Quote quote = client.getQuote(42);
|
||||
|
||||
assertThat(quote.text()).isEqualTo("Talk is cheap. Show me the code.");
|
||||
assertThat(quote.author()).isEqualTo("Linus Torvalds");
|
||||
}
|
||||
|
||||
@Test
|
||||
void connectionResetByPeerSurfacesAsResourceAccessException() {
|
||||
wireMock.stubFor(get(urlEqualTo("/quotes/99")).willReturn(aResponse()
|
||||
.withFault(com.github.tomakehurst.wiremock.http.Fault.CONNECTION_RESET_BY_PEER)));
|
||||
|
||||
var client = new QuoteClient(wireMock.baseUrl());
|
||||
|
||||
assertThatThrownBy(() -> client.getQuote(99))
|
||||
.isInstanceOf(ResourceAccessException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void fixedDelayPastTheReadTimeoutSurfacesAsResourceAccessException() {
|
||||
// QuoteClient is built with a 300ms read timeout; this stub takes 1000ms to respond.
|
||||
// ClientHttpRequestFactoryBuilder.detect() picks whichever HTTP client implementation
|
||||
// is actually on the classpath, and that choice changes the wording: the JDK HttpClient
|
||||
// reports a cancelled request as "Request cancelled" (java.net.http.HttpTimeoutException),
|
||||
// while Apache HttpClient 5 would say "Read timed out" (java.net.SocketTimeoutException).
|
||||
// Both are ResourceAccessException at the Spring level -- that type, not the message
|
||||
// text, is the thing worth asserting on; see the post.
|
||||
wireMock.stubFor(get(urlEqualTo("/quotes/7")).willReturn(aResponse()
|
||||
.withFixedDelay(1000)
|
||||
.withHeader("Content-Type", "application/json")
|
||||
.withBody("{\"id\":7,\"text\":\"late\",\"author\":\"nobody\"}")));
|
||||
|
||||
var client = new QuoteClient(wireMock.baseUrl());
|
||||
|
||||
assertThatThrownBy(() -> client.getQuote(7))
|
||||
.isInstanceOf(ResourceAccessException.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
quotes.api.base-url=http://localhost:9999
|
||||
logging.level.org.springframework.test.context.cache=DEBUG
|
||||
Reference in New Issue
Block a user