Concepts

Testing

Test components with Vitest: render into a DOM, interact, flush updates and assert, and test server rendering as HTML strings.

#Concept

Vitest uses your Vite config, so lumiose() compiles .stsx and .tsx test subjects the same way as the app. Two things matter for component tests:

  • A DOM environment. Use happy-dom (or jsdom), per file with // @vitest-environment happy-dom or for all tests with test.environment.
  • The browser build of lumiose. lumiose has a browser entry (signals, the DOM renderer) and a node entry (server rendering, where signals are plain values and effects never run). Vitest resolves Node conditions, but lumiose() gives tests in a DOM environment the browser entry of lumiose, so rendered components update. Tests without a DOM environment keep the node entry.

Updates are batched: after a write or an event outside a delegated handler, call flush() before asserting. Delegated events (onClick) need the element to be attached to document.

Server rendering is tested with renderToString(() => <Page />) under the node entry: a test file without a DOM environment, in the same project.

Scoped state is fresh in each test. lumiose() adds a Vitest setup file that resets defineState instances before each test, so one test's cart never leaks into the next. In server tests, a state's accessor called outside a render (to seed or inspect it) uses one instance per test, which renderToString then shares.

Status: @lumiose/testing (a render() helper and Testing Library integration) is planned. Until then, use render from lumiose/dom as shown here.

#API

API Use
render(() => <App />, container) from lumiose/dom Render into a DOM node; returns a disposer.
flush() Run pending bindings and effects now.
renderToString(() => <App />) Server-render to a string (node entry).

#Example

ts
// vitest.config.ts
import lumiose from "@lumiose/vite";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [lumiose()],
  test: { environment: "happy-dom" },
});
Counter.test.tsx
// src/Counter.test.tsx
import { flush } from "lumiose";
import { render } from "lumiose/dom";
import { expect, it } from "vitest";
import { Counter } from "./Counter.stsx";

it("counts clicks", () => {
  const container = document.body.appendChild(document.createElement("div"));
  const dispose = render(() => <Counter start={2} />, container);

  const button = container.querySelector("button")!;
  button.click();
  flush();

  expect(button.textContent).toContain("Clicked 3 times");
  dispose();
  container.remove();
});

#Common mistakes

  • No DOM environment. Without happy-dom (or jsdom) there is no document, and lumiose resolves to its node entry, which has no reactive graph: set the environment for component tests.
  • Asserting right after a write. Effects and bindings run in a microtask; call flush() first.
  • Clicking a detached element. Delegated handlers listen at document; append the container to document.body.
  • Not disposing. Call the disposer returned by render so effects and listeners from one test do not leak into the next.
  • Testing server components in a DOM environment. Async server components that use request(), cookies() or *.server.ts modules belong in server-side tests with renderToString.
  • LUM4003: server-only module in a client graph (also renderToString called with the browser entry).
  • LUM4004: browser API at module scope.

See also: Getting started, reference: lumiose/dom, reference: server.