# Next.js Testing Standard

> **Scope:** frontend/nextjs/testing
> **Layer:** 2 (on keyword)
> **Keywords:** testing, vitest, jest, testing-library, msw, component test, unit test
> **Load When:** writing tests for Next.js components or hooks

**Verified against:** Vitest 3 + React Testing Library 16 + MSW 2. Last-verified: 2026-05-20.

---

Vitest + React Testing Library for component tests. MSW for API mocking. Co-locate tests with the code they test.

## Core Rules

- ALWAYS co-locate test files: `user-card.test.tsx` next to `user-card.tsx`
- ALWAYS use `@testing-library/user-event` for interactions — not `fireEvent`
- ALWAYS mock the API layer with MSW — never mock `fetch` directly
- NEVER test implementation details — test what the user sees
- ALWAYS test the happy path + one error path per component

## Test File Co-location

```
features/users/
├── components/
│   ├── user-card.tsx
│   ├── user-card.test.tsx      ← co-located
│   ├── user-list.tsx
│   └── user-list.test.tsx
├── hooks/
│   ├── use-users.ts
│   └── use-users.test.ts       ← co-located
```

## Component Test Pattern

```tsx
// features/users/components/user-card.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserCard } from './user-card';

const mockUser = { id: '1', name: 'João Silva', email: 'joao@example.com', role: 'user' as const };

describe('UserCard', () => {
  it('renders user name and email', () => {
    render(<UserCard user={mockUser} />);
    expect(screen.getByText('João Silva')).toBeInTheDocument();
    expect(screen.getByText('joao@example.com')).toBeInTheDocument();
  });

  it('calls onEdit with user id when edit button clicked', async () => {
    const onEdit = vi.fn();
    render(<UserCard user={mockUser} onEdit={onEdit} />);
    await userEvent.click(screen.getByRole('button', { name: /edit/i }));
    expect(onEdit).toHaveBeenCalledWith('1');
  });
});
```

## Hook Test with MSW

```ts
// features/users/hooks/use-users.test.ts
import { renderHook, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { server } from '@/test/msw-server';
import { QueryClientWrapper } from '@/test/helpers';
import { useUsers } from './use-users';

describe('useUsers', () => {
  it('returns users from API', async () => {
    server.use(
      http.get('/api/proxy/users', () =>
        HttpResponse.json([{ id: '1', name: 'João', email: 'j@ex.com' }])
      )
    );
    const { result } = renderHook(() => useUsers(), { wrapper: QueryClientWrapper });
    await waitFor(() => expect(result.current.isSuccess).toBe(true));
    expect(result.current.data).toHaveLength(1);
  });

  it('returns error state when API fails', async () => {
    server.use(http.get('/api/proxy/users', () => new HttpResponse(null, { status: 500 })));
    const { result } = renderHook(() => useUsers(), { wrapper: QueryClientWrapper });
    await waitFor(() => expect(result.current.isError).toBe(true));
  });
});
```

## Test Helper Setup

```tsx
// test/helpers.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

export function QueryClientWrapper({ children }: { children: React.ReactNode }) {
  const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
```

## Common Mistakes

| Wrong | Right | Why |
|-------|-------|-----|
| `fireEvent.click(button)` | `await userEvent.click(button)` | userEvent simulates real browser events |
| Mock `global.fetch` | Mock with MSW | MSW intercepts at network level |
| `expect(component).toMatchSnapshot()` | `expect(screen.getByText(...))` | Snapshots break on any change |
| Test file in `__tests__/` folder | Co-locate with source | Easier to find, deleted with the component |

---

*MORPH-SPEC by Polymorphism Tech*
