feat(testing): add flash-testing, a JUnit 5 harness for Flash applications
Boots a real app on an OS-assigned port for a test class or a single test and
hands back a client pointed at it:
@RegisterExtension
static FlashTest app = FlashTest.of(new BlogApp())
.mock(UserService.class, new InMemoryUserService());
app.get("/api/users").expectStatus(200).expectBodyContains("alice");
A field rather than an annotation, because annotation values are compile-time
constants and so could never express a second server wired from the first —
FlashTest.of(new BlogApp(auth.baseUri())). Startup is lazy, so reading
baseUri() boots that server on the spot and declaration order does the wiring,
with no dependence on JUnit's extension ordering. A static field boots once per
class, a non-static field once per test; that is stock JUnit field semantics
rather than an option to configure.
Runs against a real loopback port instead of dispatching in-process. An
in-process dispatcher would be a third copy of the routing/handler/exception
sequence that Http1Connection and Http2StreamDispatcher already duplicate, kept
in sync by hand, and it would let a test pass while the status line,
content-length or HPACK encoding was broken.
mock() installs overrides as the last extension, after everything the
application and its extensions declare, so a fake always wins. Any object is
accepted, so a hand-written fake and a Mockito mock are equally welcome and
this module depends on no mocking library — only flash and junit-jupiter-api.
Teardown cancels the client before stopping the server: HttpClient holds
keep-alive sockets open and ServerLifecycle.stop() spins until the last one
closes, so the default 15s drain would otherwise be paid on every test class.
shutdownNow rather than close(), which blocks until every operation completes
and would hang on a leaked WebSocket.
Lives at the top level, not under flash-extensions/, which holds things you
install() onto an app; this carries junit-jupiter-api at compile scope and
nothing installable should.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4a85a27648
commit
424ca31b7a
@@ -0,0 +1,40 @@
|
||||
package dev.relism.flash.testing;
|
||||
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* A non-static field gets JUnit's method-level callbacks, so the app is rebuilt for every
|
||||
* test. Counting boots proves the isolation rather than inferring it from a fresh port, which
|
||||
* the OS is free to reuse.
|
||||
*/
|
||||
class FlashTestPerMethodScopeTest {
|
||||
|
||||
private static final AtomicInteger boots = new AtomicInteger();
|
||||
|
||||
@RegisterExtension
|
||||
FlashTest app = FlashTest.of(configured -> {
|
||||
boots.incrementAndGet();
|
||||
configured.get("/ping", (req, res) -> "pong");
|
||||
});
|
||||
|
||||
@AfterAll
|
||||
static void bootedOncePerTest() {
|
||||
assertEquals(2, boots.get(), "an instance FlashTest field should boot per test");
|
||||
}
|
||||
|
||||
@Test
|
||||
void firstTestGetsItsOwnApp() {
|
||||
app.get("/ping").expectStatus(200).expectBody("pong");
|
||||
}
|
||||
|
||||
@Test
|
||||
void secondTestGetsAnotherApp() {
|
||||
app.get("/ping").expectStatus(200).expectBody("pong");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package dev.relism.flash.testing;
|
||||
|
||||
import dev.relism.flash.extension.FlashApp;
|
||||
import dev.relism.flash.extension.FlashApplication;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* The scenario the harness exists for: two servers in one class, the second configured from
|
||||
* the first's address, with a service faked out on the second.
|
||||
*/
|
||||
class FlashTestSelfTest {
|
||||
|
||||
private static final AtomicInteger downstreamBoots = new AtomicInteger();
|
||||
|
||||
@RegisterExtension
|
||||
static FlashTest upstream = FlashTest.of(app -> app
|
||||
.get("/health", (req, res) -> "UP")
|
||||
.get("/echo", (req, res) -> "upstream:" + req.query("v")));
|
||||
|
||||
// upstream.baseUri() boots it right here, during this field's initialiser — declaration
|
||||
// order does the wiring, with no @Order and no reliance on JUnit's extension ordering.
|
||||
@RegisterExtension
|
||||
static FlashTest downstream = FlashTest.of(new Downstream(upstream.baseUri()))
|
||||
.mock(Greeter.class, () -> "faked");
|
||||
|
||||
@AfterAll
|
||||
static void classScopedServerBootsExactlyOnce() {
|
||||
assertEquals(1, downstreamBoots.get(), "a static FlashTest field should boot once per class");
|
||||
}
|
||||
|
||||
// ── two servers, wired together ──────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void eachServerGetsItsOwnPort() {
|
||||
assertNotEquals(upstream.port(), downstream.port());
|
||||
assertTrue(upstream.port() > 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void downstreamReachesUpstreamThroughItsInjectedBaseUri() {
|
||||
downstream.get("/call-upstream")
|
||||
.expectStatus(200)
|
||||
.expectBody("UP");
|
||||
}
|
||||
|
||||
// ── mocking ──────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void mockWinsOverAServiceProvidedByAnInstalledExtension() {
|
||||
downstream.get("/greeting")
|
||||
.expectStatus(200)
|
||||
.expectBody("faked");
|
||||
}
|
||||
|
||||
@Test
|
||||
void mockAfterStartIsRejected() {
|
||||
IllegalStateException error = assertThrows(IllegalStateException.class,
|
||||
() -> downstream.mock(Greeter.class, () -> "too late"));
|
||||
assertTrue(error.getMessage().contains("before the server starts"));
|
||||
}
|
||||
|
||||
// ── request / response surface ───────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void sendsHeadersQueriesAndBodies() {
|
||||
upstream.get("/echo?v=7").expectBody("upstream:7");
|
||||
|
||||
downstream.request()
|
||||
.header("X-Trace", "abc")
|
||||
.json("{\"name\":\"bob\"}")
|
||||
.post("/submit")
|
||||
.expectStatus(201)
|
||||
.expectHeader("X-Trace", "abc")
|
||||
.expectBodyContains("bob");
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedAssertionsReportTheActualResponse() {
|
||||
AssertionError error = assertThrows(AssertionError.class,
|
||||
() -> upstream.get("/health").expectStatus(404));
|
||||
|
||||
String message = error.getMessage();
|
||||
assertTrue(message.contains("GET /health"), message);
|
||||
assertTrue(message.contains("200"), message);
|
||||
assertTrue(message.contains("UP"), message);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unmatchedRoutesStillComeBackAsResponses() {
|
||||
upstream.get("/nope").expectStatus(404);
|
||||
}
|
||||
|
||||
// ── fixtures ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Counts its own boots so the class-scoped lifecycle can be asserted. */
|
||||
private record Downstream(URI upstream) implements FlashApplication {
|
||||
|
||||
@Override
|
||||
public void configure(FlashApp app) {
|
||||
downstreamBoots.incrementAndGet();
|
||||
|
||||
// Provided by an extension, so the .mock(...) above has something real to beat.
|
||||
app.install((registrar, ctx) -> ctx.provide(Greeter.class, () -> "real"));
|
||||
|
||||
app.get("/greeting", (req, res) -> app.ctx().require(Greeter.class).greet());
|
||||
|
||||
app.get("/call-upstream", (req, res) -> {
|
||||
try (HttpClient http = HttpClient.newHttpClient()) {
|
||||
return http.send(HttpRequest.newBuilder(upstream.resolve("/health")).build(),
|
||||
HttpResponse.BodyHandlers.ofString()).body();
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException(failure);
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException(interrupted);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/submit", (req, res) -> res.status(201)
|
||||
.header("X-Trace", req.header("X-Trace"))
|
||||
.body(req.body().bytes()));
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface Greeter { String greet(); }
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package dev.relism.flash.testing;
|
||||
|
||||
import dev.relism.flash.websocket.WebSocketFrame;
|
||||
import dev.relism.flash.websocket.WebSocketHandler;
|
||||
import dev.relism.flash.websocket.WebSocketSession;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class FlashWebSocketTest {
|
||||
|
||||
private static final Duration TIMEOUT = Duration.ofSeconds(2);
|
||||
|
||||
@RegisterExtension
|
||||
static FlashTest app = FlashTest.of(configured -> {
|
||||
configured.ws("/echo", new WebSocketHandler() {
|
||||
@Override public void onOpen(WebSocketSession session) { }
|
||||
@Override public void onMessage(WebSocketSession session, WebSocketFrame frame) {
|
||||
if (frame.opcode() != WebSocketFrame.OP_TEXT) return;
|
||||
try {
|
||||
byte[] echo = ("echo:" + new String(frame.copyPayload(), frame.payloadOffset(),
|
||||
frame.payloadLength(), StandardCharsets.UTF_8)).getBytes(StandardCharsets.UTF_8);
|
||||
session.sendText(echo, 0, echo.length);
|
||||
} catch (Exception failure) {
|
||||
throw new IllegalStateException(failure);
|
||||
}
|
||||
}
|
||||
});
|
||||
configured.ws("/silent", new WebSocketHandler() {
|
||||
@Override public void onOpen(WebSocketSession session) { }
|
||||
@Override public void onMessage(WebSocketSession session, WebSocketFrame frame) { }
|
||||
});
|
||||
});
|
||||
|
||||
@Test
|
||||
void roundTripsTextThroughARealHandshake() {
|
||||
try (FlashWebSocket socket = app.ws("/echo")) {
|
||||
socket.sendText("hello");
|
||||
assertEquals("echo:hello", socket.awaitText(TIMEOUT));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void queuesEveryMessageInOrder() {
|
||||
try (FlashWebSocket socket = app.ws("/echo")) {
|
||||
socket.sendText("one").sendText("two");
|
||||
assertEquals("echo:one", socket.awaitText(TIMEOUT));
|
||||
assertEquals("echo:two", socket.awaitText(TIMEOUT));
|
||||
}
|
||||
}
|
||||
|
||||
/** The likeliest user mistake: teardown must cancel it rather than block on it. */
|
||||
@Test
|
||||
void anUnclosedSocketDoesNotHangTeardown() {
|
||||
FlashWebSocket leaked = app.ws("/echo");
|
||||
leaked.sendText("no try-with-resources here");
|
||||
assertEquals("echo:no try-with-resources here", leaked.awaitText(TIMEOUT));
|
||||
}
|
||||
|
||||
@Test
|
||||
void awaitTextFailsLoudlyWhenNothingArrives() {
|
||||
try (FlashWebSocket socket = app.ws("/silent")) {
|
||||
socket.sendText("ignored");
|
||||
AssertionError error = assertThrows(AssertionError.class,
|
||||
() -> socket.awaitText(Duration.ofMillis(300)));
|
||||
assertEquals("No WebSocket text message within PT0.3S", error.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user