create-e2e-tests

📁 gilbertopsantosjr/fullstacknextjs 📅 12 days ago
1
总安装量
1
周安装量
#52984
全站排名
安装命令
npx skills add https://github.com/gilbertopsantosjr/fullstacknextjs --skill create-e2e-tests

Agent 安装分布

cursor 1
claude-code 1

Skill 文档

Create E2E Tests (Next.js + DynamoDB + Vitest)

Technology Stack

Component Technology
Test Runner Vitest
Database DynamoDB Local
HTTP Testing SuperTest (for API routes)
Mocking Vitest mocks, MSW (for HTTP)
Test Data @faker-js/faker
Assertions Vitest expect

Core Testing Principles

This skill enforces these critical testing principles aligned with modular architecture:

Principle How Applied in Tests
Test Independence Each feature’s tests run in complete isolation
State Isolation Never import from another feature’s DAL in tests
Explicit Communication Mock cross-feature interactions via service layer, not DAL
Replaceability Tests don’t depend on other features’ internal implementations

CRITICAL: Cross-feature DAL imports in test files are violations, even for test setup.

Quick Start

When creating tests, follow this workflow:

  1. Determine the feature and test type (unit/integration/e2e)
  2. Verify test isolation (no cross-feature DAL imports)
  3. Create test file co-located with source (<function>.test.ts)
  4. Set up DynamoDB Local test helpers
  5. Configure lifecycle hooks (beforeAll, afterAll, beforeEach)
  6. Write tests following Arrange-Act-Assert pattern
  7. Mock cross-feature dependencies via service layer
  8. Use @faker-js/faker for test data
  9. Clean up DynamoDB tables after each test
  10. Run state isolation verification before committing

File Location Pattern

Tests are co-located with source files:

src/features/<feature>/
├── dal/
│   ├── create_account.ts
│   ├── create_account.test.ts      # Co-located DAL test
│   ├── find_account_by_id.ts
│   └── find_account_by_id.test.ts  # Co-located DAL test
├── actions/
│   ├── create-account.ts
│   └── create-account.test.ts      # Co-located action test
└── service/
    ├── accounts-service.ts
    └── accounts-service.test.ts    # Co-located service test

Key Points:

  • Test files use .test.ts suffix
  • Tests live next to the code they test
  • NO separate __test__ folder pattern

Required Imports Template

DAL Tests (Integration with DynamoDB Local)

import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'
import { faker } from '@faker-js/faker'
import { setupTestDb, cleanupTestDb, clearTestData } from '@/test/db-helpers'
import { createAccount } from './create_account'
import { findAccountById } from './find_account_by_id'

Action Tests

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { faker } from '@faker-js/faker'
import { createAccountAction } from './create-account'

Service Layer Tests

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { faker } from '@faker-js/faker'
import { accountsService } from './accounts-service'
import * as dalModule from '../dal'

DynamoDB Local Setup

Test Database Helpers (src/test/db-helpers.ts)

import { DynamoDBClient } from '@aws-sdk/client-dynamodb'
import { Table } from 'dynamodb-onetable'
import { schema } from '@/features/database/db-schema'

// Use DynamoDB Local for tests
const testClient = new DynamoDBClient({
  endpoint: process.env.DYNAMODB_LOCAL_ENDPOINT || 'http://localhost:8000',
  region: 'local',
  credentials: {
    accessKeyId: 'local',
    secretAccessKey: 'local',
  },
})

let testTable: Table

export const setupTestDb = async () => {
  testTable = new Table({
    client: testClient,
    name: process.env.TEST_TABLE_NAME || 'test-table',
    schema,
    partial: true,
  })

  // Create table if it doesn't exist
  try {
    await testTable.createTable()
  } catch (error: any) {
    if (error.name !== 'ResourceInUseException') {
      throw error
    }
  }

  return testTable
}

export const cleanupTestDb = async () => {
  // Table cleanup is handled per-test
}

export const clearTestData = async (modelName: string) => {
  const Model = testTable.getModel(modelName)
  const items = await Model.scan({})

  for (const item of items) {
    await Model.remove(item)
  }
}

export const getTestTable = () => testTable

Vitest Config for DynamoDB Local (vitest.config.ts)

import { defineConfig } from 'vitest/config'
import path from 'path'

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    setupFiles: ['./src/test/setup.ts'],
    include: ['**/*.test.ts'],
    exclude: ['node_modules', 'dist'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: ['**/*.test.ts', '**/test/**'],
    },
  },
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
})

Test Setup File (src/test/setup.ts)

import { beforeAll, afterAll } from 'vitest'
import { setupTestDb, cleanupTestDb } from './db-helpers'

beforeAll(async () => {
  await setupTestDb()
})

afterAll(async () => {
  await cleanupTestDb()
})

DAL Test Pattern

// dal/account.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { faker } from '@faker-js/faker'
import { clearTestData } from '@/test/db-helpers'
import { createAccount } from './create_account'
import { findAccountById } from './find_account_by_id'
import { findAccountsByUser } from './find_accounts_by_user'
import { updateAccount } from './update_account'
import { deleteAccount } from './delete_account'

describe('Account DAL', () => {
  const testUserId = faker.string.ulid()

  beforeEach(async () => {
    await clearTestData('Account')
  })

  describe('createAccount', () => {
    it('creates a new account successfully', async () => {
      // Arrange
      const input = {
        userId: testUserId,
        name: faker.company.name(),
        description: faker.lorem.sentence(),
      }

      // Act
      const result = await createAccount(input)

      // Assert
      expect(result.success).toBe(true)
      expect(result.data).toMatchObject({
        name: input.name,
        description: input.description,
        userId: testUserId,
        status: 'active',
      })
      expect(result.data?.id).toBeDefined()
      expect(result.data?.createdAt).toBeDefined()
    })

    it('returns validation error for invalid input', async () => {
      // Arrange
      const input = {
        userId: testUserId,
        name: '', // Invalid: empty name
      }

      // Act
      const result = await createAccount(input)

      // Assert
      expect(result.success).toBe(false)
      expect(result.code).toBe('VALIDATION_ERROR')
    })

    it('returns validation error for missing userId', async () => {
      // Arrange
      const input = {
        name: faker.company.name(),
      }

      // Act
      const result = await createAccount(input as any)

      // Assert
      expect(result.success).toBe(false)
      expect(result.code).toBe('VALIDATION_ERROR')
    })
  })

  describe('findAccountById', () => {
    it('finds an existing account', async () => {
      // Arrange
      const createResult = await createAccount({
        userId: testUserId,
        name: faker.company.name(),
      })
      const accountId = createResult.data!.id

      // Act
      const result = await findAccountById(accountId, testUserId)

      // Assert
      expect(result.success).toBe(true)
      expect(result.data?.id).toBe(accountId)
      expect(result.data?.userId).toBe(testUserId)
    })

    it('returns null for non-existent account', async () => {
      // Act
      const result = await findAccountById(faker.string.ulid(), testUserId)

      // Assert
      expect(result.success).toBe(true)
      expect(result.data).toBeNull()
    })

    it('returns null when userId does not match', async () => {
      // Arrange
      const createResult = await createAccount({
        userId: testUserId,
        name: faker.company.name(),
      })
      const accountId = createResult.data!.id
      const differentUserId = faker.string.ulid()

      // Act
      const result = await findAccountById(accountId, differentUserId)

      // Assert
      expect(result.success).toBe(true)
      expect(result.data).toBeNull()
    })
  })

  describe('findAccountsByUser', () => {
    it('returns empty array when no accounts exist', async () => {
      // Act
      const result = await findAccountsByUser(testUserId)

      // Assert
      expect(result.success).toBe(true)
      expect(result.data?.items).toEqual([])
      expect(result.data?.hasMore).toBe(false)
    })

    it('returns all accounts for user', async () => {
      // Arrange
      await createAccount({ userId: testUserId, name: 'Account 1' })
      await createAccount({ userId: testUserId, name: 'Account 2' })
      await createAccount({ userId: testUserId, name: 'Account 3' })

      // Act
      const result = await findAccountsByUser(testUserId)

      // Assert
      expect(result.success).toBe(true)
      expect(result.data?.items).toHaveLength(3)
    })

    it('returns paginated results', async () => {
      // Arrange
      await createAccount({ userId: testUserId, name: 'Account 1' })
      await createAccount({ userId: testUserId, name: 'Account 2' })
      await createAccount({ userId: testUserId, name: 'Account 3' })

      // Act - First page
      const page1 = await findAccountsByUser(testUserId, { limit: 2 })

      // Assert
      expect(page1.success).toBe(true)
      expect(page1.data?.items).toHaveLength(2)
      expect(page1.data?.hasMore).toBe(true)
      expect(page1.data?.nextCursor).toBeDefined()

      // Act - Second page
      const page2 = await findAccountsByUser(testUserId, {
        limit: 2,
        cursor: page1.data!.nextCursor,
      })

      // Assert
      expect(page2.success).toBe(true)
      expect(page2.data?.items).toHaveLength(1)
      expect(page2.data?.hasMore).toBe(false)
    })

    it('does not return accounts from other users', async () => {
      // Arrange
      const otherUserId = faker.string.ulid()
      await createAccount({ userId: testUserId, name: 'My Account' })
      await createAccount({ userId: otherUserId, name: 'Other Account' })

      // Act
      const result = await findAccountsByUser(testUserId)

      // Assert
      expect(result.success).toBe(true)
      expect(result.data?.items).toHaveLength(1)
      expect(result.data?.items[0].name).toBe('My Account')
    })
  })

  describe('updateAccount', () => {
    it('updates an existing account', async () => {
      // Arrange
      const createResult = await createAccount({
        userId: testUserId,
        name: 'Original Name',
      })
      const accountId = createResult.data!.id

      // Act
      const result = await updateAccount(accountId, testUserId, {
        name: 'Updated Name',
        status: 'inactive',
      })

      // Assert
      expect(result.success).toBe(true)
      expect(result.data?.name).toBe('Updated Name')
      expect(result.data?.status).toBe('inactive')
    })

    it('returns error for non-existent account', async () => {
      // Act
      const result = await updateAccount(
        faker.string.ulid(),
        testUserId,
        { name: 'Updated' }
      )

      // Assert
      expect(result.success).toBe(false)
      expect(result.code).toBe('ACCOUNT_NOT_FOUND')
    })
  })

  describe('deleteAccount', () => {
    it('deletes an existing account', async () => {
      // Arrange
      const createResult = await createAccount({
        userId: testUserId,
        name: faker.company.name(),
      })
      const accountId = createResult.data!.id

      // Act
      const result = await deleteAccount(accountId, testUserId)

      // Assert
      expect(result.success).toBe(true)

      // Verify deleted
      const findResult = await findAccountById(accountId, testUserId)
      expect(findResult.data).toBeNull()
    })
  })
})

Server Action Test Pattern

// actions/create-account.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { faker } from '@faker-js/faker'
import { createAccountAction } from './create-account'
import * as dalModule from '../dal'

// Mock the DAL module
vi.mock('../dal', () => ({
  createAccount: vi.fn(),
}))

describe('createAccountAction', () => {
  const mockUserId = faker.string.ulid()
  const mockCtx = {
    user: { id: mockUserId },
  }

  beforeEach(() => {
    vi.clearAllMocks()
  })

  it('creates account successfully', async () => {
    // Arrange
    const input = {
      name: faker.company.name(),
      description: faker.lorem.sentence(),
    }

    const mockAccount = {
      id: faker.string.ulid(),
      ...input,
      userId: mockUserId,
      status: 'active',
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
    }

    vi.mocked(dalModule.createAccount).mockResolvedValue({
      success: true,
      data: mockAccount,
    })

    // Act
    const result = await createAccountAction.handler({
      input,
      ctx: mockCtx,
    })

    // Assert
    expect(dalModule.createAccount).toHaveBeenCalledWith({
      ...input,
      userId: mockUserId,
    })
    expect(result).toEqual(mockAccount)
  })

  it('throws error when DAL returns failure', async () => {
    // Arrange
    const input = { name: faker.company.name() }

    vi.mocked(dalModule.createAccount).mockResolvedValue({
      success: false,
      error: 'Database error',
      code: 'CREATE_ACCOUNT_ERROR',
    })

    // Act & Assert
    await expect(
      createAccountAction.handler({ input, ctx: mockCtx })
    ).rejects.toThrow('Database error')
  })

  it('validates input with Zod schema', async () => {
    // Arrange
    const invalidInput = { name: '' } // Empty name should fail

    // Act & Assert
    await expect(
      createAccountAction.handler({ input: invalidInput, ctx: mockCtx })
    ).rejects.toThrow()
  })
})

Service Layer Test Pattern

// service/accounts-service.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { faker } from '@faker-js/faker'
import { accountsService } from './accounts-service'
import * as dalModule from '../dal'

// Mock the DAL module
vi.mock('../dal', () => ({
  findAccountById: vi.fn(),
  findAccountsByUser: vi.fn(),
}))

describe('accountsService', () => {
  const testUserId = faker.string.ulid()

  beforeEach(() => {
    vi.clearAllMocks()
  })

  describe('getAccountById', () => {
    it('returns account summary when found', async () => {
      // Arrange
      const mockAccount = {
        id: faker.string.ulid(),
        name: faker.company.name(),
        status: 'active',
        description: faker.lorem.sentence(),
        userId: testUserId,
        createdAt: new Date().toISOString(),
        updatedAt: new Date().toISOString(),
      }

      vi.mocked(dalModule.findAccountById).mockResolvedValue({
        success: true,
        data: mockAccount,
      })

      // Act
      const result = await accountsService.getAccountById(mockAccount.id, testUserId)

      // Assert
      expect(result).toEqual({
        id: mockAccount.id,
        name: mockAccount.name,
        status: mockAccount.status,
      })
      // Verify only necessary fields are exposed
      expect(result).not.toHaveProperty('description')
      expect(result).not.toHaveProperty('userId')
    })

    it('returns null when account not found', async () => {
      // Arrange
      vi.mocked(dalModule.findAccountById).mockResolvedValue({
        success: true,
        data: null,
      })

      // Act
      const result = await accountsService.getAccountById(
        faker.string.ulid(),
        testUserId
      )

      // Assert
      expect(result).toBeNull()
    })

    it('returns null when DAL returns error', async () => {
      // Arrange
      vi.mocked(dalModule.findAccountById).mockResolvedValue({
        success: false,
        error: 'Database error',
        code: 'FIND_ERROR',
      })

      // Act
      const result = await accountsService.getAccountById(
        faker.string.ulid(),
        testUserId
      )

      // Assert
      expect(result).toBeNull()
    })
  })

  describe('accountExists', () => {
    it('returns true when account exists', async () => {
      // Arrange
      vi.mocked(dalModule.findAccountById).mockResolvedValue({
        success: true,
        data: { id: faker.string.ulid() } as any,
      })

      // Act
      const result = await accountsService.accountExists(
        faker.string.ulid(),
        testUserId
      )

      // Assert
      expect(result).toBe(true)
    })

    it('returns false when account does not exist', async () => {
      // Arrange
      vi.mocked(dalModule.findAccountById).mockResolvedValue({
        success: true,
        data: null,
      })

      // Act
      const result = await accountsService.accountExists(
        faker.string.ulid(),
        testUserId
      )

      // Assert
      expect(result).toBe(false)
    })
  })
})

Cross-Feature Mocking Pattern

CRITICAL: Never import from another feature’s DAL. Mock the service layer instead.

When Feature A Tests Need Feature B Data

BAD:

// In billing feature test
import { createAccount } from '@/features/accounts/dal' // VIOLATION!

it('creates invoice for account', async () => {
  await createAccount({ ... }) // Direct DAL access
})

GOOD:

// In billing feature test
import { accountsService } from '@/features/accounts'
import { vi } from 'vitest'

vi.mock('@/features/accounts', () => ({
  accountsService: {
    getAccountById: vi.fn(),
    accountExists: vi.fn(),
  },
}))

it('creates invoice for account', async () => {
  // Mock the service layer response
  vi.mocked(accountsService.accountExists).mockResolvedValue(true)
  vi.mocked(accountsService.getAccountById).mockResolvedValue({
    id: 'account-123',
    name: 'Test Account',
    status: 'active',
  })

  // Now test billing logic
  const result = await createInvoice({ accountId: 'account-123', ... })
  expect(result.success).toBe(true)
})

HTTP Mocking with MSW (for External APIs)

// test/mocks/handlers.ts
import { http, HttpResponse } from 'msw'

export const handlers = [
  // Mock Stripe API
  http.post('https://api.stripe.com/v1/customers', () => {
    return HttpResponse.json({
      id: 'cus_test123',
      email: 'test@example.com',
    })
  }),

  // Mock SendGrid API
  http.post('https://api.sendgrid.com/v3/mail/send', () => {
    return HttpResponse.json({ message: 'success' })
  }),
]
// test/setup.ts
import { setupServer } from 'msw/node'
import { handlers } from './mocks/handlers'
import { beforeAll, afterAll, afterEach } from 'vitest'

const server = setupServer(...handlers)

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterAll(() => server.close())
afterEach(() => server.resetHandlers())

Test Lifecycle Hooks

beforeAll – Setup Database

beforeAll(async () => {
  await setupTestDb()
})

afterAll – Cleanup Database

afterAll(async () => {
  await cleanupTestDb()
})

beforeEach – Clear Test Data

beforeEach(async () => {
  await clearTestData('Account')
  vi.clearAllMocks()
})

afterEach – Reset Mocks

afterEach(() => {
  vi.restoreAllMocks()
})

Test Data Factory Pattern

// test/factories/account-factory.ts
import { faker } from '@faker-js/faker'
import type { AccountEntity, CreateAccountInput } from '@/features/accounts/model/account-schemas'

export const accountFactory = {
  build(overrides: Partial<AccountEntity> = {}): AccountEntity {
    return {
      id: faker.string.ulid(),
      userId: faker.string.ulid(),
      name: faker.company.name(),
      description: faker.lorem.sentence(),
      status: 'active',
      metadata: {},
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
      ...overrides,
    }
  },

  buildCreateInput(overrides: Partial<CreateAccountInput> = {}): CreateAccountInput {
    return {
      name: faker.company.name(),
      description: faker.lorem.sentence(),
      ...overrides,
    }
  },
}

Usage:

import { accountFactory } from '@/test/factories/account-factory'

it('creates account', async () => {
  const input = accountFactory.buildCreateInput({ name: 'Custom Name' })
  const result = await createAccount({ ...input, userId: testUserId })
  expect(result.success).toBe(true)
})

Anti-Patterns to Avoid

Cross-Feature DAL Imports in Tests (CRITICAL)

BAD:

// In billing test
import { createAccount } from '@/features/accounts/dal'

GOOD:

// Mock the service layer instead
vi.mock('@/features/accounts', () => ({
  accountsService: { getAccountById: vi.fn() },
}))

Using Jest Instead of Vitest

BAD:

import { jest } from '@jest/globals'
jest.mock(...)

GOOD:

import { vi } from 'vitest'
vi.mock(...)

Tests in __test__ Folder

BAD:

src/features/accounts/__test__/create-account.test.ts

GOOD:

src/features/accounts/dal/create_account.test.ts

Missing RepositoryResult Assertions

BAD:

it('creates account', async () => {
  const result = await createAccount(input)
  expect(result.id).toBeDefined() // Assumes success
})

GOOD:

it('creates account', async () => {
  const result = await createAccount(input)
  expect(result.success).toBe(true)
  expect(result.data?.id).toBeDefined()
})

Test Isolation Verification

Detection Command for Test Files

# Find cross-feature DAL imports in test files
grep -r "from '@/features/[^']*dal'" src/features/**/*.test.ts | \
  awk -F: '{
    match($1, /features\/([^/]+)/, feat);
    match($2, /features\/([^/]+)\/dal/, imported);
    if (feat[1] != imported[1] && imported[1] != "") {
      print "VIOLATION: " $1 " imports from " imported[1] "/dal"
    }
  }'

Expected: Empty output

Verification Script

#!/bin/bash
# verify-test-isolation.sh <feature-name>

FEATURE=$1
echo "Verifying test isolation for: $FEATURE"

# Check for cross-feature DAL imports in tests
VIOLATIONS=$(grep -r "from '@/features/" src/features/$FEATURE/**/*.test.ts 2>/dev/null | \
  grep "/dal'" | \
  grep -v "@/features/$FEATURE")

if [ ! -z "$VIOLATIONS" ]; then
  echo "CRITICAL: Cross-feature DAL imports found in tests:"
  echo "$VIOLATIONS"
  exit 1
fi

echo "Test isolation verified"

Coverage Requirements

Metric Minimum Target
Branches 70% 80%
Functions 75% 85%
Lines 75% 85%
Statements 75% 85%

Priority Order for Coverage

  1. DAL functions (High) – Core data operations
  2. Service layer (High) – Cross-feature contracts
  3. Server actions (Medium) – Input validation
  4. Error handling (Medium) – Edge cases

CI/CD Integration

GitHub Actions Workflow

name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      dynamodb-local:
        image: amazon/dynamodb-local:latest
        ports:
          - 8000:8000

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Verify Test Isolation
        run: |
          VIOLATIONS=$(grep -r "from '@/features/" src/features/**/*.test.ts 2>/dev/null | \
            grep "/dal'" | \
            awk -F: '{
              match($1, /features\/([^/]+)/, feat);
              match($2, /features\/([^/]+)\/dal/, imported);
              if (feat[1] != imported[1] && imported[1] != "") {
                print "VIOLATION: " $1
              }
            }')
          if [ ! -z "$VIOLATIONS" ]; then
            echo "$VIOLATIONS"
            exit 1
          fi

      - name: Run Tests
        run: npm test
        env:
          DYNAMODB_LOCAL_ENDPOINT: http://localhost:8000
          TEST_TABLE_NAME: test-table

      - name: Upload Coverage
        uses: codecov/codecov-action@v4
        with:
          files: ./coverage/lcov.info

Running Tests

# Run all tests
npx vitest

# Run tests for specific feature
npx vitest src/features/accounts

# Run tests in watch mode
npx vitest --watch

# Run with coverage
npx vitest --coverage

# Run specific test file
npx vitest src/features/accounts/dal/create_account.test.ts

References