nuxt/test-utils: Practical testing for Nuxt 3 SaaS apps

You can bolt together pages, APIs, and auth in Nuxt 3 fast. Shipping without tests is still a coin flip. If you are here for a production-like test setup, this is how we use nuxt/test-utils to verify the exact flows a SaaS depends on before we deploy.
What nuxt/test-utils actually gives you
nuxt/test-utils boots a real Nuxt instance in your test runner. Your tests hit Nitro routes, render pages, resolve auto-imported composables, run route middleware, and load modules like i18n the same way they do in production. You get:
- Server-level tests with
$fetchthat return HTML or JSON from real routes. - Component tests with a Nuxt-aware mount so plugins, runtime config, and route context work.
- Helpers to mock Nuxt imports and composables without rewriting your app.
This is the right layer for cross-cutting SaaS features: protected pages, multi-tenant or i18n routing, payments and webhooks, an admin area, file storage, and cron-driven emails. A unit test will not catch a broken redirect or a missing Content-Language header. A nuxt/test-utils run will.
Set up a production-like test rig
Use Vitest as the runner, node as the default environment for server tests, and jsdom for DOM-heavy component tests.
- Install deps.
pnpm add -D vitest @nuxt/test-utils @vue/test-utils jsdom - Vitest config. Keep server tests fast by default.
// vitest.config.ts import { defineConfig } from 'vitest/config' export default defineConfig({ test: { environment: 'node', setupFiles: ['./tests/setup.test.ts'], globals: true, }, }) - Spin up Nuxt inside tests. Start in dev mode locally for speed. Use build mode in CI to catch production-only issues.
// tests/basic.test.ts import { describe, it, expect } from 'vitest' import { setup, $fetch } from '@nuxt/test-utils/e2e'await setup({ rootDir: process.cwd(), dev: true, // local dev feedback server: true, })
describe('home', () => { it('renders the home page title', async () => { const html = await $fetch('/') expect(html).toContain('<title>') }) })
CI switch:// tests/ci.setup.ts import { setup } from '@nuxt/test-utils/e2e' await setup({ rootDir: process.cwd(), build: true, server: true }) - Test a real server route.
// tests/api.health.test.ts import { describe, it, expect } from 'vitest' import { setup, $fetch } from '@nuxt/test-utils/e2e' await setup({ rootDir: process.cwd(), dev: true, server: true })describe('GET /api/health', () => { it('returns ok with caching headers', async () => { const res = await $fetch('/api/health', { responseType: 'json' }) expect(res).toMatchObject({ status: 'ok' }) }) }) - Mock Nuxt imports for app behavior. Replace composables like auth and runtime config.
// tests/auth.routes.test.ts import { setup, $fetch } from '@nuxt/test-utils/e2e' import { mockNuxtImport } from '@nuxt/test-utils/runtime' import { describe, it, expect, beforeEach } from 'vitest'await setup({ rootDir: process.cwd(), dev: true, server: true })
beforeEach(() => { // default: no session mockNuxtImport('useAuth', () => () => ({ user: null })) mockNuxtImport('useRuntimeConfig', () => () => ({ public: { baseURL: 'http://test' } })) })
describe('protected route', () => { it('redirects anonymous users to login', async () => { const html = await $fetch('/dashboard') expect(html).toContain('href="/login?redirect=%2Fdashboard"') })
it('renders for signed-in users', async () => { mockNuxtImport('useAuth', () => () => ({ user: { id: 'u1', role: 'user' } })) const html = await $fetch('/dashboard') expect(html).toContain('<h1>Dashboard</h1>') }) }) - Component tests with Nuxt context. Use a Nuxt-aware mount so plugins and route meta resolve.
// tests/components/i18n-toggle.test.ts import { describe, it, expect } from 'vitest' import { mountSuspended } from '@nuxt/test-utils/runtime' import LocaleToggle from '@/components/LocaleToggle.vue'describe('LocaleToggle', () => { it('switches language text', async () => { const wrapper = await mountSuspended(LocaleToggle, { route: '/' }) expect(wrapper.text()).toMatch(/English|Français/) }) }) - Stable data and time. Reset the database and freeze time for repeatable assertions.
// tests/setup.test.ts import { beforeAll, afterAll, beforeEach, vi } from 'vitest' import { db } from '~/server/utils/db' // your adapterbeforeAll(async () => { vi.useFakeTimers() vi.setSystemTime(new Date('2024-01-01T00:00:00Z')) })
beforeEach(async () => { await db.$transaction( db.session.deleteMany(), db.user.deleteMany(), db.subscription.deleteMany(), ) })
afterAll(() => { vi.useRealTimers() })
Cover the money-making flows first
Target the paths that turn visitors into revenue and support. We keep two tests per protected page, one for anonymous, one for signed-in, then extend that pattern across billing, i18n, and admin.
- Protected pages and authentication. Mock an anonymous user and assert a redirect to login with a preserved return URL. Then mock a valid session from any provider you support and assert the page renders the same post-login state. This catches middleware drift when you add a new provider.
- Payments and checkout. Your checkout route should return a payment URL or client secret and respect plan and billing interval. For webhooks, stub signature verification and assert the state change, not the provider API. Example:
// tests/billing.webhook.test.ts import { describe, it, expect, vi } from 'vitest' import { setup, $fetch } from '@nuxt/test-utils/e2e' await setup({ rootDir: process.cwd(), dev: true, server: true })describe('billing webhook', () => { it('upgrades plan on successful payment', async () => { vi.stubGlobal('verifyWebhook', () => true) const payload = { type: 'invoice.paid', data: { customerId: 'u1', plan: 'pro' } }
const res = await $fetch('/api/webhooks/billing', { method: 'POST', body: payload, responseType: 'json' }) expect(res).toMatchObject({ ok: true }) // Query your DB and assert the plan changed // const user = await db.user.findUnique({ where: { id: 'u1' } }) // expect(user.plan).toBe('pro')
}) })
- Language switching and SEO. Fetch the same page under two locales and assert visible text,
<title>,metaname=description,htmllang, and canonical orhreflangtags all switch. Also hit/sitemap.xmland ensure core URLs exist for each supported locale. - Admin area and RBAC. Mock a regular user and expect a 403 or redirect. Mock an admin and assert the dashboard renders and that actions like banning a user return the right status code and audit entry.
- AI features and uploads. Stub your model client so each request is called exactly once with sanitized inputs. For S3-compatible storage, replace the SDK with a fake adapter and assert files land in the expected bucket and signed URLs expire when intended.
- Cron jobs and transactional emails. Trigger your scheduled handler with a fixed date. Assert the right jobs are queued. For password resets and welcomes, verify your code enqueues the correct template with the right variables. Do not test the email provider.
Keep CI fast and reliable
- Split suites. Run a tiny smoke set in dev mode locally on save. Run a stricter build-mode suite in CI.
- Mock the edges, not the middle. Stub payment, email, AI, and storage SDKs. Leave your routing, middleware, and rendering real.
- Reset between tests. Clear DB tables, cookies, and any global mocks or mutated runtime config in
afterEach. - Hydration-aware checks. If a component renders only on the client, use a Nuxt-aware mount and wait for the element that proves hydration completed before asserting.
- Lock the environment. Use a dedicated
.env.test, pin Node in CI, and commit your lockfile. Differences here cause the passes locally, fails in CI pattern. - Verify assets once in build mode. Fetch a page that includes images or fonts and then request one referenced asset URL. Expect a 200 to catch public path mistakes.
Key takeaways
- Boot a real Nuxt instance with nuxt/test-utils so you test pages and APIs the way users hit them.
- Start with a smoke test, then cover auth, payments, i18n, and admin access end to end.
- Run fast dev-mode tests locally and a build-mode suite in CI. Stub external services but keep routing and rendering real.
- Assert user-visible outcomes and stable side effects, not internal call stacks.
If your SaaS includes a blog and you want launch content that compounds, this SEO content calendar template shows how to plan four weeks of posts in about an hour and pairs well with a backlink building service that secures dofollow links and fixes indexing so posts start pulling traffic.
Working inside a Nuxt SaaS starter kit means you begin with known patterns for auth, payments, i18n, admin, and deployment. Add the tests above on day one and you can ship faster without trading away quality.
FAQ
Does nuxt/test-utils work with Vitest or Jest?
Use Vitest. nuxt/test-utils is designed to run smoothly with Vitest for Nuxt 3 projects and gives you helpers to boot a Nuxt instance during tests.
Should I run tests in dev mode or after building the app?
Use dev mode for fast feedback while coding and build mode in CI to catch production-only issues like asset paths and Nitro routing differences.
How do I test protected pages that require login?
Mock your auth composable to simulate a signed-in or anonymous user, then fetch the protected route and assert on either the rendered page or the redirect.
How can I test i18n in a Nuxt app?
Fetch the same page under two locales and assert changes to visible text and meta tags. Provide a minimal translation dictionary in your test context.
What should I mock for payments and emails?
Mock the payment provider SDK and email sender. Assert your code sends the correct inputs and updates state. Do not call real external services in tests.
Recommended resources
Ready to ship your SaaS?
Nuxt Stripe payments for one-time and subscription billing
Set up Stripe payments in Nuxt: one-time checkout, subscriptions, trials, webhooks, testing, and launch steps with concrete examples you can ship today.
Nuxt vs Vue: How to Choose, Set Up, and Ship Fast Today
Nuxt vs Vue with concrete steps, quickstarts, SSR gotchas to avoid, and how a Nuxt SaaS starter kit helps you launch paid features and rank faster.