Wisp is in pre-release, self-hosted since GitHub suspended us. Install it, or see the changelog.

Wisp

Testing

A Wisp test needs no server and no port. Pick the smallest size that proves what you need:

What you testToolRuns
One componentCard::html(..), wisp::test::domin process
A handler or helperwisp::test::requestin process
A call to an APIwisp::test::netin process
A route, a form, a pagewisp::test::clientin process
Real browser codewisp::browser! or Playwrightin 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.

CallGives
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 the Origin of 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") gives cx.param("id") == "7".
  • build() runs the app's init (tables in memory) as wisp::test::client does. It panics, with the status, if the server would refuse the request.
  • block_on(future) runs an async fn to its end. reply_status(&cx) and reply_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
}
CallWhat 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 of reply calls 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 as fetch got it, after on_fetch. For fetch_stream* that includes accept-encoding: identity. The headers that the wire adds (host, connection, content-length, and user-agent when 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_fetch hook.
  • The mock works on the thread of the test: a wisp::test::client request, or a function you run with block_on. A WebSocket handler and wisp::browser! run on other threads and reach the network.
  • The mock exists only in a debug build, which cargo test makes. A release build has no mock code and no seam in fetch. wisp::test::net is absent there, so cargo test --release does 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:

  1. It builds the app as wisp preview does (--fast builds quicker and less optimized).
  2. It starts the binary on a free loopback port.
  3. It runs npx playwright test with your arguments and the environment variable WISP_URL (http://127.0.0.1:<port>).
  4. It stops the app, also when wisp fails 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 --browser adds the browser feature).
  • 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_RUSTFLAGS for the build. Cargo then ignores the rustflags of its config files, so Wisp reads build.rustflags from .cargo/config.toml (and .cargo/config) in CARGO_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. Your RUSTFLAGS or CARGO_ENCODED_RUSTFLAGS stay and replace the config flags, as in Cargo. The flag --remap-path-prefix moves the generated code out of the build folder, which cargo llvm-cov leaves 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?

View This Page's Source

Type to search every docs page and heading.