Testing Nuxt apps in the real browser, against a real backend

2 27 53
calendar_todayschedule3 min read
— Originally published at dev.to

You just built a Nuxt todo page. It loads from a Nitro route, adds a todo, deletes one, and it works when you click around. Now you want a test that proves it keeps working.

The usual answer is to leave the app, stand up a separate test stack, and rebuild the page in a fake environment with every route mocked. TWD takes the other path: test it where you already are, while you are still building it.

TWD runs your tests inside the Nuxt dev server, in the real browser tab where the app already runs. The same todo page gets tested end to end, against your real backend. No route mocks, no separate e2e stack, no jsdom.

Here is the full setup.

Setup

npm install --save-dev twd-js
npx twd-js init public --save

Add the Vite plugin in nuxt.config.ts:

import { twd } from 'twd-js/vite-plugin'

export default defineNuxtConfig({
  vite: {
    plugins: [
      twd({ testFilePattern: '/**/*.twd.test.{ts,tsx}' }),
    ],
  },
})

Two Nuxt-specific bits to wire up, both one-time.

1. Inject the sidebar in app.vue

Nuxt does not serve a Vite index.html. Nitro renders the HTML, so the plugin cannot auto-inject its sidebar script. Point your app at the virtual module yourself, dev-only, in app.vue:

<script setup lang="ts">
if (import.meta.dev) {
  useHead({
    script: [{ src: '/_nuxt/@id/virtual:twd/init', type: 'module' }],
  })
}
</script>

<template>
  <div>
    <NuxtPage />
  </div>
</template>

Note the /_nuxt/ prefix, that is Nuxt's Vite base. The import.meta.dev guard keeps the tag out of production.

2. Reload the browser when a test file changes

On Nuxt 4 with Vite 7, editing a .twd.test file does not reload the browser on its own. This small plugin fixes it: it watches your test files and triggers a reload when one changes.

// npm install --save-dev chokidar
import chokidar from 'chokidar'
import type { Plugin } from 'vite'

function twdTestHmr(): Plugin {
  const isTestFile = (file: string) => /\.twd\.test\.tsx?$/.test(file)
  return {
    name: 'twd-test-hmr',
    apply: 'serve',
    configureServer(server) {
      // Under Nuxt, Vite's config.root is the app/ srcDir, where the tests live.
      const watcher = chokidar.watch(server.config.root, {
        ignored: (path) => /node_modules|\.nuxt|\.output|\.git/.test(path),
        ignoreInitial: true,
      })
      const reload = (file: string) => {
        if (!isTestFile(file)) return
        // Drop the cached module so the reload serves fresh code.
        const graph = server.environments?.client?.moduleGraph ?? server.moduleGraph
        for (const mod of graph.getModulesByFile(file) ?? []) {
          graph.invalidateModule(mod)
        }
        server.ws.send({ type: 'full-reload', path: '*' })
      }
      watcher.on('change', reload).on('add', reload).on('unlink', reload)
      server.httpServer?.once('close', () => void watcher.close())
    },
  }
}

Add twdTestHmr() to the plugins array next to twd(). Edit a test, the browser reloads.

Run npm run dev. Sidebar on the left, your app on the right.

Nuxt app with twd tests running

Testing against the real backend

You do not mock routes. The tests hit your dev server and your database for real. The only thing you need is a clean starting point for each test.

A dev-only endpoint gives you that. It resets the database to a seeded baseline, guarded so it never exists in production:

// server/api/__test/reset.post.ts
export default defineEventHandler(() => {
  if (!import.meta.dev) {
    throw createError({ statusCode: 404, statusMessage: 'Not found' })
  }
  resetDb()
  return { ok: true, todos: listTodos() }
})

resetDb() deletes every row, resets the id sequence so seeded ids are deterministic, and re-seeds. The import.meta.dev guard means the route returns 404 in a production build.

Call it before each test, then drive the real UI:

import { twd, userEvent, screenDom } from 'twd-js'
import { describe, it, beforeEach } from 'twd-js/runner'

describe('Todos (real SQLite backend)', () => {
  beforeEach(async () => {
    // Reset the database to its seeded state. No route mocking.
    await fetch('/api/__test/reset', { method: 'POST' })
  })

  it('creates a todo end to end', async () => {
    await twd.visit('/todos')
    await userEvent.type(screenDom.getByTestId('todo-input'), 'Buy milk')
    await userEvent.click(screenDom.getByTestId('todo-add'))

    twd.should(await screenDom.findByText('Buy milk'), 'be.visible')
  })
})

No mock rules. The POST persists to the database, the list refreshes, and the assertion runs against the real rendered DOM. Delete a row and assert it is gone, same shape. When a test fails, it failed against the real backend, not against a mock you forgot to update.

Try it

  • Sample repo: BRIKEV/twd-nuxt-example. Nuxt 4, a SQLite-backed todo list, the HMR plugin, the dev-only reset endpoint, and tests that run against the real backend.
  • Docs: twd.dev
npm install --save-dev twd-js
npx twd-js init public --save

Add the plugin to nuxt.config.ts, drop the dev-only script into app.vue, add the HMR plugin, write your first .twd.test.ts. The sidebar opens itself.

🔥 Join developers growing publicly
Share your knowledge, build in public, and grow your developer presence with a global community.

More Posts

Your AI Doesn't Just Write Tests. It Runs Them Too.

Kevin Martinez - May 12

Local-First: The Browser as the Vault

Pocket Portfolio - Apr 20

How I Built a React Portfolio in 7 Days That Landed ₹1.2L in Freelance Work

Dharanidharan - Feb 9

I’m a Senior Dev and I’ve Forgotten How to Think Without a Prompt

Karol Modelskiverified - Mar 19

TypeScript Complexity Has Finally Reached the Point of Total Absurdity

Karol Modelskiverified - Apr 23
chevron_left
2.7k Points83 Badges
Spaintwd.dev
33Posts
12Comments
10Connections
Javascript developer and enthusiastic about web development and learning every day

Related Jobs

View all jobs →

Commenters (This Week)

1 comment
1 comment
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!