Files
2026-09-01 09:21:23 +02:00

215 lines
7.8 KiB
Markdown

# hemx-test
Test support for Hemx applications and generated resources.
`hemx-test` keeps assertions at the same typed boundary as application code. Use
an `EffectInspector` with generated targets instead of copying resource IDs or
matching raw wire operations.
## Choose the owning boundary
| Behavior under test | Smallest authoritative proof |
| --- | --- |
| Domain invariants and state transitions | Ordinary Rust unit tests; no Hemx harness |
| Handler output and generated-target effects | `run`, `run_async`, `run_result`, or `run_async_result` plus `EffectInspector` |
| Static document or effect-fragment structure | `HtmlInspector`, reached directly or through `target_html_document` / `target_html_fragment` |
| Axum extraction, middleware, status, headers, and response body | The opt-in `hemx_test::axum` router harness |
| Real process startup, readiness, sockets, logs, and cleanup | `TestProcess::builder` |
| JavaScript, focus, history, layout, or runtime reconciliation | A focused real-browser test outside `hemx-test` |
## Effects and structural HTML
```rust
use hemx_core::{GeneratedTarget, SafeHtml, Slot};
# use hemx_core::{Effect, ResourceId};
# #[derive(Clone, Copy)]
# struct GeneratedTodos(Slot<()>);
# impl GeneratedTarget for GeneratedTodos {
# fn __hemx_resource_id(self) -> ResourceId { self.0.id() }
# }
# impl GeneratedTodos {
# fn html(self, value: SafeHtml) -> Effect { self.0.html(value) }
# }
// Application build output supplies generated target types like this one.
let todos = GeneratedTodos(Slot::new(7));
let effect = todos.html(SafeHtml::trusted(
r#"<ul><li class="todo" data-state="open">Write tests</li></ul>"#,
));
let inspected = hemx_test::inspect(effect);
assert!(inspected.updates_html(todos));
let html = inspected.target_html_fragment(todos)?;
html.assert_count("li.todo", 1);
html.assert_text("li.todo", "Write tests");
html.assert_attribute("li.todo", "data-state", "open");
# Ok::<(), hemx_test::HtmlInspectionError>(())
```
Complete server-rendered pages can be inspected directly:
```rust
let page = hemx_test::inspect_html_document(
"<!doctype html><html><body><main id=app data-hemx-resource=7>Ready</main></body></html>",
);
page.assert_text("main#app", "Ready");
# use hemx_core::{GeneratedTarget, ResourceId, Slot};
# #[derive(Clone, Copy)]
# struct GeneratedApp(Slot<()>);
# impl GeneratedTarget for GeneratedApp {
# fn __hemx_resource_id(self) -> ResourceId { self.0.id() }
# }
page.assert_target(GeneratedApp(Slot::new(7)));
```
Structural inspection is backed internally by an HTML parser, but its types are
not part of the public API. `HtmlInspector`, `HtmlSelection`, and `HtmlElement`
own the observable source and selected data. Structural inspection does not run
JavaScript or prove browser-owned behavior such as focus, history, layout, or
runtime reconciliation.
A target inspector requires exactly one `Put`, `Insert`, or `Prepend` HTML
payload for that generated target. Missing, non-HTML, and ambiguous target
effects return `HtmlInspectionError` with the relevant effects in the
diagnostic.
## Handlers
`run` and `run_async` invoke typed synchronous and asynchronous handlers and
return the same `EffectInspector`:
```rust
use hemx_core::{Effect, GeneratedTarget, ResourceId, Slot};
# #[derive(Clone, Copy)]
# struct GeneratedCount(Slot<u32>);
# impl GeneratedTarget for GeneratedCount {
# fn __hemx_resource_id(self) -> ResourceId { self.0.id() }
# }
async fn load_count(value: u32) -> Effect {
Slot::<u32>::new(1).text(value)
}
# async fn example() {
let count = GeneratedCount(Slot::new(1));
let inspected = hemx_test::run_async(load_count, 42).await;
assert!(inspected.updates_text_containing(count, "42"));
# }
```
Fallible handlers use `run_result` or `run_async_result`. Their concrete error
is returned unchanged and is never converted into an empty or success-looking
effect batch:
```rust
use hemx_core::{Effect, Slot};
#[derive(Debug, Eq, PartialEq)]
struct Rejected;
async fn save(accepted: bool) -> Result<Effect, Rejected> {
accepted
.then(|| Slot::<()>::new(1).text("saved"))
.ok_or(Rejected)
}
# async fn example() {
let error = hemx_test::run_async_result(save, false)
.await
.unwrap_err();
assert_eq!(error, Rejected);
# }
```
`IntoEffect` conversion itself is infallible in the current public contract, so
handler errors and inspected success effects remain distinct.
## Axum routers
Enable the `axum` feature to send owned requests through a real `axum::Router`.
The response always preserves status, headers, and raw body bytes; parse it as a
Hemx effect batch or structural HTML only when that is the response contract.
```rust
# #[cfg(feature = "axum")]
# async fn example() -> Result<(), hemx_test::axum::RouterTestError> {
use axum::{response::Html, routing::get, Router};
use axum::http::StatusCode;
let app = Router::new().route(
"/",
get(|| async { Html("<main id=app>Ready</main>") }),
);
let response = hemx_test::axum::get("/").send(app).await?;
assert_eq!(response.status(), StatusCode::OK);
response.html_fragment()?.assert_text("main#app", "Ready");
# Ok(())
# }
```
`post(...).form(handle, fields)` builds the URL-encoded interaction body from a
typed Hemx handle. Authentication, CSRF, sessions, persistence, middleware, and
test providers remain application-owned. This in-process harness does not prove
real sockets, process startup, or browser behavior.
## Processes
Use `TestProcess` only when the process and socket lifecycle are part of the
behavior under test. Readiness is explicit and bounded:
```rust,no_run
use hemx_test::TestProcess;
use std::process::Command;
use std::time::Duration;
let process = TestProcess::builder(Command::new("target/debug/my-app"))
.label("application server")
.arg("serve")
.env("APP_ADDR", "127.0.0.1:4100")
.http("127.0.0.1:4100", "/health")
.timeout(Duration::from_secs(5))
.start()?;
assert!(process.id().is_some());
# Ok::<(), hemx_test::ProcessError>(())
```
TCP readiness proves only that something accepts the address; HTTP readiness
requires a 2xx or 3xx response from the selected path. Startup errors include
the process label, readiness attempts, exit status when available, and bounded
stdout/stderr. Readers keep draining after the capture limit so noisy children
do not deadlock. Explicit `shutdown` and `Drop` both kill, wait for, and reap a
running child and are safe to call more than once.
`TestProcess::start` remains available as the compact compatibility entry point
for TCP readiness. The harness intentionally does not reserve ports or claim
that a reserve-then-bind handoff is atomic.
## Migrating selector helpers
The original `0.1.0` crate exposed app-specific CSS builders and island/browser
probe helpers. They duplicated CSS syntax, made application structure look like
a framework contract, and could not prove browser behavior. They are removed
from the next release rather than preserved as a second testing vocabulary.
- Replace semantic selector builders such as `article_selector`,
`class_selector`, and `nav_link_selector` with the CSS selector that expresses
the application's own HTML contract in `HtmlInspector`.
- Replace `assert_rendered_target` and `assert_rendered_handle` with
`HtmlInspector::assert_target` and `HtmlInspector::assert_handle`; generated
resources remain the assertion vocabulary and raw runtime IDs stay private.
- Replace `target_selector`, `handle_selector`, and keyed selector builders with
structural HTML assertions. Browser tests that genuinely need a selector
should keep that selector in their browser-test adapter.
- Replace island probe scripts, readout selectors, event-name helpers, and SSE
marker strings with a focused real-browser journey; `hemx-test` does not
emulate JavaScript or runtime behavior.
The generated-resource helpers for effect inspection and typed form bodies
remain supported.
## License
MIT