Manual Tests Throws and Writes

Throws and Writes

elements man tests/throws Read as markdown

How 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.ts
import { 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.ts
import { 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.ts
test("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.ts
export function postComment(view: LiveView<Comment>, text: string) { view.insert({ text, author: session.getOrThrow("userName"), }); } // test.ts postComment(comments.view(), "hi");