brumate

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();
});

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.

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();
});

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');
});
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.