Running Vue component tests in a real browser tab

For years, my frontend JavaScript projects have gone untested. The typical testing stack — Playwright spinning up browser processes, orchestrated by Node — felt heavy and awkward for what I wanted to do. I wanted end-to-end integration tests for my Vue components, but without relying on a server-side JavaScript runtime as part of the workflow.

It turns out you can run those tests directly in a browser tab. I recently set this up for a Vue-based project and was surprised at how straightforward it was. The whole approach follows the pattern Alex Chan described for framework-less JavaScript testing: load your app in a page, run assertions against the live DOM, and let the test framework handle reporting.

QUnit as the test runner

I chose QUnit for the test framework. It works well in this context and has one feature I especially appreciated: a "rerun test" button that reruns only a single test. Since my tests make many network requests, being able to isolate one test made debugging far less confusing. The framework's browser setup instructions were all I needed.

Exposing components for testing

To make Vue components available in the test environment, I modified the main app to register all components globally:

const components = {
  'Feedback': FeedbackComponent,
  ...
}
window._components = components;

With components exposed, I wrote a mountComponent helper that renders a component into a temporary, invisible div. It works like the normal app bootstrap, with two differences: it accepts extra prop data, and the target div is positioned off-screen (position: absolute; top: -10000, ...) so it won't interfere with the visible page.

Usage looks like this:

const {div} = mountComponent(
  '<Page :feedbacks="feedbacks" id=2 />',
  {feedbacks: [testFeedback]},
);

And here is the implementation:

function mountComponent(template, data) {
  const app = Vue.createApp({
    template: template,
    data: () => data,
  })
  for (const [c, v] of Object.entries(window._components)) {
    app.component(c, v);
  }
  const div = document.getElementById('qunit-fixture')
             .appendChild(document.createElement('div'));
  return div;
}

The result is a DOM element I can programmatically click on, fill out forms in, and inspect for expected content.

Seeding test data

These are true integration tests — they exercise client JavaScript against a real server. That meant the database needed known state. I wrote roughly 25 lines of SQL to insert test fixtures, plus an endpoint on the dev server that resets the test data to that known state on demand.

async function reset() {
    return fetch('/api/reset_test_data', {method: "POST"})
}

Tests that need data start with await reset() to bring the database back to a clean slate before running.

A minimal test case

Here's what a basic test looks like — mount a component and assert that the rendered DOM contains the expected content:

QUnit.test('renders feedback content', async function (assert) {
  const {div} = mountComponent(
    '<Page :feedbacks="feedbacks" id=2 image=2 page_hash=2 />',
    {feedbacks: [testFeedback]},
  );
  assert.ok(div.textContent.includes('loved this section'));
})

Handling async rendering

Network requests and reactive Vue updates mean the DOM takes time to settle after an action. Random sleep() calls in tests are always slow and flaky, so I wrote a small polling helper instead. waitFor() checks a condition every 20ms and times out after 2 seconds:

QUnit.test("click item", async function (assert) {
  const {div} = mountComponent(
    '<Feedback zine_id="test123" image_width="800px" />',
    {});
  const item = await waitFor(() => div.querySelector('.feedback-item'));
  item.click();
  // rest of test goes here... 
})

There are more polished implementations of this concept (like qunit-wait-for or Playwright's expect.poll), but for a small project a hand-rolled version gets the job done.

Choosing what to wait for

The hardest part was identifying the right DOM condition to wait on. In one case, I assumed a textarea appearing meant the component was ready, but internal component logic meant it wasn't actually safe to proceed. I ended up adding a custom attribute (like data-this-thing-is-ready=true) to a DOM element after a critical async action completed — a pattern that felt slightly awkward.

The underlying issue is probably best fixed by refactoring the component itself: if an element in the DOM isn't ready for user interaction, it probably shouldn't be visible yet. That approach improves both testability and the user experience.

Selecting elements in tests

For elements I needed to find, click, or wait for, I added CSS classes. The testing library ecosystem generally recommends avoiding raw CSS classes in favor of role-based queries (getByRole) or dedicated attributes like data-testid. Using more accessible selection strategies would likely make the app both easier to use and easier to test.

Form interactions require events

Setting an input's value property isn't enough — you also have to dispatch an event so Vue knows the element changed. Different form controls require different event types:

textarea.value = 'banana banana banana';
textarea.dispatchEvent(new Event('input'));
checkbox.checked = true;
checkbox.dispatchEvent(new Event('change'));

This friction highlights why libraries like Testing Library and Vue Test Utils exist — they wrap form handling in much friendlier APIs.

Coverage without extra tooling

For a rough sense of test coverage, I used Chrome's built-in code coverage panel. Since my JavaScript bundles to a single bundle.js file via esbuild, inspecting that file showed which lines never ran. It was slightly finicky — sourcemaps needed to be disabled in DevTools — but it gave useful coverage information with zero additional libraries.

Remaining open questions

Two things are still on my mind. First, I'm exploring whether to shift to Testing Library for its more opinionated testing patterns, since it ships a .umd.js bundle that works without Node. Second, I haven't yet solved running browser-based tests from the command line for CI. It would be nice to develop in the browser but still be able to execute the same suite non-interactively when needed.

For now, though, having a test suite that runs in the same browser where my app lives has already made the project feel much less fragile.