Add testing-no-containers module: WireMock, MockWebServer, @MockitoBean/@MockitoSpyBean (post #43)

This commit is contained in:
Claude
2026-10-08 06:19:52 +00:00
parent f736108067
commit 5851521420
20 changed files with 666 additions and 1 deletions
@@ -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
@@ -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");
}
}
@@ -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");
}
}
@@ -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");
}
}
@@ -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