Skip to content
SaaS4Builders
Testing

Testing Overview

Testing philosophy, toolchain, directory structure, quick-start commands, and CI pipeline for the SaaS4Builders boilerplate.

SaaS4Builders ships with a comprehensive test suite covering both the Laravel backend and the Nuxt frontend. Tests mirror the application's layered architecture — feature tests validate HTTP endpoints end-to-end, unit tests verify individual actions and queries, and frontend tests cover stores, composables, components, and API modules. Everything runs in Docker, and a GitHub Actions CI pipeline enforces quality on every push.


Testing Philosophy

Three principles guide the testing strategy across both stacks:

1. Tests Mirror Application Architecture

Backend tests follow the domain-driven structure — feature tests live in tests/Feature/Api/V1/ matching the controller hierarchy, and unit tests live in tests/Unit/Application/ matching the action/query structure. Frontend tests are colocated with their features in __tests__/ directories inside each vertical slice.

2. External Services Are Always Mocked

Stripe, OAuth providers, and any external API are mocked in tests. The backend uses Mockery to mock domain contracts (interfaces), so tests never make real HTTP calls. The frontend uses Vitest's vi.mock() to replace API client modules with controlled responses.

3. Tenant Isolation Is a First-Class Concern

Multi-tenant data isolation is one of the most critical invariants in the application. Dedicated test helpers (WithTenantContext trait on the backend, mock factories on the frontend) make it straightforward to set up tenant contexts and verify that tenant A never sees tenant B's data. See Testing Multi-Tenant Isolation for a deep dive.

Local tests run against SQLite in-memory for speed (configured in phpunit.xml). The CI pipeline uses PostgreSQL 16 for production parity. If you encounter database-specific test failures locally that pass in CI (or vice versa), check which database engine your tests are targeting.

Toolchain at a Glance

ConcernBackendFrontend
Test frameworkPHPUnit v12Vitest 3.2
MockingMockeryvi.mock() / vi.hoisted()
Static analysisPHPStan level 9TypeScript strict mode
Code formattingLaravel PintESLint
Test utilitiesWithTenantContext trait@nuxt/test-utils + @vue/test-utils
DOM environmentN/Ahappy-dom
Coverage providerXdebug (CI)v8
CI runnerGitHub Actions (PHP 8.3 + PostgreSQL 16 + Redis 7)GitHub Actions (Node 20)

Quick Start Commands

All commands run from the project root via make or directly through Docker:

GoalMake CommandDocker Equivalent
Run all tests (both stacks)make test
Backend tests onlymake test-backdocker compose exec php php artisan test
Frontend tests onlymake test-frontdocker compose exec node pnpm test
Filter a single backend testdocker compose exec php php artisan test --filter=test_user_can_only_access_their_tenant
Run a single frontend test filedocker compose exec node pnpm test features/core/docs/billing/composables/__tests__/useSubscription.test.ts
Backend tests with coveragedocker compose exec php php artisan test --coverage
Frontend tests with coveragedocker compose exec node pnpm test --run --coverage
All linting (both stacks)make lint
Full CI simulationmake checkmake lint && make test
Always use docker compose exec php or docker compose exec node to run commands inside the containers. Running php artisan test or pnpm test outside Docker will use your host's PHP/Node versions, which may differ from the project's requirements.

Test Directory Structure

Backend

backend/tests/
├── Feature/
│   ├── Api/V1/              # HTTP endpoint tests
│   │   ├── Admin/           # Platform admin endpoints
│   │   │   ├── Billing/     # Stripe price management
│   │   │   ├── Catalog/     # Products, plans, features
│   │   │   ├── Impersonation/
│   │   │   ├── Tenant/      # Admin tenant management
│   │   │   └── ...
│   │   ├── Auth/            # Authentication endpoints
│   │   ├── Profile/         # User profile
│   │   └── Tenant/          # Tenant-scoped endpoints
│   ├── Auth/                # Auth flow tests (login, register, OAuth)
│   ├── Tenancy/             # Tenant isolation & resolution
│   ├── Webhooks/            # Stripe webhook signature verification
│   └── Database/Seeders/    # Seeder tests
├── Unit/
│   └── Application/         # Action & Query unit tests
│       ├── Auth/Actions/
│       └── ...
├── Traits/
│   └── WithTenantContext.php # Tenant test helper trait
└── TestCase.php             # Base test class

Frontend

frontend/
├── features/
│   ├── foundation/
│   │   ├── auth/stores/__tests__/         # Auth store tests
│   │   ├── currencies/api/__tests__/      # Currency API tests
│   │   ├── entitlements/api/__tests__/    # Entitlements tests
│   │   └── tenancy/stores/__tests__/      # Tenant store tests
│   ├── core/
│   │   ├── billing/
│   │   │   ├── api/__tests__/             # Billing API module tests
│   │   │   ├── composables/__tests__/     # Subscription composable tests
│   │   │   ├── components/__tests__/      # UI component tests
│   │   │   └── __tests__/                 # Schema validation tests
│   │   ├── catalog/                       # Catalog feature tests
│   │   └── team/                          # Team feature tests
│   └── product/                           # Product feature tests
└── tests/helpers/
    ├── mockSession.ts                     # User, tenant, session factories
    ├── mockCatalog.ts                     # Product, plan, feature factories
    └── mockTeam.ts                        # Team member, invitation factories

The key principle: backend tests mirror the application architecture (tests/Feature/Api/V1/ matches Http/Controllers/Api/V1/), while frontend tests are colocated with the feature they test (features/core/docs/billing/composables/__tests__/ sits next to features/core/docs/billing/composables/).


Test Configuration

Backend — phpunit.xml

backend/phpunit.xml
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         colors="true"
>
    <testsuites>
        <testsuite name="Unit">
            <directory>tests/Unit</directory>
        </testsuite>
        <testsuite name="Feature">
            <directory>tests/Feature</directory>
        </testsuite>
    </testsuites>
    <source>
        <include>
            <directory>app</directory>
        </include>
    </source>
    <php>
        <env name="APP_ENV" value="testing"/>
        <env name="APP_MAINTENANCE_DRIVER" value="file"/>
        <env name="BCRYPT_ROUNDS" value="4"/>
        <env name="BROADCAST_CONNECTION" value="null"/>
        <env name="CACHE_STORE" value="array"/>
        <env name="DB_CONNECTION" value="sqlite"/>
        <env name="DB_DATABASE" value=":memory:"/>
        <env name="MAIL_MAILER" value="array"/>
        <env name="QUEUE_CONNECTION" value="sync"/>
        <env name="SESSION_DRIVER" value="array"/>
        <env name="PULSE_ENABLED" value="false"/>
        <env name="TELESCOPE_ENABLED" value="false"/>
        <env name="NIGHTWATCH_ENABLED" value="false"/>
        <env name="STRIPE_KEY" value="pk_test_dummy"/>
        <env name="STRIPE_SECRET" value="sk_test_dummy"/>
        <env name="STRIPE_WEBHOOK_SECRET" value="whsec_dummy"/>
    </php>
</phpunit>

Key settings: SQLite in-memory for speed, array drivers for cache/mail/session/queue (no external services), dummy Stripe keys (all Stripe calls are mocked), and low bcrypt rounds for faster test execution.

Frontend — vitest.config.ts

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 settings: defineVitestConfig from @nuxt/test-utils/config sets up the Nuxt-aware test environment automatically. happy-dom is the DOM implementation (lighter than jsdom). globals: true means you do not need to import describe, it, and expect in every test file.


CI Pipeline

The project includes a GitHub Actions workflow (.github/workflows/ci.yml) that runs on every push and pull request to main and develop. It consists of three parallel jobs:

JobWhat It DoesServices
BackendComposer install, migrate, Pint (code style check), PHPStan (static analysis), parallel tests with coverage, Codecov uploadPostgreSQL 16, Redis 7
Frontendpnpm install (frozen lockfile), ESLint, TypeScript check, Vitest with coverage, production buildNone
Securitycomposer audit for PHP dependencies, pnpm audit for Node dependenciesNone

The backend job runs against PHP 8.3 with PostgreSQL 16 and Redis 7 as service containers. The frontend job runs on Node 20 with pnpm 9. Both jobs use dependency caching for faster runs.

The CI pipeline also validates that the frontend builds successfully (pnpm build). This catches issues that might not surface during development — such as missing imports, SSR-incompatible code, or type errors that only appear during production builds.

What's Next

  • Backend Testing (PHPUnit) — Base TestCase, WithTenantContext trait, model factories, Stripe mocking patterns, and test conventions
  • Frontend Testing (Vitest) — Vitest configuration, Nuxt test utilities, mock factories, store/composable/component test patterns