# Tests 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 ```ts // app/lib/utils.test.ts import { test, assert, equal, errorf, fatalf, sql } from "@elements/app"; test("users", () => { test("create", () => { let user = sql(` 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. ```ts test.ts test("users", () => { test("insert a row", () => { sql(` insert into users ( name ) values ( 'rollback check' ) `); let count = sql<{ n: number }>(`select count(*) as n from users where name = 'rollback check'`).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 where name = 'rollback check'`).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. ## Committing for Real 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. ```ts test.ts /** @test tx=false */ test("real commit path", () => { tx(() => { sql(` insert into events ( name ) values ( 'committed' ) `); }); let row = sql(` select * from events where name = 'committed' `).firstOrThrow(); equal(row.name, "committed"); sql(` delete from events where name = 'committed' `); }); ``` The tag is per file: a real commit sits outside any outer rollback, so the setting applies to the whole file. ## File Layout Test files are named `test.ts` or `.test.ts`. They live anywhere in the tree. ```text Directory layout app/ pages/home/ index.ts template.ehtml 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: ```bash Terminal 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. ```ts test.ts import { test, session, sql } from "@elements/app"; test("my profile returns the logged-in user", () => { let user = sql(` 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 next test starts with no logged-in user. No teardown needed. ## Running Tests Watch mode is the default in a tty: ```bash Terminal 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. Each test is followed by its assertion count, `home (2)`: the `assert()` and `equal()` calls its own body made, passing or failing. A `(0)` is a test that checked nothing. The same view is used by `elements start` when tests fail. For machine-readable output: ```bash Terminal 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, `` and `_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. ## Tests That Pass Every Time A test that passes on one build and fails on the next is a broken test. Three facts about the test database cause nearly all of them. **It holds the rows your migrations insert, seed rows included.** In development the test database gets the same migrations as the app database, so `users` is not empty when a test starts. Assert on the rows the test created, found by the ids it got back. Never count a whole table, take the first row of an unfiltered query, or match on a value a seed row could also have: ```ts Scoped to the test's rows // wrong: a seed user can come back first let u = sql(`select handle from users`).firstOrThrow(); // right let u = sql(`select handle from users where id = ${created.id}`).firstOrThrow(); ``` Do not delete the seed rows to get an empty table. The delete locks every row it removes, and the test files running beside it wait on those locks. **Test files run at the same time, each in its own transaction.** Two files that insert the same value into a unique column wait on each other until one rolls back, and two that do it in opposite orders deadlock. A fixture that inserts into a unique column makes the value unique on every call: ```ts Unique per call export function makeUser(name = "Ada") { let email = `${name.toLowerCase()}.${crypto.randomUUID().slice(0, 8)}@test.dev`; return sql(`insert into users (name, email) values (${name}, ${email}) returning *`).firstOrThrow(); } ``` **Its clock is the real clock, in UTC.** A row stamped 30 minutes ago belongs to yesterday from 00:00 to 00:30 UTC, so a test that expects it in today's count fails for half an hour every day. Stamp rows that must land in today with `now()`, and build expectations for a day, week or month from the same timestamp the query uses. ## Sync-Style Code `test` supports the async transform, so a test body that calls `sql` or another supported function needs no `async` and no `await`. The compiler adds them at build time, and the body reads as a sequence of statements. ```ts test.ts 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: ```ts test.ts 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". Marking the callback `async` is a detour; delete the `await` instead. **`test` is the one test function.** It handles sync, async and mixed bodies, and the compiler awaits each nested block, so nested tests run serially and the savepoint isolation described above holds. `Promise.all` works in a `test` body like anywhere else. A test owns one connection and Postgres runs its queries in order, so two 300 ms queries through `Promise.all` take 600 ms, the same as writing them one after the other, and the results are deterministic. ## 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 ```ts test.ts 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` also checks invariants in application code. In a request it throws. Called from code a test is exercising, it records a failure against that test and lets execution continue, so one line guards production and reports under test, and the failure points at the app line that holds the `assert`. ## 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. ```text Workflow 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 -service project`. - To inspect rows after a failure, add `@test tx=false` to the file so its writes commit, then read them back with `elements db`. ## Related - `elements man database`: the test database and the auto-rollback transaction. - `elements man build`: tests as part of the build pipeline.