Skip to content
SaaS4Builders
Testing

Frontend Testing (Vitest)

How to write frontend tests: Vitest configuration, Nuxt test utilities, mock factories, store tests, composable tests, component tests, API module tests, and coverage.

The frontend uses Vitest 3.2 with @nuxt/test-utils and @vue/test-utils for testing. Tests are colocated with their features in __tests__/ directories, following the vertical-slice architecture. The test suite covers four main categories: Pinia store tests, composable tests, Vue component tests, and API module tests.


Vitest Configuration

The project uses defineVitestConfig from @nuxt/test-utils/config, which sets up a Nuxt-aware test environment automatically:

frontend/vitest.config.ts
import { defineVitestConfig } from '@nuxt/test-utils/config'

// Ensure nuxt-studio module doesn't throw during test Nuxt initialization
process.env.STUDIO_REPOSITORY_OWNER ??= 'test'
process.env.STUDIO_REPOSITORY_NAME ??= 'test'

export default defineVitestConfig({
  test: {
    environment: 'nuxt',
    environmentOptions: {
      nuxt: {
        domEnvironment: 'happy-dom',
      },
    },
    include: ['**/*.{test,spec}.{js,ts,vue}'],
    exclude: ['node_modules', '.nuxt', '.output', 'dist'],
    globals: true,
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: [
        'node_modules/**',
        '.nuxt/**',
        '.output/**',
        'dist/**',
        '**/*.d.ts',
        '**/*.config.*',
        '**/types/**',
      ],
    },
  },
})

Key configuration choices:

  • environment: 'nuxt' — Bootstraps a real Nuxt context for tests, so auto-imports, runtime config, and composables work as expected
  • happy-dom — A lightweight DOM implementation (faster than jsdom) used for rendering Vue components
  • globals: true — You do not need to import describe, it, expect, vi, or beforeEach in every test file — they are available globally
  • v8 coverage — The v8 provider generates coverage reports in text, JSON, and HTML formats

Test File Organization

Tests live alongside the feature code they test, inside __tests__/ directories:

features/core/docs/billing/
├── api/
│   ├── billing.api.ts
│   └── __tests__/
│       └── billing.api.test.ts          # API module tests
├── composables/
│   ├── useSubscription.ts
│   └── __tests__/
│       └── useSubscription.test.ts      # Composable tests
├── components/
│   ├── UsageMeterCard.vue
│   └── __tests__/
│       └── UsageMeterCard.test.ts       # Component tests
├── schemas.ts
├── types.ts
└── __tests__/
    └── schemas.test.ts                  # Schema validation tests

Shared test helpers live in a dedicated directory at the project level:

frontend/tests/helpers/
├── mockSession.ts      # createMockUser(), createMockTenant(), TEST_IDS
├── mockCatalog.ts      # createMockPlan(), createMockProduct(), TEST_CATALOG_IDS
└── mockTeam.ts         # createMockTeamMember(), createMockInvitation()

Mock Factories

The project provides centralized mock factory functions that mirror the Zod schemas used in production code. Each factory returns a valid default object and accepts an overrides parameter for customization.

Test IDs

Pre-defined constants ensure consistency across test files:

frontend/tests/helpers/mockSession.ts
/**
 * Pre-defined IDs for common test scenarios.
 * User IDs are numbers (auto-increment), Tenant IDs are UUIDs (strings).
 */
export const TEST_IDS = {
  // User IDs are numbers
  user1: 1,
  user2: 2,
  admin: 99,
  // Tenant IDs are UUID strings
  tenant1: '660e8400-e29b-41d4-a716-446655440001',
  tenant2: '660e8400-e29b-41d4-a716-446655440002',
  tenant3: '660e8400-e29b-41d4-a716-446655440003',
} as const

Factory Functions

Each factory returns a fully typed default object and merges overrides via the spread operator:

frontend/tests/helpers/mockSession.ts
export function createMockUser(overrides: Partial<User> = {}): User {
  return {
    id: TEST_IDS.user1,
    email: 'test@example.com',
    name: 'Test User',
    avatar: null,
    emailVerifiedAt: '2024-01-01T00:00:00Z',
    ...overrides,
  }
}

export function createMockTenant(overrides: Partial<Tenant> = {}): Tenant {
  return {
    id: TEST_IDS.tenant1,
    name: 'Test Tenant',
    slug: 'test-tenant',
    ...overrides,
  }
}
frontend/tests/helpers/mockTeam.ts
export function createMockTeamMember(overrides: Partial<TeamMember> = {}): TeamMember {
  return {
    id: TEST_IDS.user1,
    name: 'Test User',
    email: 'test@example.com',
    role: { id: 3, name: 'member' },
    joinedAt: '2024-01-01T00:00:00Z',
    ...overrides,
  }
}

export function createMockInvitation(overrides: Partial<Invitation> = {}): Invitation {
  return {
    id: '550e8400-e29b-41d4-a716-446655440099',
    email: 'invited@example.com',
    role: 'member',
    status: 'pending',
    expiresAt: '2025-12-31T23:59:59Z',
    isExpired: false,
    isValid: true,
    tenant: { id: TEST_IDS.tenant1, name: 'Test Tenant' },
    inviter: { id: TEST_IDS.user1, name: 'Test User' },
    createdAt: '2024-01-01T00:00:00Z',
    updatedAt: '2024-01-01T00:00:00Z',
    ...overrides,
  }
}
Mock factories mirror the Zod schemas from each feature's schemas.ts. When you update a schema (add a field, change a type), update the corresponding mock factory to match — otherwise tests will fail with Zod validation errors.

Test Patterns

Store Tests (Pinia)

Store tests use setActivePinia(createPinia()) in beforeEach to ensure a fresh Pinia instance for every test. Nuxt auto-imported composables are mocked with mockNuxtImport() from @nuxt/test-utils/runtime.

frontend/features/foundation/auth/stores/__tests__/useAuthStore.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mockNuxtImport } from '@nuxt/test-utils/runtime'
import { setActivePinia, createPinia } from 'pinia'
import type { ImpersonationContext } from '../../types'

// ─── Mocks ──────────────────────────────────────────────────────────────────

const mockTenantStore = { setUserTenants: vi.fn(), setCurrentTenant: vi.fn(), clear: vi.fn() }
const mockEntitlementsStore = { clear: vi.fn() }

mockNuxtImport('useTenantStore', () => {
  return () => mockTenantStore
})

mockNuxtImport('useEntitlementsStore', () => {
  return () => mockEntitlementsStore
})

mockNuxtImport('useRuntimeConfig', () => {
  return () => ({
    public: { apiBaseUrl: 'http://localhost', authMode: 'cookie' },
  })
})

mockNuxtImport('useLocalePath', () => {
  return () => (path: string) => path
})

mockNuxtImport('navigateTo', () => {
  return vi.fn()
})

// ─── Test Data ──────────────────────────────────────────────────────────────

const mockImpersonation: ImpersonationContext = {
  isImpersonating: true,
  impersonatorId: 99,
  impersonatorName: 'Admin User',
}

// ─── Tests ──────────────────────────────────────────────────────────────────

describe('useAuthStore — impersonation state', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
    vi.clearAllMocks()
  })

  it('isImpersonating and impersonatorName reflect impersonation state', async () => {
    const { useAuthStore } = await import('../useAuthStore')
    const store = useAuthStore()

    store.impersonation = mockImpersonation

    expect(store.isImpersonating).toBe(true)
    expect(store.impersonatorName).toBe('Admin User')
  })

  it('clearAuth resets impersonation to null', async () => {
    const { useAuthStore } = await import('../useAuthStore')
    const store = useAuthStore()

    store.impersonation = mockImpersonation
    expect(store.isImpersonating).toBe(true)

    store.clearAuth()

    expect(store.impersonation).toBeNull()
    expect(store.isImpersonating).toBe(false)
  })
})

Key patterns:

  • mockNuxtImport() must be called at the module level (outside describe/it blocks), before any test execution
  • Dynamic import — Use await import('../useAuthStore') inside each test to get the store after mocks are set up
  • setActivePinia(createPinia()) in beforeEach resets all store state between tests
  • vi.clearAllMocks() resets mock call counts and return values

Composable Tests

Composable tests use vi.hoisted() to define mock variables before module-level vi.mock() runs (Vitest hoists vi.mock() calls to the top of the file, so variables must also be hoisted to be accessible).

frontend/features/core/docs/billing/composables/__tests__/useSubscription.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mockNuxtImport } from '@nuxt/test-utils/runtime'
import { ref } from 'vue'

// ─── Hoisted Mocks ──────────────────────────────────────────────────────────

const { mockGetSubscription, mockCancelSubscription, mockResumeSubscription } =
  vi.hoisted(() => {
    const mockSub = {
      id: 'sub-123',
      status: 'active',
      currency: 'EUR',
      priceCents: 2900,
      quantity: 1,
      intervalUnit: 'month',
      intervalCount: 1,
      currentPeriodStart: '2025-01-01T00:00:00Z',
      currentPeriodEnd: '2025-02-01T00:00:00Z',
      trialEndsAt: null,
      cancelAtPeriodEnd: false,
      canceledAt: null,
      cancellationReason: null,
      plan: null,
      createdAt: '2025-01-01T00:00:00Z',
      updatedAt: '2025-01-01T00:00:00Z',
    }

    return {
      mockGetSubscription: vi.fn().mockResolvedValue(mockSub),
      mockCancelSubscription: vi.fn(),
      mockResumeSubscription: vi.fn(),
    }
  })

vi.mock('../../api', () => ({
  useBillingApi: () => ({
    getSubscription: mockGetSubscription,
    cancelSubscription: mockCancelSubscription,
    resumeSubscription: mockResumeSubscription,
  }),
}))

const { mockRefresh } = vi.hoisted(() => ({
  mockRefresh: vi.fn(),
}))

mockNuxtImport('useAuthenticatedAsyncData', () => {
  return (_key: string, fetcher: () => Promise<unknown>) => {
    const data = ref(null)
    fetcher().then((result) => { data.value = result })
    return { data, pending: ref(false), refresh: mockRefresh }
  }
})

Key patterns:

  • vi.hoisted() ensures mock variables are defined before Vitest's hoisted vi.mock() calls run — this is required when the mocked module and the mock state share variables
  • vi.mock('../../api', ...) replaces the API module with controlled mock implementations
  • mockNuxtImport('useAuthenticatedAsyncData', ...) replaces the data fetching composable with a synchronous version for predictable test behavior

Component Tests

Component tests use mountSuspended() from @nuxt/test-utils/runtime — this handles Nuxt's async component setup and Suspense boundaries automatically.

frontend/features/core/docs/billing/components/__tests__/UsageMeterCard.test.ts
import { describe, it, expect } from 'vitest'
import { mountSuspended } from '@nuxt/test-utils/runtime'
import type { MeterUsageSummaryItem } from '../../types'
import UsageMeterCard from '../UsageMeterCard.vue'

// ─── Helpers ────────────────────────────────────────────────────────────────

function createMeter(overrides: Partial<MeterUsageSummaryItem> = {}): MeterUsageSummaryItem {
  return {
    meterId: 'meter-1',
    meterCode: 'api_calls',
    meterName: 'API Calls',
    currentUsage: 500,
    quotaLimit: 1000,
    usagePercent: 50,
    aggregationType: 'sum',
    resetInterval: 'monthly',
    quotaEnforcement: 'none',
    unitLabel: 'calls',
    ...overrides,
  }
}

// ─── Tests ──────────────────────────────────────────────────────────────────

describe('UsageMeterCard', () => {
  it('renders meter name and unit label', async () => {
    const wrapper = await mountSuspended(UsageMeterCard, {
      props: { meter: createMeter() },
    })

    expect(wrapper.text()).toContain('API Calls')
    expect(wrapper.text()).toContain('calls')
  })

  it('renders UProgress with correct percentage', async () => {
    const wrapper = await mountSuspended(UsageMeterCard, {
      props: { meter: createMeter({ currentUsage: 500, quotaLimit: 1000 }) },
    })

    const progress = wrapper.findComponent({ name: 'UProgress' })
    expect(progress.exists()).toBe(true)
    expect(progress.props('modelValue')).toBe(50)
  })

  it('uses warning color when usage is between 80-99%', async () => {
    const wrapper = await mountSuspended(UsageMeterCard, {
      props: { meter: createMeter({ currentUsage: 850, quotaLimit: 1000 }) },
    })

    const progress = wrapper.findComponent({ name: 'UProgress' })
    expect(progress.props('color')).toBe('warning')
  })

  it('shows skeletons during loading', async () => {
    const wrapper = await mountSuspended(UsageMeterCard, {
      props: { meter: createMeter(), isLoading: true },
    })

    const skeletons = wrapper.findAllComponents({ name: 'USkeleton' })
    expect(skeletons.length).toBe(2)
  })
})

Key patterns:

  • mountSuspended() instead of mount() — Nuxt components may use async setup, and mountSuspended handles Suspense boundaries
  • Local helper factories — Create typed functions like createMeter() for test data that is specific to one component
  • findComponent({ name: 'UProgress' }) — Use the component name to locate Nuxt UI child components for assertions
  • Test behavior, not implementation — Check rendered text, prop values, and component existence rather than internal state

API Module Tests

API module tests mock the shared useApiClient composable and the tenant context, then verify that API methods call the correct endpoints and validate responses through Zod schemas.

frontend/features/core/docs/billing/api/__tests__/docs/billing.api.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref } from 'vue'
import { useBillingApi } from '../docs/billing.api'

// ─── Mock Dependencies ──────────────────────────────────────────────────────

const mockGet = vi.fn()
const mockPost = vi.fn()
const mockRequest = vi.fn()

vi.mock('@common/api', async (importOriginal) => {
  const actual = await importOriginal()
  return {
    ...actual,
    useApiClient: vi.fn(() => ({
      get: mockGet,
      post: mockPost,
      request: mockRequest,
    })),
  }
})

const TEST_TENANT_ID = '550e8400-e29b-41d4-a716-446655440099'

vi.mock('@foundation/tenancy', () => ({
  useCurrentTenant: vi.fn(() => ({
    tenant: ref({ id: '550e8400-e29b-41d4-a716-446655440099' }),
  })),
}))

// ─── Tests ──────────────────────────────────────────────────────────────────

describe('useBillingApi', () => {
  beforeEach(() => {
    vi.clearAllMocks()
  })

  it('fetches subscription successfully', async () => {
    const mockSubscription = {
      id: '550e8400-e29b-41d4-a716-446655440000',
      status: 'active',
      currency: 'EUR',
      priceCents: 2900,
      quantity: 1,
      intervalUnit: 'month',
      intervalCount: 1,
      currentPeriodStart: '2025-01-01T00:00:00Z',
      currentPeriodEnd: '2025-02-01T00:00:00Z',
      trialEndsAt: null,
      cancelAtPeriodEnd: false,
      canceledAt: null,
      cancellationReason: null,
      createdAt: '2025-01-01T00:00:00Z',
      updatedAt: '2025-01-01T00:00:00Z',
    }
    mockGet.mockResolvedValueOnce({ data: mockSubscription })

    const api = useBillingApi()
    const result = await api.getSubscription()

    expect(mockGet).toHaveBeenCalledWith(
      `/api/v1/tenant/${TEST_TENANT_ID}/subscription`
    )
    expect(result).toEqual(mockSubscription)
  })
})

Key patterns:

  • Mock useApiClient — Replace the shared API client module so no real HTTP requests are made
  • Mock useCurrentTenant — Provide a fake tenant ID that gets interpolated into endpoint URLs
  • importOriginal() — Preserve the real module's other exports while replacing only useApiClient
  • Verify endpoint paths — Assert that the correct URL was called with the tenant ID
  • Zod validation — The real API module validates responses via Zod schemas. Tests provide data that conforms to the schema, verifying the validation pipeline works end-to-end

Schema Validation Tests

Schema tests verify that your Zod schemas accept valid data and reject invalid data. They live alongside the schemas they test:

import { subscriptionSchema } from '../schemas'

describe('subscriptionSchema', () => {
  const validData = {
    id: '550e8400-e29b-41d4-a716-446655440000',
    status: 'active',
    currency: 'EUR',
    priceCents: 2900,
    // ... all required fields matching the schema
  }

  it('parses valid subscription data', () => {
    expect(subscriptionSchema.safeParse(validData).success).toBe(true)
  })

  it('rejects invalid status', () => {
    expect(subscriptionSchema.safeParse({ ...validData, status: 'invalid' }).success).toBe(false)
  })
})

Schema tests are lightweight but valuable — they catch breaking changes when the API response format changes, before the bug reaches a composable or component test.


ESLint Relaxations for Tests

Test files have relaxed ESLint rules to accommodate mocking patterns that require any types:

// In eslint.config.ts
{
  name: 'project/test-files',
  files: ['**/*.test.ts', '**/*.spec.ts', 'tests/**/*.ts'],
  rules: {
    '@typescript-eslint/no-explicit-any': 'off',
  },
}

This is intentional and scoped only to test files. Production code retains strict TypeScript rules.


Coverage

The v8 coverage provider generates reports in three formats: text (console output), JSON (for CI tooling), and HTML (for local review).

Run coverage locally:

docker compose exec node pnpm test --run --coverage

The HTML report is generated in frontend/coverage/ — open index.html in a browser to explore coverage by file and line.

Excluded from coverage: node_modules, .nuxt, .output, dist, type declaration files (*.d.ts), configuration files (*.config.*), and types/ directories. These exclusions ensure coverage metrics reflect your application code, not build artifacts or type definitions.


Running Tests

GoalCommand
All frontend testsmake test-front
Watch mode (re-run on changes)docker compose exec node pnpm test
Single run (CI mode)docker compose exec node pnpm test --run
Single test filedocker compose exec node pnpm test features/core/docs/billing/composables/__tests__/useSubscription.test.ts
Tests matching a patterndocker compose exec node pnpm test --run -t "renders meter name"
With coveragedocker compose exec node pnpm test --run --coverage
In watch mode (pnpm test without --run), Vitest re-runs only the tests affected by your changes. This is the fastest feedback loop during development. Use --run for a single pass, typically before pushing or in CI.

What's Next