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,94 @@
|
||||
package dev.relism.flash.testing;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* A request being built against a {@link FlashTest} server. The HTTP verb is terminal — it
|
||||
* sends the request and hands back a {@link FlashResponse}:
|
||||
*
|
||||
* <pre>{@code
|
||||
* app.request()
|
||||
* .header("Authorization", "Bearer " + token)
|
||||
* .json("{\"name\":\"bob\"}")
|
||||
* .post("/api/users")
|
||||
* .expectStatus(201);
|
||||
* }</pre>
|
||||
*
|
||||
* For a bodyless {@code GET} or {@code DELETE}, {@link FlashTest#get} and
|
||||
* {@link FlashTest#delete} skip the builder entirely.
|
||||
*/
|
||||
public final class FlashRequest {
|
||||
|
||||
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(10);
|
||||
|
||||
private final FlashTest server;
|
||||
private final HttpRequest.Builder request = HttpRequest.newBuilder().timeout(REQUEST_TIMEOUT);
|
||||
private byte[] body;
|
||||
|
||||
FlashRequest(FlashTest server) {
|
||||
this.server = server;
|
||||
}
|
||||
|
||||
/** Adds a header. Repeatable — a name may be sent more than once. */
|
||||
public FlashRequest header(String name, String value) {
|
||||
request.header(name, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Sets a UTF-8 request body. */
|
||||
public FlashRequest body(String text) {
|
||||
this.body = Objects.requireNonNull(text, "text").getBytes(StandardCharsets.UTF_8);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Sets a raw request body. */
|
||||
public FlashRequest body(byte[] bytes) {
|
||||
this.body = Objects.requireNonNull(bytes, "bytes").clone();
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Sets a UTF-8 body and {@code content-type: application/json}. */
|
||||
public FlashRequest json(String json) {
|
||||
return body(json).header("content-type", "application/json");
|
||||
}
|
||||
|
||||
public FlashResponse get(String path) { return send("GET", path); }
|
||||
public FlashResponse post(String path) { return send("POST", path); }
|
||||
public FlashResponse put(String path) { return send("PUT", path); }
|
||||
public FlashResponse patch(String path) { return send("PATCH", path); }
|
||||
public FlashResponse delete(String path) { return send("DELETE", path); }
|
||||
public FlashResponse head(String path) { return send("HEAD", path); }
|
||||
public FlashResponse options(String path) { return send("OPTIONS", path); }
|
||||
|
||||
/** Sends any method, including the ones Flash adds beyond RFC 9110 ({@code PURGE}, {@code QUERY}). */
|
||||
public FlashResponse send(String method, String path) {
|
||||
URI target = server.baseUri().resolve(normalise(path));
|
||||
HttpRequest built = request.uri(target)
|
||||
.method(method, body == null
|
||||
? HttpRequest.BodyPublishers.noBody()
|
||||
: HttpRequest.BodyPublishers.ofByteArray(body))
|
||||
.build();
|
||||
try {
|
||||
HttpResponse<String> response =
|
||||
server.client().send(built, HttpResponse.BodyHandlers.ofString());
|
||||
return new FlashResponse(method, target.getPath(), response);
|
||||
} catch (IOException failure) {
|
||||
throw new AssertionError(method + ' ' + target + " failed", failure);
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new AssertionError(method + ' ' + target + " was interrupted", interrupted);
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared with {@link FlashTest#ws} — the ws:// URI needs the same leading slash. */
|
||||
static String normalise(String path) {
|
||||
Objects.requireNonNull(path, "path");
|
||||
return path.startsWith("/") ? path : '/' + path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package dev.relism.flash.testing;
|
||||
|
||||
import java.net.http.HttpHeaders;
|
||||
import java.net.http.HttpResponse;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* A response from a {@link FlashTest} server, with chainable assertions.
|
||||
*
|
||||
* <pre>{@code
|
||||
* app.get("/api/users")
|
||||
* .expectStatus(200)
|
||||
* .expectHeader("content-type", "application/json")
|
||||
* .expectBodyContains("alice");
|
||||
* }</pre>
|
||||
*
|
||||
* Every failure message carries the request line, the status and the body, so a red test says
|
||||
* what actually came back rather than only what did not match. Use {@link #status()},
|
||||
* {@link #body()} and {@link #headers()} for anything the assertions do not cover.
|
||||
*/
|
||||
public final class FlashResponse {
|
||||
|
||||
private final String method;
|
||||
private final String path;
|
||||
private final HttpResponse<String> response;
|
||||
|
||||
FlashResponse(String method, String path, HttpResponse<String> response) {
|
||||
this.method = method;
|
||||
this.path = path;
|
||||
this.response = response;
|
||||
}
|
||||
|
||||
// ── Raw access ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Response status code. */
|
||||
public int status() { return response.statusCode(); }
|
||||
|
||||
/** Response body decoded as a string. */
|
||||
public String body() { return response.body(); }
|
||||
|
||||
/** All response headers. */
|
||||
public HttpHeaders headers() { return response.headers(); }
|
||||
|
||||
/** First value of {@code name} (case-insensitive), or {@code null} if absent. */
|
||||
public String header(String name) { return response.headers().firstValue(name).orElse(null); }
|
||||
|
||||
// ── Assertions ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Asserts the status code. */
|
||||
public FlashResponse expectStatus(int expected) {
|
||||
assertEquals(expected, status(), this::describe);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Asserts the first value of a header (name case-insensitive). */
|
||||
public FlashResponse expectHeader(String name, String expected) {
|
||||
assertEquals(expected, header(name), () -> "header '" + name + '\'' + describe());
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Asserts the body matches exactly. */
|
||||
public FlashResponse expectBody(String expected) {
|
||||
assertEquals(expected, body(), this::describe);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Asserts the body contains {@code fragment}. */
|
||||
public FlashResponse expectBodyContains(String fragment) {
|
||||
assertTrue(body().contains(fragment), () -> "expected body to contain '" + fragment + '\'' + describe());
|
||||
return this;
|
||||
}
|
||||
|
||||
private String describe() {
|
||||
return "\n request: " + method + ' ' + path
|
||||
+ "\n status: " + status()
|
||||
+ "\n body: " + body();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package dev.relism.flash.testing;
|
||||
|
||||
import dev.relism.flash.extension.FlashApp;
|
||||
import dev.relism.flash.extension.FlashApplication;
|
||||
import dev.relism.flash.extension.FlashConfiguration;
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
import org.junit.jupiter.api.extension.AfterAllCallback;
|
||||
import org.junit.jupiter.api.extension.AfterEachCallback;
|
||||
import org.junit.jupiter.api.extension.BeforeAllCallback;
|
||||
import org.junit.jupiter.api.extension.BeforeEachCallback;
|
||||
import org.junit.jupiter.api.extension.ExtensionContext;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Boots a real {@link FlashApp} on an OS-assigned port for the duration of a test class or a
|
||||
* single test, and gives you a client pointed at it.
|
||||
*
|
||||
* <pre>{@code
|
||||
* class UserRoutesTest {
|
||||
*
|
||||
* @RegisterExtension
|
||||
* static FlashTest app = FlashTest.of(new BlogApp())
|
||||
* .mock(UserService.class, new InMemoryUserService());
|
||||
*
|
||||
* @Test
|
||||
* void listsUsers() {
|
||||
* app.get("/api/users")
|
||||
* .expectStatus(200)
|
||||
* .expectBodyContains("alice");
|
||||
* }
|
||||
* }
|
||||
* }</pre>
|
||||
*
|
||||
* <h3>More than one server</h3>
|
||||
* Because this is an ordinary object in a field rather than an annotation, a test class can
|
||||
* hold as many as it needs, and one can be wired from another with plain Java. Startup is lazy
|
||||
* — reading {@code baseUri()} boots that server on the spot — so declaration order does the
|
||||
* wiring, with no reliance on JUnit's extension ordering:
|
||||
*
|
||||
* <pre>{@code
|
||||
* @RegisterExtension static FlashTest auth = FlashTest.of(new FakeOidcApp());
|
||||
* @RegisterExtension static FlashTest api = FlashTest.of(new BlogApp(auth.baseUri()));
|
||||
* }</pre>
|
||||
*
|
||||
* <h3>Scope</h3>
|
||||
* A {@code static} field boots once for the class; a non-static field boots a fresh app for
|
||||
* every test. That is stock JUnit field semantics — the isolation switch is the keyword.
|
||||
*
|
||||
* <h3>Replacing services</h3>
|
||||
* {@link #mock} installs its replacements as the last extension, after everything the
|
||||
* application and its extensions declare, so a fake always wins. Any object will do — a
|
||||
* hand-written fake or a Mockito mock you created; this module depends on no mocking library.
|
||||
*
|
||||
* @see FlashRequest
|
||||
* @see FlashResponse
|
||||
*/
|
||||
public final class FlashTest implements BeforeAllCallback, AfterAllCallback,
|
||||
BeforeEachCallback, AfterEachCallback {
|
||||
|
||||
private static final String LOOPBACK = "127.0.0.1";
|
||||
|
||||
/**
|
||||
* ponytail: the harness pins the drain window so teardown is fast — the default 15s would
|
||||
* be paid on every class. Add a profile escape hatch if a test ever needs to exercise
|
||||
* draining itself.
|
||||
*/
|
||||
private static final int DRAIN_MS = 250;
|
||||
|
||||
private final FlashApplication application;
|
||||
private final Map<Class<?>, Object> overrides = new LinkedHashMap<>();
|
||||
private Consumer<FlashConfiguration.FlashConfigurationBuilder> profile = builder -> { };
|
||||
|
||||
private FlashApp app;
|
||||
private HttpClient client;
|
||||
private URI baseUri;
|
||||
private boolean classScoped;
|
||||
|
||||
private FlashTest(FlashApplication application) {
|
||||
this.application = Objects.requireNonNull(application, "application");
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a harness for {@code application}. Nothing is bound or started until first use.
|
||||
*
|
||||
* <p>{@link FlashApplication} is a functional interface, so a lambda works as well as a
|
||||
* named class: {@code FlashTest.of(app -> app.get("/ping", (req, res) -> "pong"))}.
|
||||
*/
|
||||
public static FlashTest of(FlashApplication application) {
|
||||
return new FlashTest(application);
|
||||
}
|
||||
|
||||
/**
|
||||
* Customises the {@link FlashConfiguration} this server runs with — timeouts, HTTP/2
|
||||
* switches, buffer sizes.
|
||||
*
|
||||
* <p>The harness stamps host, port and the shutdown drain window <em>after</em> this runs,
|
||||
* so a profile cannot break it. Setting {@code listener(...)} or {@code tls(...)} is
|
||||
* rejected: the harness owns the single plaintext loopback listener it hands you a client
|
||||
* for.
|
||||
*/
|
||||
public FlashTest profile(Consumer<FlashConfiguration.FlashConfigurationBuilder> profile) {
|
||||
requireNotStarted("profile(...)");
|
||||
this.profile = Objects.requireNonNull(profile, "profile");
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the service bound to {@code type} with {@code instance} for this server.
|
||||
*
|
||||
* <p>Wins over anything the application or its extensions declare, including services
|
||||
* provided by an installed {@code FlashExtension}.
|
||||
*
|
||||
* <p>ponytail: overrides are applied to the app's root context. A service declared inside
|
||||
* a {@code mount(...)} scope's child context shadows the root and is therefore not
|
||||
* reachable — add child-context targeting if that ever comes up.
|
||||
*/
|
||||
public <T> FlashTest mock(Class<T> type, T instance) {
|
||||
requireNotStarted("mock(...)");
|
||||
overrides.put(Objects.requireNonNull(type, "type"), Objects.requireNonNull(instance, "instance"));
|
||||
return this;
|
||||
}
|
||||
|
||||
// ── Accessors (each boots the server if it is not running) ────────────────
|
||||
|
||||
/** Base URI of the running server, e.g. {@code http://127.0.0.1:41307}. */
|
||||
public URI baseUri() { ensureStarted(); return baseUri; }
|
||||
|
||||
/** OS-assigned port of the running server. */
|
||||
public int port() { ensureStarted(); return app.port(); }
|
||||
|
||||
/** The running app — escape hatch for assertions the harness does not cover. */
|
||||
public FlashApp app() { ensureStarted(); return app; }
|
||||
|
||||
/** The client the harness issues requests with. */
|
||||
public HttpClient client() { ensureStarted(); return client; }
|
||||
|
||||
// ── Requests ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Starts a request with headers or a body; the HTTP verb sends it. */
|
||||
public FlashRequest request() { ensureStarted(); return new FlashRequest(this); }
|
||||
|
||||
/** Sends {@code GET path} with no headers or body. */
|
||||
public FlashResponse get(String path) { return request().get(path); }
|
||||
|
||||
/** Sends {@code DELETE path} with no headers or body. */
|
||||
public FlashResponse delete(String path) { return request().delete(path); }
|
||||
|
||||
/**
|
||||
* Opens a WebSocket to {@code path} on this server. Close it when done — a
|
||||
* try-with-resources block is the usual shape.
|
||||
*/
|
||||
public FlashWebSocket ws(String path) {
|
||||
ensureStarted();
|
||||
return new FlashWebSocket(client,
|
||||
URI.create("ws://" + LOOPBACK + ':' + app.port() + FlashRequest.normalise(path)));
|
||||
}
|
||||
|
||||
// ── JUnit lifecycle ──────────────────────────────────────────────────────
|
||||
// A static field receives class- AND method-level callbacks, so afterEach would otherwise
|
||||
// tear the server down after the first test. classScoped records which tier owns it.
|
||||
|
||||
@Override public void beforeAll(ExtensionContext context) { classScoped = true; ensureStarted(); }
|
||||
@Override public void afterAll(ExtensionContext context) { stop(); }
|
||||
@Override public void beforeEach(ExtensionContext context) { if (!classScoped) ensureStarted(); }
|
||||
@Override public void afterEach(ExtensionContext context) { if (!classScoped) stop(); }
|
||||
|
||||
// ── Internals ────────────────────────────────────────────────────────────
|
||||
|
||||
private void ensureStarted() {
|
||||
if (app == null) start();
|
||||
}
|
||||
|
||||
private void start() {
|
||||
FlashConfiguration.FlashConfigurationBuilder builder = FlashConfiguration.builder();
|
||||
profile.accept(builder);
|
||||
FlashConfiguration config = builder
|
||||
.port(0)
|
||||
.host(LOOPBACK)
|
||||
.shutdownDrainTimeoutMs(DRAIN_MS)
|
||||
.build();
|
||||
rejectListenerOverrides(config);
|
||||
|
||||
FlashApp starting = FlashApp.create(config).apply(application);
|
||||
if (!overrides.isEmpty())
|
||||
starting.install((registrar, ctx) -> applyOverrides(ctx));
|
||||
starting.start();
|
||||
|
||||
app = starting;
|
||||
baseUri = URI.create("http://" + LOOPBACK + ':' + starting.port());
|
||||
client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code listeners} is a lombok {@code @Singular} field, so it can only be inspected after
|
||||
* the build — hence build-then-check rather than a guard on the builder.
|
||||
*/
|
||||
private static void rejectListenerOverrides(FlashConfiguration config) {
|
||||
if (!config.getListeners().isEmpty())
|
||||
throw new IllegalStateException(
|
||||
"FlashTest owns the listener — remove listener(...) from the profile");
|
||||
if (config.getTls() != null)
|
||||
throw new IllegalStateException(
|
||||
"FlashTest serves plaintext on loopback — remove tls(...) from the profile");
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void applyOverrides(FlashContext ctx) {
|
||||
overrides.forEach((type, instance) -> ctx.override((Class<Object>) type, instance));
|
||||
}
|
||||
|
||||
private void stop() {
|
||||
if (app == null) return;
|
||||
// Order matters: the client holds keep-alive sockets open, and ServerLifecycle.stop()
|
||||
// spins until the last one closes or the drain window expires. shutdownNow rather than
|
||||
// close(), which blocks until every operation completes — a test that leaked an open
|
||||
// WebSocket would hang teardown forever.
|
||||
client.shutdownNow();
|
||||
try {
|
||||
client.awaitTermination(Duration.ofSeconds(2));
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
app.stop().join();
|
||||
app = null;
|
||||
client = null;
|
||||
baseUri = null;
|
||||
}
|
||||
|
||||
private void requireNotStarted(String what) {
|
||||
if (app != null)
|
||||
throw new IllegalStateException(what + " must be configured before the server starts");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package dev.relism.flash.testing;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.WebSocket;
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
/**
|
||||
* A WebSocket connected to a {@link FlashTest} server, for asserting on what a Flash endpoint
|
||||
* pushes back.
|
||||
*
|
||||
* <pre>{@code
|
||||
* try (FlashWebSocket socket = app.ws("/live")) {
|
||||
* socket.sendText("hello");
|
||||
* assertEquals("echo:hello", socket.awaitText(Duration.ofSeconds(2)));
|
||||
* }
|
||||
* }</pre>
|
||||
*
|
||||
* Backed by {@link java.net.http.WebSocket}, so the RFC 6455 handshake, masking and
|
||||
* fragmentation are the JDK's, not hand-rolled. Incoming text is queued as it arrives, so a
|
||||
* message that lands before {@link #awaitText} is called is not lost.
|
||||
*/
|
||||
public final class FlashWebSocket implements AutoCloseable {
|
||||
|
||||
private final BlockingQueue<String> received = new LinkedBlockingQueue<>();
|
||||
private final CompletableFuture<Integer> closed = new CompletableFuture<>();
|
||||
private final WebSocket socket;
|
||||
|
||||
FlashWebSocket(HttpClient client, URI uri) {
|
||||
this.socket = client.newWebSocketBuilder()
|
||||
.buildAsync(uri, new QueueingListener())
|
||||
.join();
|
||||
}
|
||||
|
||||
/** Sends a whole text message. */
|
||||
public FlashWebSocket sendText(String message) {
|
||||
socket.sendText(Objects.requireNonNull(message, "message"), true).join();
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the next text message.
|
||||
*
|
||||
* @throws AssertionError if none arrives within {@code timeout}
|
||||
*/
|
||||
public String awaitText(Duration timeout) {
|
||||
String message = poll(timeout);
|
||||
if (message == null)
|
||||
throw new AssertionError("No WebSocket text message within " + timeout);
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the server to close the connection and returns its close status code.
|
||||
*
|
||||
* @throws AssertionError if the server does not close within {@code timeout}
|
||||
*/
|
||||
public int awaitClose(Duration timeout) {
|
||||
try {
|
||||
return closed.get(timeout.toMillis(), TimeUnit.MILLISECONDS);
|
||||
} catch (TimeoutException notClosed) {
|
||||
throw new AssertionError("WebSocket was not closed within " + timeout, notClosed);
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new AssertionError("Interrupted awaiting WebSocket close", interrupted);
|
||||
} catch (ExecutionException failure) {
|
||||
throw new AssertionError("WebSocket failed before closing", failure.getCause());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a normal close and gives the server a moment to answer it.
|
||||
*
|
||||
* <p>Never throws: this is cleanup, usually in a try-with-resources, and a connection the
|
||||
* server already dropped must not mask the failure the test was actually reporting. Use
|
||||
* {@link #awaitClose} when the close itself is what you are asserting on.
|
||||
*/
|
||||
@Override
|
||||
public void close() {
|
||||
try {
|
||||
if (!socket.isOutputClosed()) socket.sendClose(WebSocket.NORMAL_CLOSURE, "").join();
|
||||
} catch (RuntimeException alreadyGone) {
|
||||
// nothing to close
|
||||
}
|
||||
closed.completeOnTimeout(WebSocket.NORMAL_CLOSURE, 1, TimeUnit.SECONDS)
|
||||
.exceptionally(failure -> WebSocket.NORMAL_CLOSURE)
|
||||
.join();
|
||||
}
|
||||
|
||||
private String poll(Duration timeout) {
|
||||
try {
|
||||
return received.poll(timeout.toMillis(), TimeUnit.MILLISECONDS);
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new AssertionError("Interrupted awaiting a WebSocket message", interrupted);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reassembles fragmented text and queues whole messages. */
|
||||
private final class QueueingListener implements WebSocket.Listener {
|
||||
|
||||
private final StringBuilder partial = new StringBuilder();
|
||||
|
||||
@Override
|
||||
public void onOpen(WebSocket webSocket) {
|
||||
webSocket.request(1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletionStage<?> onText(WebSocket webSocket, CharSequence data, boolean last) {
|
||||
partial.append(data);
|
||||
if (last) {
|
||||
received.add(partial.toString());
|
||||
partial.setLength(0);
|
||||
}
|
||||
webSocket.request(1);
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) {
|
||||
closed.complete(statusCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(WebSocket webSocket, Throwable error) {
|
||||
closed.completeExceptionally(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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