Throws and Writes
elements man tests/throws Read as markdownHow to test that a call is refused, and how to test a LiveTable write through a view, as a template would.
Testing That Code Throws
A bare thrown error records a Crash and stops the block, so to assert that a
call should throw, catch it yourself. Verify both that it threw and that the
error was the expected kind.
test.tsimport { test, session, assert, AuthError } from "@elements/app"; test("posting while logged out is rejected", () => { let threw = false; try { createPost({ title: "hi" }); } catch (err) { threw = true; assert(err instanceof AuthError, `got ${err}`); } assert(threw); });
Put the error in the message. Without `got ${err}`, when the call
throws something unexpected (usually a real bug, not the rejection you were
testing for), the assertion fails with assertion failed and the actual error,
its type and its message are discarded. Interpolating err costs nothing when
the test passes and is the whole diagnosis when it does not.
A failed assert records and continues, and anchors to the line it is written
on. Factor this shape into a helper and every throw-test in the file reports at
that one line inside the helper, not at the test that called it. Interpolating
the error is what keeps such a helper readable.
A helper that takes the call as a callback has to be async and await it.
Once it is async, the compiler stops awaiting calls to it, so write
await expectError(...) in an async test. An unawaited helper runs after the
test has ended, against a rolled-back savepoint and whatever session the test
logged in last, and reports failures that did not happen.
The threw flag makes the test fail when the call unexpectedly succeeds;
without it, a call that never throws passes. Use the same shape for ValidationError on bad input,
ForbiddenError on a role check, and so on.
Testing a LiveTable Write
insert, update, and delete work on the server, so a test calls them the
same way a template does: on a view. table.view() opens one, exactly as a
route would, and the write runs the real path: partition check, your handler
or auto-SQL, then the broadcast.
test.tsimport { test, equal, session, sql } from "@elements/app"; import { comments } from "./services"; test("a member can comment", () => { session.login({ userId: "u1", userName: "alice", }); comments.view().insert({ text: "hi", author: "alice", }); equal(sql(`select text from comments`).firstOrThrow().text, "hi"); });
A partition is passed here too: comments.view({ roomId }).
The session is the one the test is running in, so a handler's session check sees exactly what a request would. A test that logs nobody in is an anonymous caller, which is how you test that a write is refused:
test.tstest("an anonymous visitor cannot comment", () => { let threw = false; try { comments.view().insert({ text: "nope", author: "nobody", }); } catch (err) { threw = true; assert(err instanceof AuthError, `got ${err}`); } assert(threw); equal(sql(`select count(*) from comments`).firstOrThrow().count, 0); });
Write the mutation somewhere a test can import. A helper defined inside a
template.ehtml is reachable only from that template, so a write worth testing
belongs in the page's services.ts or in app/shared/services/. Give it the
view as a parameter. The template passes the one the route opened, and the test
passes one of its own:
services.tsexport function postComment(view: LiveView<Comment>, text: string) { view.insert({ text, author: session.getOrThrow("userName"), }); } // test.ts postComment(comments.view(), "hi");