AgentSkillsCN

Webapp Testing

Web 应用测试

SKILL.md

Webapp Testing Skill

Testing patterns for Next.js applications with Playwright and Vitest

PRINCIPLES

  1. Test behavior, not implementation: Focus on user interactions
  2. Arrange-Act-Assert: Clear test structure
  3. Isolation: Each test independent
  4. Fast feedback: Quick unit tests, slower E2E tests

PLAYWRIGHT E2E TESTING

Setup

typescript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
    { name: 'mobile', use: { ...devices['iPhone 13'] } },
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

Page Object Model

typescript
// e2e/pages/login.page.ts
import { Page, Locator, expect } from '@playwright/test';

export class LoginPage {
  readonly page: Page;
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;
  readonly errorMessage: Locator;

  constructor(page: Page) {
    this.page = page;
    this.emailInput = page.getByLabel('Email');
    this.passwordInput = page.getByLabel('Password');
    this.submitButton = page.getByRole('button', { name: 'Sign in' });
    this.errorMessage = page.getByRole('alert');
  }

  async goto() {
    await this.page.goto('/login');
  }

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }

  async expectError(message: string) {
    await expect(this.errorMessage).toHaveText(message);
  }
}

// e2e/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/login.page';

test.describe('Login', () => {
  let loginPage: LoginPage;

  test.beforeEach(async ({ page }) => {
    loginPage = new LoginPage(page);
    await loginPage.goto();
  });

  test('successful login redirects to dashboard', async ({ page }) => {
    await loginPage.login('user@example.com', 'password123');
    await expect(page).toHaveURL('/dashboard');
  });

  test('shows error for invalid credentials', async () => {
    await loginPage.login('wrong@email.com', 'wrongpass');
    await loginPage.expectError('Invalid credentials');
  });
});

Common Patterns

typescript
// Wait for network
await page.waitForResponse(resp => 
  resp.url().includes('/api/users') && resp.status() === 200
);

// Screenshot testing
await expect(page).toHaveScreenshot('homepage.png');

// Mock API responses
await page.route('**/api/users', route => {
  route.fulfill({
    status: 200,
    body: JSON.stringify([{ id: 1, name: 'John' }]),
  });
});

// Test accessibility
import AxeBuilder from '@axe-core/playwright';

test('should pass accessibility audit', async ({ page }) => {
  await page.goto('/');
  const results = await new AxeBuilder({ page }).analyze();
  expect(results.violations).toEqual([]);
});

// File upload
await page.setInputFiles('input[type="file"]', 'path/to/file.pdf');

// Drag and drop
await page.dragAndDrop('#source', '#target');

COMPONENT TESTING (Vitest + Testing Library)

Setup

typescript
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./vitest.setup.ts'],
    include: ['**/*.test.{ts,tsx}'],
  },
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
});

// vitest.setup.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';

afterEach(() => {
  cleanup();
});

Component Tests

typescript
// components/Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { Button } from './Button';

describe('Button', () => {
  it('renders with text', () => {
    render(<Button>Click me</Button>);
    expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
  });

  it('calls onClick when clicked', async () => {
    const user = userEvent.setup();
    const handleClick = vi.fn();
    
    render(<Button onClick={handleClick}>Click</Button>);
    await user.click(screen.getByRole('button'));
    
    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  it('is disabled when disabled prop is true', () => {
    render(<Button disabled>Disabled</Button>);
    expect(screen.getByRole('button')).toBeDisabled();
  });

  it('applies variant classes', () => {
    const { rerender } = render(<Button variant="primary">Button</Button>);
    expect(screen.getByRole('button')).toHaveClass('bg-blue-600');
    
    rerender(<Button variant="destructive">Button</Button>);
    expect(screen.getByRole('button')).toHaveClass('bg-red-600');
  });
});

Testing Hooks

typescript
// hooks/useCounter.test.ts
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { useCounter } from './useCounter';

describe('useCounter', () => {
  it('initializes with default value', () => {
    const { result } = renderHook(() => useCounter());
    expect(result.current.count).toBe(0);
  });

  it('initializes with custom value', () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });

  it('increments count', () => {
    const { result } = renderHook(() => useCounter());
    
    act(() => {
      result.current.increment();
    });
    
    expect(result.current.count).toBe(1);
  });

  it('decrements count', () => {
    const { result } = renderHook(() => useCounter(5));
    
    act(() => {
      result.current.decrement();
    });
    
    expect(result.current.count).toBe(4);
  });
});

MOCKING WITH MSW

Setup

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

export const handlers = [
  http.get('/api/users', () => {
    return HttpResponse.json([
      { id: 1, name: 'John Doe' },
      { id: 2, name: 'Jane Doe' },
    ]);
  }),
  
  http.post('/api/users', async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json({ id: 3, ...body }, { status: 201 });
  }),
  
  http.get('/api/users/:id', ({ params }) => {
    const { id } = params;
    return HttpResponse.json({ id: Number(id), name: 'John Doe' });
  }),
];

// mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);

// vitest.setup.ts
import { server } from './mocks/server';

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Using MSW in Tests

typescript
import { http, HttpResponse } from 'msw';
import { server } from '@/mocks/server';

test('handles API error', async () => {
  // Override handler for this test
  server.use(
    http.get('/api/users', () => {
      return HttpResponse.json(
        { message: 'Internal Server Error' },
        { status: 500 }
      );
    })
  );
  
  render(<UserList />);
  
  await waitFor(() => {
    expect(screen.getByText('Error loading users')).toBeInTheDocument();
  });
});

TESTING SERVER COMPONENTS

typescript
// For Next.js Server Components
import { render } from '@testing-library/react';

// Mock server-side functions
vi.mock('@/lib/db', () => ({
  getUsers: vi.fn().mockResolvedValue([
    { id: 1, name: 'Test User' }
  ]),
}));

// Test async server component
test('renders server component with data', async () => {
  const ServerComponent = await import('./ServerComponent');
  const { container } = render(await ServerComponent.default());
  
  expect(container).toHaveTextContent('Test User');
});

TEST PATTERNS

AAA Pattern

typescript
test('user can submit form', async () => {
  // Arrange
  const user = userEvent.setup();
  const onSubmit = vi.fn();
  render(<ContactForm onSubmit={onSubmit} />);
  
  // Act
  await user.type(screen.getByLabelText('Name'), 'John');
  await user.type(screen.getByLabelText('Email'), 'john@test.com');
  await user.click(screen.getByRole('button', { name: 'Submit' }));
  
  // Assert
  expect(onSubmit).toHaveBeenCalledWith({
    name: 'John',
    email: 'john@test.com',
  });
});

Data-Testid (Last Resort)

typescript
// Only when semantic queries don't work
<div data-testid="custom-element">Content</div>

// In test
screen.getByTestId('custom-element');

// Priority of queries (best to worst):
// 1. getByRole - accessible roles
// 2. getByLabelText - form elements
// 3. getByPlaceholderText
// 4. getByText - non-interactive elements
// 5. getByDisplayValue
// 6. getByAltText - images
// 7. getByTitle
// 8. getByTestId - last resort

ANTI-PATTERNS

❌ Avoid

typescript
// ❌ Testing implementation details
expect(component.state.isOpen).toBe(true);

// ❌ Snapshot abuse
expect(component).toMatchSnapshot(); // For everything

// ❌ Waiting with setTimeout
await new Promise(r => setTimeout(r, 1000));

// ❌ Using container.querySelector
const button = container.querySelector('.btn-primary');

✅ Prefer

typescript
// ✅ Test behavior
expect(screen.getByRole('dialog')).toBeVisible();

// ✅ Snapshots for specific use cases
expect(component).toMatchInlineSnapshot(`"expected output"`);

// ✅ Use waitFor or findBy
await waitFor(() => expect(element).toBeVisible());
await screen.findByText('Loaded');

// ✅ Use accessible queries
const button = screen.getByRole('button', { name: 'Submit' });

QUICK REFERENCE

QueryWhen to Use
getByRoleButtons, links, inputs, headings
getByLabelTextForm fields with labels
getByTextStatic text content
findByXAsync/waiting for element
queryByXAssert element NOT present
MatcherAssertion
toBeInTheDocument()Element exists in DOM
toBeVisible()Element is visible
toHaveTextContent()Has text content
toHaveAttribute()Has attribute value
toBeDisabled()Element is disabled