Testing
brumate/testing renders a component on the server and resumes it in the DOM of a test, the same way a page does. A test clicks and types like a user, and checks what the user would see, with the code that ships. visit does the same for a route of the app, with its layouts, middleware and client navigation.
Set up
Use the brumate plugin in the Vitest config, with a DOM environment such as happy-dom:
pnpm add -D vitest happy-dom
// vitest.config.ts
import brumate from 'brumate/vite';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [brumate()],
test: { environment: 'happy-dom' },
});
A first test
// test/counter.test.ts
import { expect, it } from 'vitest';
import { mount } from 'virtual:brumate/testing';
import Counter from '../src/Counter.brm?brumate-server';
it('counts', async () => {
const page = await mount(Counter, { start: 1 });
expect(page.text('button')).toBe('Clicked 1 times');
page.click('button');
await page.waitFor(() => expect(page.text('button')).toBe('Clicked 2 times'));
page.unmount();
});
mount(component, props)renders the component on the server, puts the HTML into the document, and starts the loader.- The loader loads the client code of the component, as in a browser. An event before that waits for it, so its effect arrives a moment later. Check it with
waitFor. - Call
unmount()at the end, so the next test starts with an empty document. - A test can mount several components, one after another or at the same time. Each mount gets scope IDs of its own, and they share one loader, as the components of a page do.
Why ?brumate-server
The test runs in a DOM environment, so Vite gives it the browser version of a .brm file by default. The browser version cannot render a page. ?brumate-server imports the server version, which mount renders. The components that it renders are server versions too.
What mount returns
| Member | What it does |
|---|---|
container |
The element that holds the component. |
get(selector) |
The first element that matches, or an error if none does. |
text(selector) |
The text of the first element that matches. |
click(selector) |
Clicks an element. |
input(selector, value) |
Types a value into a field and fires input. |
submit(selector) |
Submits a form, as its submit button does. |
reveal(selector) |
Brings elements that offscreen kept out of view into view. |
waitFor(assertion, timeout) |
Retries an assertion until it passes, for 2 seconds by default. |
unmount() |
Stops the loader and removes the component. |
mount takes the options of render as its third argument, such as a request for components that read it with getEvent(), and offscreen. See Triggers.
Content that streams in
A #loading body streams into the document when its data is ready, as on a page. Check the fallback first, then wait for the content:
it('shows the quote once it loads', async () => {
const page = await mount(App, {});
expect(page.container.textContent).toContain('Loading a quote');
await page.waitFor(() => expect(page.text('blockquote')).toBe('Ship less code to the browser.'), 3000);
page.unmount();
});
Pass a longer timeout to waitFor when the data takes a while.
Triggers
The triggers of $client, #client and #defer fire in a test:
| Trigger | When it fires in a test |
|---|---|
'load' |
When the loader starts. |
'visible' |
At once, since mount and visit report each element that the page observes as in the viewport. |
'idle' |
A moment after the page starts. happy-dom has no requestIdleCallback, so the loader uses a timer. |
- Check the effect with
waitFor, since each trigger runs after the loader starts. mountandvisitinstall their own IntersectionObserver, because the one of happy-dom never reports.unmount()andclose()put the observer of the environment back.- A test needs no action for
'idle'. It fires by itself, as it does in a browser.
To test what waits for an element, keep the element out of view with offscreen, a selector. The element is reported when the test calls reveal(selector):
it('measures the chart when it comes into view', async () => {
const page = await mount(Report, {}, { offscreen: '.chart' });
expect(page.get('.chart').dataset.drawn).toBeUndefined();
page.reveal('.chart');
await page.waitFor(() => expect(page.get('.chart').dataset.drawn).toBe('true'));
page.unmount();
});
visit(path, { offscreen }) takes the same option.
Server functions
A server function that the component calls from a handler or a memo goes through fetch, which a test has no server for. answerCalls() from virtual:brumate/testing answers these calls in the test process, with the server code of the app. Return it from beforeEach, and Vitest restores fetch after each test:
import { beforeEach, expect, it } from 'vitest';
import { answerCalls, mount } from 'virtual:brumate/testing';
import App from '../src/App.brm?brumate-server';
import { resetTodos, todos } from '../src/db.ts';
let calls: string[] = [];
beforeEach(() => {
resetTodos();
calls = [];
return answerCalls({ onCall: (id) => calls.push(id) });
});
it('saves a new todo', async () => {
const page = await mount(App, {});
page.input('input', 'Ship it');
page.submit('form');
await page.waitFor(() => expect(page.text('ul + p')).toBe('1 left'));
expect(todos).toHaveLength(1);
expect(calls).toHaveLength(1);
page.unmount();
});
- Each call goes through
handleRequest, as on a server. Arguments and results are serialized,getEvent()works, andevent.refreshloads memos again. - The functions are those of
virtual:brumate/server: the functions of components and of'use server'files. They load on the first call. - They share modules with the test, such as
src/db.tsabove, so the test can set up data and check what a call changed. onCallgets the ID of each call, such asdb_1a2b#addTodo. The name of the function is after the#.- Requests to other URLs go to the
fetchfrom before.
Why the test gets the server code
A DOM test gets the browser version of a file by default, so a 'use server' file that the test imports is the stubs, which call fetch. The server version of a component imports the server code of 'use server' files instead, and so does virtual:brumate/server. Other modules, such as src/db.ts, are the same copy for the test and for the server code.
brumate/testing exports answerCalls(functions, options) too, for functions of your own by ID. A server function that calls a stub throws an error that names it. Without the error, the call would answer itself through fetch without end.
Routes
visit(path) answers a path with the routes of the app, puts the page in the document and starts the loader with client navigation. Every request of the page goes to the same routes, in the test, without a server:
import { afterEach, expect, it } from 'vitest';
import { visit } from 'virtual:brumate/testing';
let page: Awaited<ReturnType<typeof visit>> | undefined;
afterEach(() => {
page?.close();
});
it('signs in and opens a new issue', async () => {
page = await visit('/issues/new');
// The middleware sent the visitor to the sign-in page.
expect(page.url).toBe('/sign-in?next=%2Fissues%2Fnew');
page.input('input[name=name]', 'Ada');
page.submit('form');
await page.waitFor(() => expect(page!.url).toBe('/issues/new'));
expect(page.text('header .user span')).toBe('Ada');
});
- The page renders with its layouts and middleware, as on a server. A redirect is followed, and
urlis where it ended. - Links use client navigation. Forms, server function calls and requests for
#deferbodies go to the routes and tovirtual:brumate/server. - The cookies that the answers set are kept and sent with the next requests, so a test can sign in and go on.
cookieshas them by name. visit(path, { cookies })starts with cookies, such as those of an earlier visit.headersare sent with every request.- Call
close()at the end. It stops the loader, putsfetchback and empties the document. A new visit closes the one before.
| Member | What it does |
|---|---|
url |
The path and query of the page. It changes with client navigation. |
status |
The status of the first page. |
cookies |
The cookies that the browser has now, by name. |
close() |
Stops the loader, puts fetch back and empties the document. |
get, text, click, input, submit, reveal and waitFor work as they do for mount, on the whole document.
Requests to other origins go to the fetch from before. Use visit or mount in a test, not both, since each starts a loader.
Type-checking tests
brumate-tsc checks test files with the rest of the project. Add vitest types if your tests use globals. See TypeScript.