# Tests Tests are a core part of Elements. We treat them as part of the build: if your tests are failing, your build is failing. Test errors are delivered alongside compile errors in the same diagnostic stream. Tests participate in the build loop and only rerun when their dependency graph changes. The project server runs a test server alongside it with a pool of concurrent test workers, so tests execute in parallel. The test API ships with `@elements/app`: `test`, `assert`, `equal`, `errorf`, and `fatalf`, composed through nested `test()` blocks. Each test body runs inside a Postgres transaction that is rolled back afterward, so a test does not need setup or teardown. Save a file and tests run. ## 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("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. ```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'`); }); ``` 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 `.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. ```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 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. Tests that did not depend on the changed code do not re-run. The reason this is fast: - Multiple test runners execute tests in parallel. - Only tests whose dependency graph changed re-run. - Each test is isolated (a transaction at the top level, a savepoint when nested), so there is no per-test setup cost beyond opening it. Test errors are reported alongside compile errors in the same diagnostic stream. Failed tests block deploys. The deploy pipeline runs tests on every machine before the new release is swapped in. ## 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. ```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("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. Note that 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 ```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. ## 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. ```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); } assert(threw); }); ``` 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. ## 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.