Testing
A Wisp test needs no server and no port. Pick the smallest size that proves what you need:
| What you test | Tool | Runs |
|---|---|---|
| One component | Card::html(..), wisp::test::dom | in process |
| A handler or helper | wisp::test::request | in process |
| A call to an API | wisp::test::net | in process |
| A route, a form, a page | wisp::test::client | in process |
| Real browser code | wisp::browser! or Playwright | in a browser |
A whole example is examples/testing. Run the tests with wisp test (or cargo test).
Put the tests in src/tests.rs. The build adds this file as #[cfg(test)] mod tests at the crate root, so use crate::App; works in it. Do not add a mod tests; line. When src/main.rs or src/lib.rs declares mod tests, the build does not add it again. An app with src/lib.rs and wisp::app!() in it has the tests in the library. A src/main.rs that also calls wisp::app!() has them too.
A mod helpers; in the added src/tests.rs means src/helpers.rs, not src/tests/helpers.rs. So when src/tests.rs declares a mod helpers; with no #[path] at its top level, the build does not add the module and shows a warning. With strict = true it is an error. A folder src/tests/ of fixtures changes nothing. Then write #[cfg(test)] mod tests; in src/main.rs (or src/lib.rs).
One Component
Each component has Name::html(props) in Rust. It returns the HTML as a String. Pass every prop, in the order of {@props}. For a component that uses {@render children()}, call Name::html_with(props, children). children is &dyn Fn(&mut wisp::Out).
use wisp::test::dom;
#[test]
fn card() {
let html = Card::html_with("Tea", 3, &|o| o.body.push_str("<p>Black</p>"));
let card = dom(&html);
assert_eq!(card.get("h2").text(), "Tea");
assert_eq!(card.get(".count").text(), "3");
assert_eq!(card.get("section.card > p").text(), "Black");
}
A component from a library has its prefix: ui::Button::html("Hi", Tone::Quiet).
Query HTML
wisp::test::dom(html) parses a page or a piece. reply.dom() does the same for the body of a reply from the test client.
| Call | Gives |
|---|---|
get(sel) | the first match. It panics and shows the HTML when there is none |
find(sel) | Option of the first match |
all(sel) | every match, in document order |
count(sel), has(sel) | how many, whether any |
text() | the text by the rule of textContent: every text in document order, so <li>a</li><li>b</li> is ab. Entities are decoded, each run of white space is one space, and script and style are not text |
el.attr(name) | the attribute. Some("") for disabled |
el.has_class(c), el.tag() | a class test, the tag in lower case |
el.inner_html(), el.outer_html() | the HTML as given |
el.find(sel), el.all(sel) | the matches inside the element |
Selectors: a tag, *, #id, .class, [a], [a=v] (also ^= $= *= ~=), joined as a.b#c[d], then a space (descendant), > (child) and , (any of). Any other syntax panics and names the selector.
The reader does not add implied end tags (<li> with no </li> nests). It ends an element at its end tag, and it never panics on broken HTML. Elements nested deeper than 512 levels end where they start.
A Handler or Helper With a Cx
A function that takes &Cx needs a Cx. wisp::test::request::<App>(method, target) builds one as the server does, with no routing, no before hook and no CSRF check.
use wisp::test::{block_on, reply_headers, reply_status, request};
#[test]
fn greeting() {
let cx = request::<App>("GET", "/hello?lang=fr")
.cookie("name", "Ada")
.build();
assert_eq!(greeting::greeting(&cx), "Bonjour, Ada");
let member = request::<App>("GET", "/hello").sign_in(7).build();
assert_eq!(greeting::greeting(&member), "Hello, member 7");
let mut cx = request::<App>("POST", "/hello").form(&[("x", "1")]).build();
greeting::stamp(&mut cx);
assert_eq!(reply_status(&cx), 202);
assert!(reply_headers(&cx).iter().any(|(n, _)| n == "x-greeting"));
}
- Add what the handler reads:
header(name, value),cookie(name, value),bearer(token),form(&[(k, v)])(with theOriginof a browser),json(text),body(content_type, bytes),peer("203.0.113.9:4000"),sign_in(id). - The route's parameters work: for the route
/notes/[id],request::<App>("GET", "/notes/7")givescx.param("id") == "7". build()runs the app'sinit(tables in memory) aswisp::test::clientdoes. It panics, with the status, if the server would refuse the request.block_on(future)runs anasync fnto its end.reply_status(&cx)andreply_headers(&cx)read what the handler set.
Mock an API
wisp::test::net() stops every outgoing call of wisp::fetch, fetch_follow, fetch_public, fetch_stream and fetch_stream_public on the thread of the test. You queue the replies. Nothing leaves the process.
#[test]
fn rate() {
let net = wisp::test::net();
net.json("GET", "https://api.example.com/rate/EUR", 200, r#"{"usd_cents": 108}"#);
net.fail("GET", "https://api.example.com/old", 504, "timed out");
let mut app = wisp::test::client::<App>();
assert_eq!(app.get("/rate").text(), "1.08");
assert_eq!(net.sent()[0].header("accept"), Some("application/json"));
net.done(); // fails if a queued reply was not used
}
| Call | What it does |
|---|---|
reply(method, url, status, body) | queues a reply with a text body |
json(method, url, status, json) | the same, with content-type: application/json |
respond(method, url, Reply) | queues a whole Reply: headers, a location for fetch_follow |
fail(method, url, status, why) | queues the Err that fetch returns (504 for a timeout) |
sent(), sent_to(method, url) | the calls the app made: method, url, headers, body (header(name), text(), json::<T>()) |
done() | fails the test if a queued reply is left |
- Replies for one method and URL answer in order. A URL that ends in
*matches every URL that starts with the text before it. - A call answers from the most specific route that has a reply left: an exact URL before a
*prefix, and a longer prefix before a shorter one. The order ofreplycalls does not matter between routes. - One
net()at a time on a thread. A second call panics and leaves the first mock as it is. sent()holds the request asfetchgot it, afteron_fetch. Forfetch_stream*that includesaccept-encoding: identity. The headers that the wire adds (host,connection,content-length, anduser-agentwhen you set none) are not in it.- A call with no queued reply fails with a 502 that names the method and URL.
- The mock sees the request after the
on_fetchhook. - The mock works on the thread of the test: a
wisp::test::clientrequest, or a function you run withblock_on. A WebSocket handler andwisp::browser!run on other threads and reach the network. - The mock exists only in a debug build, which
cargo testmakes. A release build has no mock code and no seam infetch.wisp::test::netis absent there, socargo test --releasedoes not compile a test that uses it.
The Whole App
wisp::test::client::<App>() sends requests through the same path as the server (routing, hooks, errors, CSRF), keeps cookies, and has get, post_form, post_json, sign_in, websocket and more. See Testing and mixing with Rust code and Tests.
let home = app.get("/");
assert_eq!(home.dom().get("h1").text(), "Testing a Wisp app");
In a Browser
wisp test --browser runs tests that drive headless Chrome or Edge over the DevTools protocol (wisp::browser!(App)). It needs no Node. See Tests.
Playwright
wisp test --e2e runs your Playwright tests against the production build:
- It builds the app as
wisp previewdoes (--fastbuilds quicker and less optimized). - It starts the binary on a free loopback port.
- It runs
npx playwright testwith your arguments and the environment variableWISP_URL(http://127.0.0.1:<port>). - It stops the app, also when
wispfails or panics. The exit code is the one of Playwright.
The app keeps its data in a new folder of the system temp folder (WISP_DATA), and wisp deletes the folder when the run ends. The tests do not touch the data folder of your app. Arguments after -- go to Playwright without the --: wisp test --e2e -- --grep home. wisp test --e2e --init takes no other argument.
Start with wisp test --e2e --init. It writes playwright.config.ts and e2e/home.spec.ts, keeps any file that exists, and adds /test-results and /playwright-report to .gitignore. Then install Playwright once:
npm install --save-dev @playwright/test
npx playwright install chromium
wisp test --e2e
wisp test --e2e --grep "home"
The config reads the address from the environment, so the tests use page.goto("/"):
export default defineConfig({
testDir: "./e2e",
use: { baseURL: process.env.WISP_URL },
});
Coverage
wisp test --coverage runs the tests with cargo llvm-cov. Install it once:
cargo install cargo-llvm-cov
rustup component add llvm-tools-preview
A .wisp file compiles to generated Rust, and the generated code marks each line with its origin. Wisp moves the counts from the generated lines to the lines of the .wisp file. A line counts as hit when any generated line from it ran. The command prints a table and writes target/wisp-coverage.lcov, which editors and services such as Codecov read.
src/components/Card.wisp 3/3 100.0%
src/greeting.rs 14/14 100.0%
src/rates.rs 9/10 90.0%
src/routes/+page.wisp 1/1 100.0%
src/routes/hello/+server.rs 3/3 100.0%
src/routes/rate/+server.rs 4/4 100.0%
total 34/35 97.1%
- Other arguments go to
cargo llvm-cov(wisp test --coverage --browseradds thebrowserfeature). - The report lists the files of your app. Dependencies and the build folder are not in it.
- The command sets the environment variable
CARGO_ENCODED_RUSTFLAGSfor the build. Cargo then ignores therustflagsof its config files, so Wisp readsbuild.rustflagsfrom.cargo/config.toml(and.cargo/config) inCARGO_HOME, in each folder above the app and in the app, and adds them. It reads a string or an array on one line. For a[target.*]table or an array over many lines, it prints a warning that names the file and does not add the flags. YourRUSTFLAGSorCARGO_ENCODED_RUSTFLAGSstay and replace the config flags, as in Cargo. The flag--remap-path-prefixmoves the generated code out of the build folder, whichcargo llvm-covleaves out of its report. - Wisp copies a Rust file with inner attributes (
//!or#![..]lines) into the generated code. The report has no lines for such a file. Use//lines at the top to get them. - Coverage counts the server code of a template. Browser code (a
<script>that runs in the browser) is not Rust, so it has no counts.
Is This Page Useful?