Manual Tests

Tests

elements man tests Read as markdown

Tests are part of the build. If your tests are failing, your build is failing: test errors arrive in the same diagnostic stream as compile errors, and a release does not happen until every test passes. Save a file and the tests whose dependencies changed run, on a pool of concurrent workers, each inside a Postgres transaction that rolls back when it ends. Results land in the editor, in elements build -json, and in the test tree.

The test API ships with @elements/app: test, assert, equal, errorf, and fatalf, composed through nested test() blocks. No runner to configure, no setup or teardown.

At a Glance

// app/lib/utils.test.ts
import { test, assert, equal, errorf, fatalf, sql } from "@elements/app";

test("users", () => {
  test("create", () => {
    let user = sql<User>(`insert into users (name, email) values ('alice', 'a@x.com') returning *`).firstOrThrow();
    assert(user.name === "alice");
    equal(user.email, "a@x.com");
  });

  test("validate email shape", () => {
    if (!"alice@x.com".includes("@")) {
      errorf("expected %v to contain @", "alice@x.com");
    }
  });

  test("hits the database", () => {
    let row = sql<{ n: number }>(`select 1 as n`).firstOrThrow();
    equal(row.n, 1);
  });
});

That is a complete test file. No setup, no teardown, no beforeEach. The transaction wrapper handles cleanup. Save the file and tests run.

The API

Sync-style. The compiler propagates async automatically.

  • test(desc, fn): defines a test block. Nested test() calls produce child blocks. Tests run sequentially within a parent.
  • assert(cond, msg?): records an error if cond is false. Continues.
  • equal(actual, expected, msg?): deep equality. Records an error showing both values if not equal. Continues.
  • errorf(fmt, ...args): records a formatted error. Uses %v placeholders. Continues.
  • fatalf(fmt, ...args): records a formatted error and stops the current test block immediately. Same %v placeholders as errorf.

There is no async variant. test takes an async callback, so there is nothing to switch to; see the sync-style section below. The assertions themselves are synchronous because they record errors and return.

Auto-Rollback Transactions

Every test() is isolated and rolls back when it ends. A top-level test runs in its own transaction; a nested test runs in a savepoint on its parent's connection. Either way the rows a test writes are gone when it finishes, and a sibling never sees them. No cleanup code needed.

test("users", () => {
  test("insert a row", () => {
    sql(`insert into users (name) values ('alice')`);
    let count = sql<{ n: number }>(`select count(*) as n from users`).firstOrThrow();
    equal(count.n, 1);
  });

  test("does not see the previous test's row", () => {
    let count = sql<{ n: number }>(`select count(*) as n from users`).firstOrThrow();
    equal(count.n, 0);
  });
});

sql() calls inside the test automatically use the transaction. Nothing to pass.

A SQL error in one test rolls back to that test's savepoint, so the next test still runs against a clean connection.

Opting Out of the Transaction

Some tests need real commits: verifying a commit-time trigger, or code that spans several transactions. Add the @test tx=false build tag to the file. Its tests then commit for real, and cleanup is yours to handle.

/** @test tx=false */

test("real commit path", () => {
  tx(() => {
    sql(`insert into events (name) values ('committed')`);
  });

  let row = sql<Event>(`select * from events where name = 'committed'`).firstOrThrow();
  equal(row.name, "committed");

  sql(`delete from events where name = 'committed'`);
});

Opting out is per file, not per test: a real commit cannot live inside an outer rollback, so the setting applies to the whole file.

File Layout

Test files are named test.ts or <name>.test.ts. They live anywhere in the tree.

app/
  pages/home/
    index.ts
    template.html
    test.ts                  # tests for the home page
  lib/
    utils.ts
    utils.test.ts            # tests for utils
    parser.test.ts           # tests for parser

Scaffold a test for an existing file:

elements create test app/lib/utils.ts

Session in Tests

session is available inside tests. The session is a fresh empty session per test body. Log in to it explicitly when a test needs an authenticated context.

import { test, session, sql } from "@elements/app";

test("my profile returns the logged-in user", () => {
  let user = sql<User>(`insert into users (name) values ('alice') returning *`).firstOrThrow();
  session.login({ userId: user.id, userName: user.name });

  let me = getMyProfile();
  equal(me.id, user.id);
});

The session is per-test. The next test starts with no logged-in user. No teardown needed.

Running Tests

Tests run automatically as part of the build. Every save triggers any tests whose dependencies changed. Failed tests block the build.

Watch mode is the default in a tty:

elements test                          # all tests, watch mode
elements test app/pages/home/test.ts   # specific file
elements test app/lib/*.test.ts        # shell glob expands to matching files

You get an interactive tree view. The same view is used by elements start when tests fail.

For machine-readable output:

elements test -json
elements test path/test.ts -json

-json returns the test tree as structured data. Build and test state lives in the project server, so elements test -json is instant and atomic across multiple callers.

Tests as Part of the Build

In Elements, "build" means: get the app ready for release. Tests are part of that. Every save runs the tests whose source dependencies changed, and only those: a test that did not depend on the changed code does not re-run, and a test whose dependencies have an error does not run at all until the error is fixed. Failing tests block the release, so .elements/release only ever holds an app whose tests passed.

Why this is fast enough to run on every save:

  • A pool of test workers, one per CPU by default, runs test files in parallel.
  • Only tests whose dependency graph changed re-run. The rest report their cached result.
  • The workers are hot reloaded like the app: a save swaps the changed modules into running worker processes rather than starting new ones.
  • Each test is isolated by a transaction at the top level and a savepoint when nested, so there is no per-test setup beyond opening it.

There Is No CI Layer

The loop that runs tests on your save is the same loop that runs them on a deploy. elements deploy builds on the target machine against its own test database, and the release does not go live until every test passes there; a failure aborts the deploy and the previous release stays up (see deploy/environments). Tests therefore run in three places with one definition: your editor as you type, your machine when you build, and the machine you ship to before it switches over. There is no pipeline to write, no runner to keep in sync with local, and no waiting for a queue to tell you what your editor already told you.

Two Databases

Every environment, development included, has an app database and a test database, <app> and <app>_test. Migrations run against the test database before tests, and against the app database before release. Tests never touch the data you are looking at in the browser, and a migration that breaks a test never reaches the app database.

Sync-Style Code

Tests are sync-style. The compiler converts to async at build time. You don't need to write async or await unless you want to. The test body reads as a sequence of statements.

test("create", () => {
  let user = sql(`insert into users (name) values ('a') returning *`).firstOrThrow();
  assert(user.name === "a");
});

test accepts an async callback, so a body may be sync, async, or a mix of both. All three of these are correct:

test("sync", () => { post("a"); });
test("async", async () => { await post("b"); });
test("mixed", async () => { await post("c"); count(); });

Do not write await on a sync-style call. The compiler inserts it. If you write it yourself you get "'await' expressions are only allowed within async functions". The obvious fix, marking the callback async, is a detour rather than a repair. Delete the await instead.

There is no testAsync. It used to exist and it was a trap: it was test minus the compiler's await, which bought nothing and cost isolation. A nested block with no await registers its reporting node but leaves nobody owning its promise, and the savepoint isolation described above depends on nested tests running serially.

Promise.all needs no special variant either. It works in a test body like anywhere else. It will not make queries concurrent inside a test: a test runs on one connection and Postgres serialises on it. Two 300 ms queries through Promise.all take 600 ms, the same as writing them one after the other.

Viewing the Test Tree

elements test (in a tty) opens a live tree view. Tests are organized by file and nested test() blocks. Each leaf shows pass, fail, or skip with the failure message inline.

Keyboard:

  • j / k: navigate.
  • Enter: expand or collapse.
  • y: copy the current selection.
  • ctrl-c: exit.

The tree updates live as new test results arrive.

Nesting

test("login", () => {
  test("with valid credentials", () => { /* ... */ });
  test("with invalid credentials", () => { /* ... */ });
  test("with locked account", () => { /* ... */ });
});

Nesting is for organization. Tests run sequentially within a parent. Each child runs in its own savepoint on the parent's connection and rolls back when it ends, so siblings stay isolated. Rows a parent writes before its children run are visible to every child.

What Counts as a Test Failure

  • assert(false): records Assert, continues.
  • equal(actual, expected) mismatch: records Equal with both values, continues.
  • errorf(...): records Errorf, continues.
  • fatalf(...): records Fatal and stops the block.
  • Any thrown Error outside of fatalf: records Crash, stops the block.
  • A test that exceeds its timeout: records Timeout, stops the block.

fatalf is the explicit "stop now" signal. Everything else accumulates errors and lets the test continue, which is useful for assertions in a loop.

assert is also usable in application code, and there it depends on whether a test is on the stack. In a request it throws, as you would expect. Called from code a test is exercising, it records a failure against that test and lets execution continue, so the same line behaves differently under test than it does in production. That is deliberate, and it means an assert buried in app code can fail a test at a line that is not in the test file.

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.

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}` the interesting case reports nothing useful: when the call throws something you did not expect, which is usually a real bug rather than the rejection you were testing for, the assertion fails with assertion failed and the actual error, its type and its message are all discarded. Interpolating err costs nothing when the test passes and is the whole diagnosis when it does not.

Note too that a failed assert records and continues, and it anchors to the line the assert 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.

The threw flag is what makes the test fail when the call unexpectedly succeeds. Without it, a call that never throws would let the test pass even though it should fail. 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.

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("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.html 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");

Test-First Development

Write failing tests first. Define the behavior in a test, watch it fail, then implement until it passes. The build loop makes this fast.

1. write test.ts that calls the not-yet-existent function and asserts the expected result.
2. save. elements build -json shows the test failing.
3. implement the function.
4. save. elements build -json shows the test passing.

Why this works in Elements specifically:

  • The build loop is the feedback signal. Saving a file is the trigger.
  • elements build -json returns structured pass/fail per test. An agent can poll it deterministically.
  • Auto-rollback transactions mean tests do not leave residue between iterations.
  • Tests run only the affected subgraph.

Change the code, not the test. When a test fails, the fix goes in the code under test. You make the red report go green by implementing the behavior, never by weakening, deleting, or rewriting the test to match what the code happens to do. The test is the specification; edit it only when the specification itself changes.

Debugging

  • The build state shows the failure with file path and line number.
  • elements test path/test.ts runs only that file in the live viewer.
  • console.log from inside a test body shows in the test output and in .elements/logs/project.log.
  • The auto-rollback means you cannot inspect db state with elements db after a failure. To inspect, add @test tx=false to the file and read the rows back.

Related

  • elements man database: the test database and the auto-rollback transaction.
  • elements man build: tests as part of the build pipeline.