# Unit Testing Prisma ORM with pgLite and Vitest

> Test Prisma database services using pgLite for fast, isolated tests without Docker. Covers setup, migrations, test factories, and real service testing patterns.

*Published: 2026-01-26*
*Canonical: https://makerkit.dev/blog/tutorials/unit-testing-prisma-vitest*

---

Testing database services typically requires spinning up Docker containers, managing test databases, and dealing with slow setup times. pgLite changes this by providing a complete PostgreSQL implementation that runs in-memory using WebAssembly.

[pgLite](https://pglite.dev) is a lightweight PostgreSQL implementation compiled to WebAssembly that runs entirely in-memory within your Node.js process. It provides full SQL compatibility without external dependencies.

When we built the MakerKit ORM variants (Prisma and Drizzle kits), we needed a different testing approach than our Supabase kits. The Supabase kits use pgtap to test Row Level Security policies directly in the database. But the ORM kits handle authorization at the service layer, making pgLite the better fit: we get real PostgreSQL behavior for testing business logic without the overhead of spinning up Supabase or Docker for every test run.

MakerKit uses pgLite with Prisma and Drizzle for all database service tests in the ORM kits. Tests run in milliseconds, require no external dependencies, and execute real SQL against a real PostgreSQL-compatible database.

**Tested with:** Prisma 7.0, pgLite 0.3.14, pglite-prisma-adapter 0.7.1, Vitest 4.0, Node.js 20+

## What Are Prisma Services?

A **Prisma service** is a TypeScript function or class that encapsulates database operations using Prisma Client. Instead of scattering `prisma.user.findMany()` calls throughout your application, you create dedicated service functions that handle business logic alongside database queries.

```typescript title="packages/organization/core/src/services/seat-enforcement.service.ts"
export function createSeatEnforcementService(db: PrismaClient) {
  return {
    async assertCanInviteMember(params: { organizationId: string }) {
      const [subscription, memberCount, pendingCount] = await Promise.all([
        db.subscription.findFirst({
          where: { referenceId: params.organizationId, status: 'active' },
        }),
        db.member.count({
          where: { organizationId: params.organizationId },
        }),
        db.invitation.count({
          where: { organizationId: params.organizationId, status: 'pending' },
        }),
      ]);

      if (!subscription) return; // No seat limit on free tier

      const usedSeats = memberCount + pendingCount;
      if (usedSeats >= (subscription.seats ?? Infinity)) {
        throw new SeatLimitReachedError();
      }
    },
  };
}
```

This pattern provides several benefits:

- **Testability**: Services accept a database client as a parameter, making it easy to inject a test database
- **Encapsulation**: Business rules live in one place, not scattered across API routes and components
- **Type safety**: Prisma's generated types flow through your service layer
- **Reusability**: The same service works in API routes, Server Actions, and background jobs

The MakerKit ORM kits use this service pattern extensively. Authorization checks, seat enforcement, invitation handling, and subscription logic all live in dedicated services that we test with pgLite.

## Why pgLite for Database Testing

Traditional approaches to testing database code have significant tradeoffs:

- **Mocking the database** catches typos but misses real issues. Your mock returns what you tell it to return, so constraint violations, query errors, and relationship problems slip through to production.
- **Docker-based testing** catches real issues but adds friction. Starting containers takes seconds, tests run slower, and CI pipelines need Docker support.
- **pgLite** gives you both: real PostgreSQL behavior without the infrastructure overhead. It's compiled to WebAssembly, runs entirely in-process, and supports the SQL features you actually use in application code.

For MakerKit's service tests, pgLite provides:

- Full PostgreSQL SQL support (constraints, transactions, CASCADE)
- Sub-second test startup
- No Docker or external database required
- Real Prisma queries against real data

## Setting Up pgLite with Prisma

### Dependencies

Install the required packages in your database package:

```bash
pnpm add -D @electric-sql/pglite pglite-prisma-adapter
```

Your `package.json` devDependencies should include:

```json
{
  "devDependencies": {
    "@electric-sql/pglite": "^0.3.14",
    "pglite-prisma-adapter": "^0.7.1",
    "@prisma/client": "^7.0.0",
    "vitest": "^4.0.0"
  }
}
```

### Creating the Test Database Utility

Create a utility that initializes pgLite, applies migrations, and provides cleanup functions.

```typescript title="packages/database/src/test-utils/pglite-db.ts"
import * as fs from 'node:fs';
import * as path from 'node:path';

import { PGlite } from '@electric-sql/pglite';
import { PrismaPGlite } from 'pglite-prisma-adapter';

import { PrismaClient } from '../prisma/generated/index';

export interface TestDatabase {
  db: PrismaClient;
  client: PGlite;
  cleanup: () => Promise<void>;
  close: () => Promise<void>;
}

export async function createTestDatabase(): Promise<TestDatabase> {
  const client = new PGlite();

  // Apply migrations from the migrations folder
  const migrationsDir = path.join(
    import.meta.dirname,
    '..',
    'prisma',
    'migrations',
  );

  const migrations = fs
    .readdirSync(migrationsDir)
    .filter((dir) => fs.statSync(path.join(migrationsDir, dir)).isDirectory())
    .sort();

  for (const migration of migrations) {
    const sqlPath = path.join(migrationsDir, migration, 'migration.sql');

    if (fs.existsSync(sqlPath)) {
      const sql = fs.readFileSync(sqlPath, 'utf-8');
      await client.exec(sql);
    }
  }

  const adapter = new PrismaPGlite(client);
  const db = new PrismaClient({ adapter });

  await db.$connect();

  return {
    db,
    client,

    async cleanup() {
      // Truncate all tables in dependency order
      await client.exec(`
        TRUNCATE TABLE "rateLimit" CASCADE;
        TRUNCATE TABLE member CASCADE;
        TRUNCATE TABLE invitation CASCADE;
        TRUNCATE TABLE "organizationRole" CASCADE;
        TRUNCATE TABLE subscription CASCADE;
        TRUNCATE TABLE organization CASCADE;
        TRUNCATE TABLE session CASCADE;
        TRUNCATE TABLE account CASCADE;
        TRUNCATE TABLE "twoFactor" CASCADE;
        TRUNCATE TABLE verification CASCADE;
        TRUNCATE TABLE "user" CASCADE;
      `);
    },

    async close() {
      await db.$disconnect();
      await client.close();
    },
  };
}

export function createTestId(id: string): string {
  return `test_${id}_${Date.now()}`;
}
```

Key implementation details:

1. **Migration application**: Reads actual Prisma migration files and executes them in order. Your test database has the exact same schema as production.
2. **CASCADE truncation**: The `cleanup()` function truncates tables with `CASCADE` to handle foreign key relationships. Order matters less with CASCADE, but listing tables explicitly makes the cleanup predictable.
3. **Unique test IDs**: The `createTestId()` helper generates unique identifiers with timestamps, preventing collisions when tests run in parallel.
4. **Node.js requirement**: The `import.meta.dirname` syntax requires Node.js 20.11+ or 21+.

### Exporting Test Utilities

Export the utilities from your database package:

```typescript title="packages/database/src/test-utils/index.ts"
export * from './pglite-db';
export * from './factories';
```

Add the export path to your `package.json`:

```json title="packages/database/package.json"
{
  "exports": {
    ".": "./src/index.ts",
    "./testing": "./src/test-utils/index.ts",
    "./testing/pglite": "./src/test-utils/pglite-db.ts"
  }
}
```

## Test Data Factories

Factory functions create consistent test data with sensible defaults while allowing overrides for specific test cases.

```typescript title="packages/database/src/test-utils/factories.ts"
import { randomBytes } from 'crypto';

/**
 * Factory for creating test user data
 */
export const createTestUser = (overrides: Partial<TestUser> = {}): TestUser => {
  const id = randomId();

  return {
    id,
    email: `test-${id}@makerkit.dev`,
    emailVerified: true,
    name: `Test User ${id.slice(0, 4)}`,
    image: null,
    createdAt: new Date(),
    updatedAt: new Date(),
    ...overrides,
  };
};

/**
 * Factory for creating test organization data
 */
export const createTestOrganization = (
  overrides: Partial<TestOrganization> = {},
): TestOrganization => {
  const id = randomId();

  return {
    id,
    name: `Test Organization ${id.slice(0, 4)}`,
    slug: `test-org-${id}`,
    logo: null,
    metadata: JSON.stringify({}),
    createdAt: new Date(),
    ...overrides,
  };
};

/**
 * Factory for creating test member relationship
 */
export const createTestMember = (
  overrides: Partial<TestMember> = {},
): TestMember => {
  return {
    id: randomId(),
    userId: '',
    organizationId: '',
    role: 'member',
    createdAt: new Date(),
    ...overrides,
  };
};

/**
 * Factory for creating test subscription data
 */
export const createTestSubscription = (
  overrides: Partial<TestSubscription> = {},
): TestSubscription => {
  const id = randomId();

  return {
    id,
    plan: 'pro-monthly',
    referenceId: '',
    customer_id: `cus_test_${id}`,
    subscription_id: `sub_test_${id}`,
    status: 'active',
    periodStart: new Date(),
    periodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
    cancelAtPeriodEnd: false,
    cancelAt: null,
    canceledAt: null,
    endedAt: null,
    seats: 1,
    trialStart: null,
    trialEnd: null,
    ...overrides,
  };
};

function randomId(): string {
  return randomBytes(16).toString('hex');
}

// Type definitions for test data
export interface TestUser {
  id: string;
  email: string;
  emailVerified: boolean;
  name: string;
  image: string | null;
  createdAt: Date;
  updatedAt: Date;
}

export interface TestOrganization {
  id: string;
  name: string;
  slug: string;
  logo: string | null;
  metadata: string;
  createdAt: Date;
}

export interface TestMember {
  id: string;
  userId: string;
  organizationId: string;
  role: 'owner' | 'admin' | 'member';
  createdAt: Date;
}

export interface TestSubscription {
  id: string;
  plan: string;
  referenceId: string;
  customer_id: string | null;
  subscription_id: string | null;
  status: string;
  periodStart: Date | null;
  periodEnd: Date | null;
  cancelAtPeriodEnd: boolean | null;
  cancelAt: Date | null;
  canceledAt: Date | null;
  endedAt: Date | null;
  seats: number | null;
  trialStart: Date | null;
  trialEnd: Date | null;
}
```

Factory design principles:

- **Arrow function syntax**: Consistent with modern TypeScript style
- **Spread overrides last**: `{ ...defaults, ...overrides }` lets callers override any field
- **Generated IDs by default**: Each factory call produces unique data
- **Realistic defaults**: Subscriptions get 30-day periods, users are email-verified
- **Type safety**: TypeScript interfaces ensure factories match your schema

## Writing Database Service Tests

### Basic Test Structure

Here's the pattern for testing a service that interacts with the database:

```typescript title="packages/database/src/services/example.service.test.ts"
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';

import {
  type TestDatabase,
  createTestDatabase,
  createTestId,
} from '../test-utils/pglite-db';

import { createTestUser, createTestOrganization } from '../test-utils/factories';
import { createExampleService } from './example.service';

describe('ExampleService', () => {
  let testDb: TestDatabase;
  let service: ReturnType<typeof createExampleService>;

  beforeAll(async () => {
    testDb = await createTestDatabase();
    service = createExampleService(testDb.db);
  });

  beforeEach(async () => {
    await testDb.cleanup();
  });

  afterAll(async () => {
    await testDb.close();
  });

  it('should perform operation', async () => {
    // Arrange: Create test data
    const user = createTestUser();
    await testDb.db.user.create({ data: user });

    // Act: Call the service
    const result = await service.someOperation(user.id);

    // Assert: Verify the result
    expect(result).toBeDefined();
  });
});
```

The lifecycle hooks serve distinct purposes:

- **beforeAll**: Creates one database instance for the entire test file. Migration application happens once.
- **beforeEach**: Truncates all data between tests. Each test starts with an empty database.
- **afterAll**: Closes connections and releases resources.

Note that test files can be colocated with the service file (as shown above) or placed in a `__tests__` subdirectory. MakerKit uses both patterns depending on the package.

### Testing Seat Enforcement (Real Example)

This example from MakerKit tests the seat enforcement service, which validates whether an organization can add more members based on their subscription:

```typescript title="packages/organization/core/src/services/__tests__/seat-enforcement.service.test.ts"
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';

import {
  type TestDatabase,
  createTestDatabase,
  createTestId,
} from '@kit/database/testing/pglite';

import {
  SeatLimitReachedError,
  createSeatEnforcementService,
} from '../seat-enforcement.service';

const now = new Date();

describe('SeatEnforcementService', () => {
  let testDb: TestDatabase;
  let service: ReturnType<typeof createSeatEnforcementService>;

  beforeAll(async () => {
    testDb = await createTestDatabase();
    service = createSeatEnforcementService(testDb.db);
  });

  beforeEach(async () => {
    await testDb.cleanup();
  });

  afterAll(async () => {
    await testDb.close();
  });

  async function seedOrgBase(orgSuffix = '1') {
    const orgId = createTestId(`org-${orgSuffix}`);
    const userId1 = createTestId('u-1');
    const userId2 = createTestId('u-2');
    const userId3 = createTestId('u-3');

    // Create test users efficiently with createMany
    await testDb.db.user.createMany({
      data: [
        {
          id: userId1,
          email: `test_u1@example.com`,
          name: 'User 1',
          emailVerified: true,
          createdAt: now,
          updatedAt: now,
        },
        {
          id: userId2,
          email: `test_u2@example.com`,
          name: 'User 2',
          emailVerified: true,
          createdAt: now,
          updatedAt: now,
        },
        {
          id: userId3,
          email: `test_u3@example.com`,
          name: 'User 3',
          emailVerified: true,
          createdAt: now,
          updatedAt: now,
        },
      ],
    });

    // Create test organization
    await testDb.db.organization.create({
      data: {
        id: orgId,
        name: 'Org',
        slug: `test_org-${orgSuffix}`,
        createdAt: now,
        metadata: '{}',
      },
    });

    return { orgId, userIds: [userId1, userId2, userId3] as const };
  }

  describe('assertCanInviteMember', () => {
    it('allows invite when under seat limit', async () => {
      const { orgId, userIds } = await seedOrgBase();

      // Create subscription with 5 seats
      await testDb.db.subscription.create({
        data: {
          id: createTestId('sub'),
          referenceId: orgId,
          plan: 'team',
          status: 'active',
          periodStart: now,
          periodEnd: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000),
          seats: 5,
        },
      });

      // Add 2 members
      await testDb.db.member.createMany({
        data: [
          {
            id: createTestId('m-1'),
            organizationId: orgId,
            userId: userIds[0],
            role: 'owner',
            createdAt: now,
          },
          {
            id: createTestId('m-2'),
            organizationId: orgId,
            userId: userIds[1],
            role: 'member',
            createdAt: now,
          },
        ],
      });

      await expect(
        service.assertCanInviteMember({ organizationId: orgId }),
      ).resolves.not.toThrow();
    });

    it('blocks invite when at seat limit', async () => {
      const { orgId, userIds } = await seedOrgBase();

      // Create subscription with only 2 seats
      await testDb.db.subscription.create({
        data: {
          id: createTestId('sub'),
          referenceId: orgId,
          plan: 'team',
          status: 'active',
          periodStart: now,
          periodEnd: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000),
          seats: 2,
        },
      });

      // Fill both seats
      await testDb.db.member.createMany({
        data: [
          {
            id: createTestId('m-1'),
            organizationId: orgId,
            userId: userIds[0],
            role: 'owner',
            createdAt: now,
          },
          {
            id: createTestId('m-2'),
            organizationId: orgId,
            userId: userIds[1],
            role: 'member',
            createdAt: now,
          },
        ],
      });

      await expect(
        service.assertCanInviteMember({ organizationId: orgId }),
      ).rejects.toBeInstanceOf(SeatLimitReachedError);
    });

    it('counts pending invitations toward seat usage', async () => {
      const { orgId, userIds } = await seedOrgBase();

      // Create subscription with 3 seats
      await testDb.db.subscription.create({
        data: {
          id: createTestId('sub'),
          referenceId: orgId,
          plan: 'team',
          status: 'active',
          periodStart: now,
          periodEnd: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000),
          seats: 3,
        },
      });

      // Add 2 members
      await testDb.db.member.createMany({
        data: [
          {
            id: createTestId('m-1'),
            organizationId: orgId,
            userId: userIds[0],
            role: 'owner',
            createdAt: now,
          },
          {
            id: createTestId('m-2'),
            organizationId: orgId,
            userId: userIds[1],
            role: 'member',
            createdAt: now,
          },
        ],
      });

      // Add 1 pending invitation
      await testDb.db.invitation.create({
        data: {
          id: createTestId('inv-1'),
          organizationId: orgId,
          email: `test_pending@example.com`,
          role: 'member',
          status: 'pending',
          inviterId: userIds[0],
          createdAt: now,
          expiresAt: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000),
        },
      });

      // 2 members + 1 pending = 3 seats used, at limit
      await expect(
        service.assertCanInviteMember({ organizationId: orgId }),
      ).rejects.toBeInstanceOf(SeatLimitReachedError);
    });
  });
});
```

This test demonstrates several important patterns:

1. **Helper functions for complex setup**: `seedOrgBase` handles the multi-table setup, keeping individual tests focused on the behavior being verified.
2. **Batch operations**: Using `createMany` instead of multiple `create` calls improves test performance.
3. **Testing business rules**: The seat enforcement logic considers both active members and pending invitations. The tests verify this counting behavior.
4. **Error type assertions**: Using `toBeInstanceOf(SeatLimitReachedError)` verifies the service throws the correct error type, not just any error.

### Testing Time-Dependent Logic

For services that depend on time (rate limiting, expiration checks), use Vitest's fake timers:

```typescript title="packages/database/src/services/rate-limit.service.test.ts"
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { createTestDatabase } from '../test-utils/pglite-db';
import { createRateLimitService } from './rate-limit.service';

describe('RateLimitService', () => {
  let testDb: Awaited<ReturnType<typeof createTestDatabase>>;

  beforeEach(async () => {
    testDb = await createTestDatabase();
  });

  afterEach(async () => {
    vi.useRealTimers();
    await testDb.close();
  });

  it('blocks requests after limit reached within window', async () => {
    vi.useFakeTimers();
    vi.setSystemTime(0);

    const service = createRateLimitService({
      database: testDb.db as unknown as typeof import('../client').db,
    });

    const key = 'rate:limit';
    const windowSeconds = 10;
    const max = 3;

    // First 3 requests succeed
    for (let i = 0; i < max; i++) {
      const result = await service.limit(key, { windowSeconds, max });
      expect(result.success).toBe(true);
    }

    // 4th request is blocked
    const blocked = await service.limit(key, { windowSeconds, max });
    expect(blocked.success).toBe(false);
    expect(blocked.retryAfter).toBe(10);
  });

  it('resets after window expires', async () => {
    vi.useFakeTimers();
    vi.setSystemTime(0);

    const service = createRateLimitService({
      database: testDb.db as unknown as typeof import('../client').db,
    });

    const key = 'rate:reset';
    const windowSeconds = 10;
    const max = 2;

    // Use the limit
    await service.limit(key, { windowSeconds, max });
    await service.limit(key, { windowSeconds, max });

    // Blocked after hitting max
    const blocked = await service.limit(key, { windowSeconds, max });
    expect(blocked.success).toBe(false);

    // Advance past the window
    vi.advanceTimersByTime(11_000);

    // Should succeed again
    const afterWindow = await service.limit(key, { windowSeconds, max });
    expect(afterWindow.success).toBe(true);
  });
});
```

Timer testing considerations:

- **Set time to zero**: `vi.setSystemTime(0)` gives predictable timestamps
- **Restore real timers**: Always call `vi.useRealTimers()` in afterEach to prevent timer leakage between tests
- **Advance time explicitly**: `vi.advanceTimersByTime()` moves the clock forward without waiting

### Handling Prisma Type Casting

When passing the test database to services that expect the production Prisma client, you may need type casting. This is necessary when a service accepts a narrower type than `PrismaClient`:

```typescript
// When service expects a specific database client type
const service = createRateLimitService({
  database: testDb.db as unknown as typeof import('../client').db,
});

// When service accepts PrismaClient directly, no casting needed
const service = createSeatEnforcementService(testDb.db);
```

The need for casting depends on how the service's database parameter is typed.

## Vitest Configuration

### Shared Configuration

Create a shared Vitest configuration that all packages inherit:

```typescript title="tooling/vitest/vitest.config.ts"
import { defineConfig } from 'vitest/config';

export default defineConfig({
  resolve: {
    alias: {
      'server-only': new URL('./src/server-only-mock.ts', import.meta.url)
        .pathname,
    },
  },
  test: {
    globals: true,
    environment: 'node',
    passWithNoTests: true,
    pool: 'forks',
    hookTimeout: 20_000,
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      include: ['src/services/**/*.ts'],
      exclude: [
        '**/*.spec.ts',
        '**/*.integration.test.ts',
        '**/*.edge-cases.test.ts',
        '**/types/**',
      ],
    },
  },
});
```

Configuration highlights:

- **pool: 'forks'**: Each test file runs in a separate process, preventing test pollution
- **hookTimeout: 20_000**: 20 seconds for beforeAll/afterAll hooks accommodates database setup
- **server-only mock**: Prevents Next.js server-only import errors in tests
- **coverage excludes**: Keeps coverage focused on service code, not test files

### Package Configuration

Each package uses the shared config:

```typescript title="packages/database/vitest.config.ts"
import vitestConfig from '@kit/vitest';

export default vitestConfig;
```

### Server-Only Mock

Create a mock for the `server-only` package that Next.js uses:

```typescript title="tooling/vitest/src/server-only-mock.ts"
// Empty export satisfies the import without throwing
export {};
```

This prevents the "server-only module cannot be imported" error when testing server-side code.

## Common Patterns and Gotchas

### Testing Error Conditions

Always test that services throw the correct errors:

```typescript
it('throws NotFoundError when organization does not exist', async () => {
  await expect(
    service.getOrganization('non-existent-id'),
  ).rejects.toBeInstanceOf(NotFoundError);
});

it('throws ValidationError with specific message', async () => {
  await expect(
    service.createInvitation({ email: 'invalid' }),
  ).rejects.toThrow('Invalid email format');
});
```

### Verifying Database State

Sometimes you need to verify the database state directly, not just the service return value:

```typescript
it('creates member record when accepting invitation', async () => {
  const { orgId, invitationId, userId } = await seedInvitation();

  await service.acceptInvitation({ invitationId, userId });

  // Verify the member was created
  const member = await testDb.db.member.findFirst({
    where: { userId, organizationId: orgId },
  });

  expect(member).not.toBeNull();
  expect(member?.role).toBe('member');

  // Verify the invitation was consumed
  const invitation = await testDb.db.invitation.findUnique({
    where: { id: invitationId },
  });

  expect(invitation?.status).toBe('accepted');
});
```

### Test Isolation Failures

If tests fail intermittently, check for:

1. **Missing cleanup**: Ensure `beforeEach` calls `testDb.cleanup()`
2. **Shared state**: Don't store test data in module-level variables
3. **Timer leakage**: Always restore real timers in afterEach

```typescript
// Bad: Shared state between tests
let testUser: TestUser;

beforeAll(async () => {
  testUser = await createUser(); // Other tests see this user
});

// Good: Fresh data per test
beforeEach(async () => {
  await testDb.cleanup();
});

it('test 1', async () => {
  const user = await createUser(); // Only this test sees this user
});
```

## Testing Server Actions

Server Actions can't be tested directly with pgLite because they depend on Next.js runtime features (form data, headers, cookies). The solution: keep actions thin and push logic into services.

### The Pattern: Thin Actions, Fat Services

Create Server Actions that only handle:
1. Input parsing with Zod
2. Authentication checks
3. Delegating to a service

```typescript title="apps/web/app/home/[account]/members/_actions/invite-member.action.ts"
'use server';

import { z } from 'zod';
import { revalidatePath } from 'next/cache';

import { requireAuth } from '@kit/auth/server';
import { createInvitationService } from '@kit/organization/services';

const InviteMemberSchema = z.object({
  organizationId: z.string().min(1),
  email: z.string().email(),
  role: z.enum(['admin', 'member']),
});

export async function inviteMemberAction(formData: FormData) {
  // 1. Parse and validate input
  const input = InviteMemberSchema.parse({
    organizationId: formData.get('organizationId'),
    email: formData.get('email'),
    role: formData.get('role'),
  });

  // 2. Authenticate
  const { user } = await requireAuth();

  // 3. Delegate to service (this is what we test)
  const service = createInvitationService();

  await service.inviteMember({
    inviterId: user.id,
    ...input,
  });

  // 4. Revalidate and return
  revalidatePath(`/home/${input.organizationId}/members`);

  return { success: true };
}
```

### Test the Service, Not the Action

The action is just glue code. Test the service where the business logic lives:

```typescript title="packages/organization/core/src/services/__tests__/invitations.service.test.ts"
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';

import {
  type TestDatabase,
  createTestDatabase,
  createTestId,
} from '@kit/database/testing/pglite';

import { createInvitationService } from '../invitations.service';

describe('InvitationService', () => {
  let testDb: TestDatabase;
  let service: ReturnType<typeof createInvitationService>;

  beforeAll(async () => {
    testDb = await createTestDatabase();
    service = createInvitationService(testDb.db);
  });

  beforeEach(async () => {
    await testDb.cleanup();
  });

  afterAll(async () => {
    await testDb.close();
  });

  it('creates invitation with pending status', async () => {
    const { orgId, userId } = await seedOrgWithOwner();

    const result = await service.inviteMember({
      organizationId: orgId,
      inviterId: userId,
      email: 'newmember@example.com',
      role: 'member',
    });

    expect(result.status).toBe('pending');
    expect(result.email).toBe('newmember@example.com');

    // Verify in database
    const invitation = await testDb.db.invitation.findUnique({
      where: { id: result.id },
    });

    expect(invitation).not.toBeNull();
  });

  it('throws when inviting existing member', async () => {
    const { orgId, userId, memberEmail } = await seedOrgWithMember();

    await expect(
      service.inviteMember({
        organizationId: orgId,
        inviterId: userId,
        email: memberEmail,
        role: 'member',
      }),
    ).rejects.toThrow('User is already a member');
  });
});
```

### Why This Pattern Works

1. **Zod handles validation**: Invalid input throws before hitting your service. You don't need to test that Zod works.
2. **Auth is a separate concern**: Test auth in E2E tests where you have a real session. Don't mock it in unit tests.
3. **Services are pure business logic**: They take typed inputs and return results. No request context, no form data, no cookies.
4. **Actions are thin glue**: If an action is just parse → auth → service → revalidate, there's little to test. The service tests cover the logic.

This separation also makes your code more reusable. The same service works in Server Actions, API routes, background jobs, and CLI scripts.

## When to Use pgLite vs Other Testing Approaches

pgLite testing is ideal for **service-layer business logic** but doesn't replace all database testing scenarios.

### Use pgLite when:

- Testing business logic that involves multiple tables
- Verifying constraint enforcement (unique, foreign keys)
- Testing transaction behavior
- The query logic is complex enough that mocking would be unreliable
- You're not using RLS (Row Level Security) policies
- Testing services that bypass Supabase and use Prisma/Drizzle directly

### Avoid pgLite when:

- Testing Row Level Security (RLS) policies (use pgtap instead)
- Verifying database-level security constraints
- Testing PostgreSQL functions and triggers
- You need to test the exact database behavior with authenticated Supabase users

### Use mocks when:

- Testing code that calls external services (auth providers, payment APIs)
- The database interaction is trivial (simple CRUD)
- You need to test specific error scenarios from the database layer
- Testing retry logic or connection handling

### If unsure:

Start with pgLite. It catches more bugs than mocks and runs faster than Docker. Only switch to pgtap/Supabase tests when you have RLS policies, or to mocks when you need to simulate specific database failures.

### pgLite Limitations

pgLite tests your service logic, not your database policies. If you're using Supabase with RLS, pgLite won't enforce those policies because:

1. pgLite runs without the Supabase auth context
2. RLS policies aren't applied in the pgLite environment
3. Database functions that depend on Supabase-specific features won't work

For RLS testing, use Supabase's built-in testing or pgtap. pgLite is best for the Prisma/Drizzle approach where security is enforced at the service layer rather than the database layer.

### How MakerKit Chooses Between Testing Approaches

We maintain both Supabase and ORM-based (Prisma/Drizzle) variants of MakerKit. Each uses a different testing strategy based on where authorization happens:

- **Supabase kits**: Security lives in RLS policies. We use pgtap to test that policies correctly restrict access based on user context. These tests run against Supabase directly because RLS behavior can't be simulated elsewhere.
- **ORM kits (Prisma/Drizzle)**: Security lives in service functions. We use pgLite because we're testing TypeScript business logic, not database policies. The service layer checks permissions before queries run, so we need to test that logic with real database operations.
This split emerged from experience: early on we tried mocking Prisma queries, but mocks don't catch issues like constraint violations or incorrect JOINs. pgLite gave us real PostgreSQL behavior without the CI complexity of Docker-based database testing.
- **If unsure:** Start with pgLite for service tests. They catch business logic bugs and are fast. Add pgtap/Supabase tests only when you have RLS policies or database-level security to verify.

## Running Database Tests

### All Tests in a Package

```bash
pnpm --filter @kit/database test:unit
```

### Watch Mode During Development

```bash
pnpm --filter @kit/database test:unit:watch
```

### Specific Test File

```bash
pnpm --filter @kit/database exec vitest run src/services/rate-limit.service.test.ts
```

### Coverage Report

```bash
pnpm --filter @kit/database test:unit:coverage
```

{% faq
   title="Frequently Asked Questions"
   items=[
     {"question": "Why use pgLite instead of a Docker PostgreSQL container?", "answer": "pgLite runs in-process as WebAssembly, eliminating container startup time and external dependencies. Tests start in milliseconds instead of seconds. Your CI pipeline doesn't need Docker support, and developers can run tests without any setup."},
     {"question": "Does pgLite support all PostgreSQL features?", "answer": "pgLite supports the vast majority of PostgreSQL features used in application code: transactions, constraints, JSON operations, common SQL functions. Some advanced features like full-text search extensions may have limitations. For typical ORM usage through Prisma, pgLite provides complete compatibility."},
     {"question": "Can I use pgLite with Drizzle instead of Prisma?", "answer": "Yes. pgLite works with any ORM that supports PostgreSQL. MakerKit's Drizzle kit uses the same pgLite testing approach. The setup differs slightly (Drizzle uses drizzle-orm/pglite instead of pglite-prisma-adapter), but the testing patterns are identical."},
     {"question": "How do I test Server Actions?", "answer": "Don't test Server Actions directly. Instead, keep actions thin (parse input with Zod, check auth, call service) and test the underlying service with pgLite. The service contains the business logic; the action is just glue code."},
     {"question": "How do I handle migrations in test databases?", "answer": "The createTestDatabase utility reads your Prisma migration files and executes them in order. Your test database has the exact same schema as production. When you add new migrations, tests automatically pick them up."},
     {"question": "Why use beforeAll instead of beforeEach for database creation?", "answer": "Creating the database and applying migrations takes time. By creating once per test file (beforeAll) and truncating data between tests (beforeEach), you get isolation without the overhead of recreating the schema for every test."},
     {"question": "How do I test services that need both database and external API mocks?", "answer": "Inject the database through the service factory, and mock external dependencies with vi.mock(). The database tests real queries while external calls are controlled."},
     {"question": "What if my tests are still slow?", "answer": "Check for unnecessary database operations in test setup, reduce the number of records created per test, and ensure you're not recreating the database for each test. The pool: 'forks' config runs test files in parallel."}
   ]
/%}

## Next Steps

- [pgLite Documentation](https://pglite.dev) for the complete pgLite documentation
- [MakerKit Prisma Kit Documentation](/docs/nextjs-prisma) for the complete Next.js + Prisma SaaS starter
- [Unit Testing with Vitest](/docs/nextjs-prisma/testing/unit) for testing pure functions and non-database code
- [Writing Your Own Tests](/docs/nextjs-prisma/testing/writing-tests) for E2E and general testing patterns
- [Testing Overview](/docs/nextjs-prisma/testing/overview) for the complete testing strategy
