diff --git a/README.md b/README.md index 99661cc..84cf184 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,7 @@ See extension-specific READMEs for full details: - [`flash-ext-mcp`](flash-extensions/flash-ext-mcp/docs/README.md) - [`flash-ext-view-jte`](flash-extensions/flash-ext-view-jte/README.md) - [`flash-ext-view-thymeleaf`](flash-extensions/flash-ext-view-thymeleaf/README.md) +- [`flash-testing`](flash-testing/docs/README.md) ## Error handlers @@ -482,6 +483,9 @@ That is stock JUnit field semantics — the isolation switch is the keyword, not ### Configuration +Full reference: [`flash-testing/docs`](flash-testing/docs/README.md), including the +[limits](flash-testing/docs/limits.md) the harness deliberately does not cross. + `profile` customises the `FlashConfiguration` — timeouts, HTTP/2 switches, buffer sizes. Host, port and the shutdown drain window are stamped afterwards, so a profile cannot break the harness; `listener(...)` and `tls(...)` are rejected because the harness owns the loopback listener it gives diff --git a/flash-testing/docs/README.md b/flash-testing/docs/README.md new file mode 100644 index 0000000..47c3d51 --- /dev/null +++ b/flash-testing/docs/README.md @@ -0,0 +1,195 @@ +# flash-testing + +JUnit 5 harness for testing Flash applications. Boots a real app on an OS-assigned port, +swaps services for fakes, and asserts on responses. No mocking library, no assertion library — +`flash` and `junit-jupiter-api`, nothing else. + +## What it provides + +| Component | Description | +|---|---| +| `FlashTest` | JUnit 5 extension — owns one app's lifecycle and hands you a client | +| `FlashRequest` | Header/body builder; the HTTP verb is terminal and sends | +| `FlashResponse` | Chainable assertions that report the real response on failure | +| `FlashWebSocket` | WebSocket client over `java.net.http`, with a queue and timeouts | + +## Dependency + +```xml + + dev.relism + flash-testing + ${flash.version} + test + +``` + +## Quick start + +```java +class UserRoutesTest { + + @RegisterExtension + static FlashTest app = FlashTest.of(new BlogApp()) + .mock(UserService.class, new InMemoryUserService()); + + @Test + void listsUsers() { + app.get("/api/users") + .expectStatus(200) + .expectHeader("content-type", "application/json") + .expectBodyContains("alice"); + } +} +``` + +## The application under test + +`FlashTest.of` takes a `FlashApplication` — your app's routes, extensions and services declared +independently of the port they run on: + +```java +public final class BlogApp implements FlashApplication { + @Override public void configure(FlashApp app) { + app.install(new JacksonExtension()); + app.mount("/api", scope -> scope.scan("dev.blog.api")); + app.ws("/live", new FeedSocket()); + } +} + +FlashApp.create(8080).apply(new BlogApp()).startAndBlock(); // production +``` + +It takes `FlashApp` rather than `FlashRegistrar` deliberately: `ws()` and `mount()` live there, +and an application that could not register a WebSocket route or a mounted namespace would be a +half-application. + +It is a `@FunctionalInterface`, so a lambda and a named class are the same thing: + +```java +FlashTest.of(app -> app.get("/ping", (req, res) -> "pong")) +``` + +## Requests + +`get` and `delete` skip the builder when there is nothing to add. Everything else goes through +`request()`, where the HTTP verb sends: + +```java +app.get("/api/users").expectStatus(200); + +app.request() + .header("Authorization", "Bearer " + token) + .json("{\"name\":\"bob\"}") + .post("/api/users") + .expectStatus(201); +``` + +`json(...)` sets the body and `content-type: application/json`. `send(method, path)` reaches any +verb, including the ones Flash adds beyond RFC 9110 (`PURGE`, `QUERY`). + +Assertions chain, and every failure message carries the request line, status and body: + +``` +expected: <200> but was: <404> + request: GET /api/users + status: 404 + body: {"status":404,"error":"Not Found"} +``` + +`status()`, `body()`, `headers()` and `header(name)` are there for anything the assertions do +not cover. + +## Replacing services + +`mock` installs its replacements as the **last** extension, after everything the application and +its own extensions declare, so a fake always wins: + +```java +FlashTest.of(new BlogApp()) + .mock(UserService.class, new InMemoryUserService()) + .mock(Clock.class, Clock.fixed(instant, ZoneOffset.UTC)); +``` + +Any object is accepted — a hand-written fake, or a Mockito mock you created yourself. This module +depends on no mocking library. + +Prefer a constructor parameter when the application already takes one: +`new AdminApi(upstreamUri)` needs no override at all. `mock` is for what you do not control — +services declared inside `configure`, or provided by an installed extension. + +Calling `mock` after the server has started throws. + +## More than one server + +`FlashTest` is an ordinary object in a field, so a class can hold as many as it needs and wire one +from another in plain Java. Booting is lazy — reading `baseUri()` starts that server on the spot — +so declaration order does the wiring, with no `@Order` and no reliance on JUnit's extension +ordering: + +```java +@RegisterExtension static FlashTest auth = FlashTest.of(new FakeOidcApp()); +@RegisterExtension static FlashTest api = FlashTest.of(new BlogApp(auth.baseUri())); +``` + +Because binding happens when the app is created rather than when it starts, an application can +even be configured against its own address: + +```java +FlashTest.of(app -> app.install(new McpExtension( + McpConfig.builder("srv").resourceIdentifier("http://127.0.0.1:" + app.port() + "/mcp").build()))); +``` + +A server nothing touches is never booted. + +## Scope + +A `static` field boots once for the test class; a non-static field boots a fresh app for every +test. That is stock JUnit field semantics — the isolation switch is the keyword, not an option: + +```java +@RegisterExtension static FlashTest shared = FlashTest.of(new BlogApp()); // one boot per class +@RegisterExtension FlashTest fresh = FlashTest.of(new BlogApp()); // one boot per test +``` + +A class-scoped server shares state across its tests, including anything a `mock` fake accumulates. +Reset it in `@BeforeEach`, or use an instance field. + +## WebSockets + +```java +try (FlashWebSocket socket = app.ws("/live")) { + socket.sendText("hello"); + assertEquals("echo:hello", socket.awaitText(Duration.ofSeconds(2))); +} +``` + +Incoming text is queued as it arrives, so a message that lands before `awaitText` is called is not +lost. `awaitClose(timeout)` returns the server's close status code. `close()` never throws — a +connection the server already dropped must not mask the failure the test was reporting. + +## Configuration + +`profile` customises the `FlashConfiguration` — timeouts, HTTP/2 switches, buffer sizes: + +```java +FlashTest.of(new BlogApp()).profile(cfg -> cfg.http2CleartextEnabled(true)); +``` + +Host, port and the shutdown drain window are stamped **after** the profile runs, so it cannot +break the harness. `listener(...)` and `tls(...)` are rejected — see [limits](limits.md). + +## Teardown + +The client is cancelled before the server stops. `HttpClient` holds keep-alive sockets open and +`ServerLifecycle.stop()` spins until the last one closes or the drain window expires, so the +default 15s drain would otherwise be paid on every test class. The harness pins it to 250ms and +uses `shutdownNow()` rather than `close()`, which blocks until every operation completes and would +hang on a leaked WebSocket. + +Service cleanup registered with `FlashContext.onClose` runs after the drain, so a pooled +`DataSource` is released between test classes rather than at JVM exit. + +## See also + +- [limits.md](limits.md) — what the harness deliberately cannot do, and what to use instead diff --git a/flash-testing/docs/limits.md b/flash-testing/docs/limits.md new file mode 100644 index 0000000..01cfdd5 --- /dev/null +++ b/flash-testing/docs/limits.md @@ -0,0 +1,79 @@ +# Limits + +What `flash-testing` deliberately cannot do, why, and what to use instead. Each of these is a +consequence of a real constraint, not an unfinished feature. + +## TLS + +`profile(cfg -> cfg.tls(...))` is rejected. The harness serves plaintext on loopback and hands you +a client bound to `http://127.0.0.1:`; a TLS listener would leave that base URI pointing at +the wrong scheme, and the client would need the test certificate in a trust store. + +**Instead:** build the app directly with `FlashApp.create(...)` and a raw `SSLSocket`, as +`HttpServerTlsTest` does. `port(0)` plus `FlashApp.port()` still removes the free-port dance. + +Lifting this is the only limit here worth reconsidering, and only if application-level TLS testing +is actually wanted — transport-level TLS is already covered by the core suite. + +## HTTP/2 + +`java.net.http` reaches cleartext h2 through an `Upgrade:` handshake. Flash implements HTTP/2 +cleartext by **prior knowledge only** — a deliberate choice recorded in the root README — so the +harness client cannot negotiate h2 against a plaintext Flash listener. Over TLS it would work +through ALPN, but TLS is unavailable per the section above. + +**Instead:** the `flash/src/test/.../http2/` suites drive h2 frames over raw sockets. That is the +right tool for protocol behaviour anyway. + +## WebSocket over HTTP/2 + +`java.net.http.WebSocket` does not negotiate RFC 8441 extended CONNECT, so `FlashWebSocket` always +speaks the HTTP/1.1 upgrade. Flash supports both, but this client can only exercise one. + +**Instead:** `WebSocketOverH2Test` and `WebSocketParityTest` frame extended CONNECT by hand. + +## Malformed requests + +Every request goes through `java.net.http`, which structurally cannot emit an invalid request +line, a bad header block, a smuggled `Content-Length`, or a chunked *request* body on demand. That +is a feature for application testing and a blocker for parser testing. + +**Instead:** `RequestParserSecurityTest`, `RequestParserFuzzTest` and the raw-socket half of +`HttpServerTest` write bytes directly. A harness that could send malformed requests would just be +a socket. + +## Response framing + +`java.net.http` transparently decodes chunked responses and hides connection reuse, so +`Transfer-Encoding: chunked` and `Connection: keep-alive` are not observable through +`FlashResponse`. + +**Instead:** `HttpServerTest` keeps raw sockets for exactly those assertions, taking its port from +a `FlashTest` field so the class still boots once. Mixing the two styles in one class is the +intended pattern, not a workaround. + +## The `flash` core module + +`flash-testing` depends on `flash`, so `flash`'s own tests cannot depend on `flash-testing` — +Maven rejects module cycles regardless of scope. + +**Instead:** core tests use `FlashApp.create(...)` with `port(0)` and read `port()` back. Every +core suite already does this. Unblocking it would need a third module depending on both, which is +not worth it for the two suites that would benefit. + +## Scoped services + +`mock` writes to the app's root `FlashContext`. `FlashContext.require` checks its own bindings +before its parent's, so a service declared inside a `mount(...)` scope's child context shadows the +root and is **not** reachable from `mock`. + +**Instead:** declare the service on the app rather than inside the scope, or assert against the +real one. Child-context targeting would be a small addition if a scoped service ever needs faking. + +## Shutdown draining + +The harness pins `shutdownDrainTimeoutMs` to 250ms after any profile runs, so a test cannot +exercise graceful-drain behaviour through it. + +**Instead:** `ServerLifecycleGracefulShutdownTest` builds its app directly. A profile escape hatch +would be easy to add if this ever comes up twice.