---
name: testing-patterns
description: Guide for writing tests with Jest and React Testing Library. Use this when creating tests, debugging test failures, or implementing test patterns for Server Actions and Next.js 15.
---

# Testing Patterns Skill

When writing tests in this project, follow these patterns specific to Next.js 15 and React 19.

## Critical Next.js 15 Testing Constraints

### ❌ Async Server Components - NOT Fully Supported

```typescript
// ❌ CANNOT test async Server Components with Jest
export default async function ServerComponent() {
  const data = await fetch('https://api.example.com/data');
  return <div>{data.title}</div>;
}

// ❌ This will fail in Jest
describe('ServerComponent', () => {
  it('should render', async () => {
    const { container } = render(await ServerComponent());  // Error!
  });
});

// ✅ Solution: Use E2E tests (Playwright) for async components
```

### ❌ API Routes - Avoid Testing in Jest

```typescript
// ❌ Web API compatibility issues
import { POST } from '@/app/api/webhook/route';

describe('Webhook', () => {
  it('should process', async () => {
    const request = new NextRequest(...);  // ❌ Error: Request not defined
    await POST(request);
  });
});

// ✅ Solution: Test Server Actions instead
```

## Mock Setup Order - CRITICAL

### Mocks MUST come BEFORE imports

```typescript
// ✅ CORRECT: Mock first, then import
const mockStripeCreate = jest.fn();

jest.mock('stripe', () => {
  return class MockStripe {
    checkout = {
      sessions: {
        create: (...args: unknown[]) => mockStripeCreate(...args)
      }
    };
  };
});

// THEN import
import { createCheckoutSession } from '@/actions/checkout';

// ❌ INCORRECT: Import before mock
import { createCheckoutSession } from '@/actions/checkout';  // Too early!

jest.mock('stripe', () => ({
  // Mock comes too late - already imported
}));
```

## Server Action Testing Pattern

### Mocking `next/navigation`

Server Actions that call `redirect()` or use routing helpers require `next/navigation` to be mocked, since it is not available in the Jest test environment.

```typescript
// Mock next/navigation BEFORE imports.
// redirect() must THROW like the real implementation — Next signals a redirect by throwing
// NEXT_REDIRECT to terminate the action's control flow. A plain `jest.fn()` no-op would let
// code after redirect() keep running, producing outcomes impossible in production. Throw a
// sentinel and assert on it (e.g. `await expect(action()).rejects.toThrow(/NEXT_REDIRECT/)`).
jest.mock('next/navigation', () => ({
  redirect: jest.fn((url) => {
    throw new Error(`NEXT_REDIRECT: ${url}`);
  }),
  useRouter: jest.fn(() => ({ push: jest.fn() })),
}));
```

### Basic Server Action Test

```typescript
// 1. Setup mocks BEFORE imports
const mockCreateLead = jest.fn();
jest.mock('@/lib/salesforce', () => ({
  createSalesforceRecord: mockCreateLead,
}));

// 2. Import AFTER mocks
import { submitWizardToSalesforce } from '@/actions/salesforce-wizard';

describe('submitWizardToSalesforce', () => {
  beforeEach(() => {
    jest.clearAllMocks();
    
    mockCreateLead.mockResolvedValue({
      id: 'a1B123ABC',
      success: true,
    });
  });

  it('should create Salesforce lead', async () => {
    // 3. Create FormData
    const formData = new FormData();
    formData.append('email', 'test@example.com');
    formData.append('firstName', 'John');

    // 4. Call with prevState (useActionState signature)
    const result = await submitWizardToSalesforce(
      { success: false, message: '', timestamp: 0 },
      formData
    );

    // 5. Assert
    expect(result.success).toBe(true);
    expect(result.leadId).toBeDefined();
  });

  it('should return failure state when the API rejects', async () => {
    // Simulate an error thrown by the mocked dependency
    mockCreateLead.mockRejectedValue(new Error('API error'));

    const formData = new FormData();
    formData.append('email', 'test@example.com');
    formData.append('firstName', 'John');

    const result = await submitWizardToSalesforce(
      { success: false, message: '', timestamp: 0 },
      formData
    );

    // Assert the error path for THIS action: it catches the API failure and returns a
    // failure state. Match the action's own contract — actions that instead throw (e.g.
    // via redirect(), see the mock above) must be asserted with `rejects.toThrow(...)`.
    expect(result.success).toBe(false);
    expect(result.message).toContain('error');
  });
});
```

### useActionState Signature - CRITICAL

```typescript
// Server Actions for useActionState MUST have this signature:
export async function action(
  prevState: ActionState,    // First param: previous state
  formData: FormData         // Second param: form data
): Promise<ActionState> {
  // Implementation
}

// ❌ INCORRECT signature
export async function action(formData: FormData) { }  // Missing prevState!
```

## Type Safety in Tests

### Type Check BEFORE Tests

```bash
# Type check only (no test execution)
npm run type-check

# Both type check + tests (CI mode)
npm run test:ci
```

## beforeEach Pattern

```typescript
describe('MyTests', () => {
  beforeEach(() => {
    // ✅ ALWAYS clear mocks
    jest.clearAllMocks();
    
    // Reset mock implementations
    mockFunction.mockResolvedValue(defaultResponse);
  });

  afterEach(() => {
    jest.restoreAllMocks();
  });
});
```

## Integration Testing Pattern

```typescript
// src/__tests__/integration/flow.test.ts

// 1. MOCKS SETUP (before imports)
const mockStripeCreate = jest.fn();
jest.mock('stripe', () => class MockStripe {
  checkout = { sessions: { create: mockStripeCreate } };
});

// 2. IMPORTS (after mocks)
import { createCheckoutSessionAction } from '@/actions/create-checkout-session';

// 3. TEST SUITE
describe('Payment Flow Integration', () => {
  beforeEach(() => {
    jest.clearAllMocks();
    
    mockStripeCreate.mockResolvedValue({
      id: 'cs_test_123',
      url: 'https://checkout.stripe.com/pay/cs_test_123',
    });
  });
  
  it('should complete full flow', async () => {
    const formData = new FormData();
    formData.append('email', 'test@example.com');
    
    const result = await createCheckoutSessionAction(
      { success: false, url: '', error: '', timestamp: 0 },
      formData
    );
    
    expect(result.success).toBe(true);
  });
});
```

## Testing Checklist

Before committing tests:

- [ ] **Mock before import** - All mocks defined before imports
- [ ] **Clear mocks** - `jest.clearAllMocks()` in `beforeEach`
- [ ] **Test Server Actions** - Not API routes
- [ ] **Match actual types** - Assertions match real return types
- [ ] **Type check passes** - `npm run type-check` succeeds
- [ ] **All tests pass** - `npm test` succeeds

## Quick Commands

```bash
# Run all tests
npm test

# Run specific test file
npm test -- payment-flow-integration

# Run tests in watch mode
npm test -- --watch

# Type check without tests
npm run type-check

# Both type check and tests
npm run test:ci
```

## Common Pitfalls

### ❌ Testing Implementation Details

```typescript
// ❌ BAD: Testing internal state
expect(component.state.isLoading).toBe(true);

// ✅ GOOD: Testing user-visible behavior
expect(screen.getByText('Loading...')).toBeInTheDocument();
```
