Frontend Testing (Vitest)
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:
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 expectedhappy-dom— A lightweight DOM implementation (faster than jsdom) used for rendering Vue componentsglobals: true— You do not need to importdescribe,it,expect,vi, orbeforeEachin every test file — they are available globallyv8coverage — 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:
/**
* 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:
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,
}
}
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,
}
}
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.
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 (outsidedescribe/itblocks), before any test execution- Dynamic import — Use
await import('../useAuthStore')inside each test to get the store after mocks are set up setActivePinia(createPinia())inbeforeEachresets all store state between testsvi.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).
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 hoistedvi.mock()calls run — this is required when the mocked module and the mock state share variablesvi.mock('../../api', ...)replaces the API module with controlled mock implementationsmockNuxtImport('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.
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 ofmount()— Nuxt components may use async setup, andmountSuspendedhandles 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.
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 onlyuseApiClient- 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
| Goal | Command |
|---|---|
| All frontend tests | make 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 file | docker compose exec node pnpm test features/core/docs/billing/composables/__tests__/useSubscription.test.ts |
| Tests matching a pattern | docker compose exec node pnpm test --run -t "renders meter name" |
| With coverage | docker compose exec node pnpm test --run --coverage |
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
- Backend Testing (PHPUnit) — PHPUnit configuration, model factories, Stripe mocking patterns, and test conventions
- Testing Overview — Toolchain comparison, CI pipeline, and quick start commands
- Architecture Overview — The layered architecture that the test structure mirrors
- Billing Overview — Billing domain context for understanding the billing test examples used in this guide
Backend Testing (PHPUnit)
How to write backend tests: base TestCase, WithTenantContext trait, model factories, Stripe mocking patterns, feature and unit test conventions, and running tests.
API Conventions
API versioning, authentication, pagination, filtering, error format, data types, and backward-compatibility rules for the SaaS4Builders REST API.