---
title: Test a component in a real browser
description: Use @markless/vitest-browser to mount a component in the browser with render(), or render it on a server with renderSSR() and click the HTML.
sidebar: { label: Testing, badge: Experimental }
---

Types pass. But a type check never clicks a button.

`@markless/vitest-browser` runs your component in a real browser, and your test clicks it. The package is experimental.

The compiler turns `Counter.tsrx` into code that renders in many places. A test is one more place. The package gives you two ways in. Mount the component in the browser. Or render it to HTML on a server, then click that HTML.

A bug can hide in one way and not in the other. So write the check once and run it both ways.

Pick a way in. Then press **Run the test**.

<ToolTestPathsFigure />

Notice that the last three steps are the same both ways. Only the start differs: where your component runs.

| Helper | What it does |
| --- | --- |
| `render(Counter)` | Mounts `Counter` in the browser, the way an app without a server does. |
| `renderSSR(Counter)` | Renders `Counter` to HTML on the test server, then puts that HTML in the page. |

## Set it up

The starters do not include this setup yet, and the package has no README. This configuration comes from the Markless repo's own test projects.

```package-install
npm install -D @markless/vitest-browser vitest playwright
```

```bash
npx playwright install chromium
```

```ts vitest.config.ts
import { markless } from '@markless/core/vite';
import { testSSR } from '@markless/vitest-browser/ssr-plugin';
import { playwright } from 'vite-plus/test/browser-playwright';
import { defineProject } from 'vitest/config';

export default defineProject({
	plugins: [testSSR(), markless()],
	test: {
		include: ['src/**/*.browser.ts'],
		browser: { enabled: true, headless: true, provider: playwright(), instances: [{ browser: 'chromium' }] },
	},
});
```

Put `testSSR()` before `markless()`. Without it, `renderSSR()` throws.

## One check, both ways

Take the counter from the starter app.

```tsrx Counter.tsrx
import { state } from '@markless/core';

export default function Counter() @{
	let count = state(0);

	<button onClick={() => count++}>Count {count}</button>
}
```

Write the check once. Then run it both ways.

```ts counter.browser.ts
import { render, renderSSR } from '@markless/vitest-browser';
import { expect, test } from 'vitest';
import Counter from './Counter.tsrx';

async function clickCounts(container: HTMLElement) {
	const button = container.querySelector('button')!;
	button.click();
	await expect.poll(() => button.textContent).toBe('Count 1');
}

test('mounted in the browser: a click counts', async () => {
	const screen = await render(Counter);
	await clickCounts(screen.container as HTMLElement);
});

test('rendered on a server: a click counts', async () => {
	const screen = await renderSSR(Counter);
	await clickCounts(screen.container);
});
```

The package removes each mounted component after each test. Call `cleanup()` yourself only in the middle of a test.

:::danger[Why does renderSSR() throw "was not transformed"?]
`testSSR()` rewrites the text `renderSSR(Counter)` before the test runs. Write the call by name, with a component from its own `.tsrx` file and no props. A helper that gets `renderSSR` passed in as a value skips the rewrite.
:::

:::warning[Why does my check see the old text?]
The click code loads the first time someone clicks. So a read right after `click()` can see the old text. Wait with `expect.poll` before you compare.
:::

**Next:** A build failed with a long code in capital letters. What is it telling you? [Diagnostics →](/tooling/diagnostics)
