Skip to content

Latest commit

 

History

History
436 lines (347 loc) · 10 KB

File metadata and controls

436 lines (347 loc) · 10 KB

Testing Guide

Table of Contents

  1. Setup
  2. Running Tests
  3. Test Structure
  4. Writing Tests
  5. Test Examples

Setup

Install Dependencies

The boilerplate already includes testing dependencies:

{
  "devDependencies": {
    "vitest": "^1.0.0",
    "@testing-library/react": "^14.0.0",
    "@testing-library/jest-dom": "^6.0.0",
    "@testing-library/user-event": "^14.0.0",
    "jsdom": "^23.0.0"
  }
}

Configuration

// vite.config.js
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: './tests/setup.js',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: [
        'node_modules/',
        'tests/',
        '**/*.config.js',
      ],
    },
  },
});

Test Setup File

// tests/setup.js
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach, vi } from 'vitest';

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

global.fetch = vi.fn();

Running Tests

Available Commands

npm run test           # Run tests once
npm run test:watch    # Run tests in watch mode
npm run test:ui       # Run tests with UI
npm run test:coverage  # Run tests with coverage

Coverage Target

Type Target
Statements 80%
Branches 80%
Functions 80%
Lines 80%

Test Structure

tests/
├── setup.js              # Test configuration
├── fixtures/             # Mock data
│   └── users.json
├── components/           # Component tests
│   ├── Button.test.jsx
│   └── Input.test.jsx
├── features/             # Feature tests
│   ├── auth/
│   │   └── authSlice.test.js
│   └── products/
│       └── productSlice.test.js
└── utils/                # Utility tests
    └── validators.test.js

Writing Tests

Basic Component Test

// tests/components/Button.test.jsx
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Button } from '../../src/components/ui/Button';

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

  it('renders with variant', () => {
    render(<Button variant="primary">Submit</Button>);
    const button = screen.getByRole('button');
    expect(button).toHaveClass('bg-blue-500');
  });

  it('handles click events', () => {
    const handleClick = vi.fn();
    render(<Button onClick={handleClick}>Click me</Button>);
    
    screen.getByRole('button').click();
    expect(handleClick).toHaveBeenCalledTimes(1);
  });

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

Form Test

// tests/features/auth/LoginForm.test.jsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import { BrowserRouter } from 'react-router-dom';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { LoginForm } from '../../../src/features/auth/components/LoginForm';

const mockLogin = vi.fn();
vi.mock('../../../src/features/auth/hooks/useAuth', () => ({
  useAuth: () => ({
    login: mockLogin,
    isLoading: false,
  }),
}));

const renderWithProviders = (component) => {
  return render(
    <BrowserRouter>
      {component}
    </BrowserRouter>
  );
};

describe('LoginForm', () => {
  beforeEach(() => {
    mockLogin.mockClear();
  });

  it('renders login form', () => {
    renderWithProviders(<LoginForm />);
    
    expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
    expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
  });

  it('shows validation errors for empty fields', async () => {
    renderWithProviders(<LoginForm />);
    
    await userEvent.click(screen.getByRole('button', { name: /sign in/i }));
    
    await waitFor(() => {
      expect(screen.getByText(/email is required/i)).toBeInTheDocument();
      expect(screen.getByText(/password is required/i)).toBeInTheDocument();
    });
  });

  it('calls login with form data on submit', async () => {
    const testCredentials = {
      email: 'test@example.com',
      password: 'password123',
    };
    
    mockLogin.mockResolvedValue({ success: true });
    
    renderWithProviders(<LoginForm />);
    
    await userEvent.type(screen.getByLabelText(/email/i), testCredentials.email);
    await userEvent.type(screen.getByLabelText(/password/i), testCredentials.password);
    await userEvent.click(screen.getByRole('button', { name: /sign in/i }));
    
    await waitFor(() => {
      expect(mockLogin).toHaveBeenCalledWith(testCredentials);
    });
  });
});

Redux Slice Test

// tests/features/auth/authSlice.test.js
import { describe, it, expect } from 'vitest';
import authReducer, {
  login,
  logout,
  setUser,
} from '../../../src/features/auth/slices/authSlice';

describe('authSlice', () => {
  const initialState = {
    user: null,
    token: null,
    loading: false,
    error: null,
  };

  it('should return initial state', () => {
    expect(authReducer(undefined, { type: 'unknown' })).toEqual(initialState);
  });

  it('should handle login.pending', () => {
    const actual = authReducer(initialState, login.pending());
    expect(actual.loading).toBe(true);
    expect(actual.error).toBeNull();
  });

  it('should handle login.fulfilled', () => {
    const user = { id: 1, email: 'test@example.com' };
    const token = 'jwt-token';
    
    const actual = authReducer(
      { ...initialState, loading: true },
      login.fulfilled({ user, token })
    );
    
    expect(actual.loading).toBe(false);
    expect(actual.user).toEqual(user);
    expect(actual.token).toBe(token);
  });

  it('should handle login.rejected', () => {
    const error = 'Invalid credentials';
    
    const actual = authReducer(
      { ...initialState, loading: true },
      login.rejected(null, null, null, error)
    );
    
    expect(actual.loading).toBe(false);
    expect(actual.error).toBe(error);
  });

  it('should handle logout', () => {
    const stateWithUser = {
      ...initialState,
      user: { id: 1, email: 'test@example.com' },
      token: 'jwt-token',
    };
    
    const actual = authReducer(stateWithUser, logout());
    
    expect(actual.user).toBeNull();
    expect(actual.token).toBeNull();
  });

  it('should handle setUser', () => {
    const user = { id: 1, name: 'John' };
    const actual = authReducer(initialState, setUser(user));
    expect(actual.user).toEqual(user);
  });
});

Utility Function Test

// tests/utils/validators.test.js
import { describe, it, expect } from 'vitest';
import {
  validateEmail,
  validatePassword,
  validateRequired,
} from '../../src/utils/validators';

describe('validators', () => {
  describe('validateEmail', () => {
    it('returns true for valid email', () => {
      expect(validateEmail('test@example.com')).toBe(true);
    });

    it('returns false for invalid email', () => {
      expect(validateEmail('invalid')).toBe(false);
      expect(validateEmail('test@')).toBe(false);
      expect(validateEmail('@example.com')).toBe(false);
    });
  });

  describe('validatePassword', () => {
    it('returns true for valid password', () => {
      expect(validatePassword('password123')).toBe(true);
      expect(validatePassword('myP@ssw0rd')).toBe(true);
    });

    it('returns false for short password', () => {
      expect(validatePassword('short')).toBe(false);
    });

    it('returns false for password without number', () => {
      expect(validatePassword('password')).toBe(false);
    });
  });

  describe('validateRequired', () => {
    it('returns true for non-empty value', () => {
      expect(validateRequired('hello')).toBe(true);
      expect(validateRequired(123)).toBe(true);
    });

    it('returns false for empty value', () => {
      expect(validateRequired('')).toBe(false);
      expect(validateRequired(null)).toBe(false);
      expect(validateRequired(undefined)).toBe(false);
    });
  });
});

Test Examples

Test Fixtures

// tests/fixtures/users.json
[
  {
    "id": 1,
    "name": "John Doe",
    "email": "john@example.com",
    "role": "admin"
  },
  {
    "id": 2,
    "name": "Jane Smith",
    "email": "jane@example.com",
    "role": "user"
  }
]

Mocking API

// tests/mocks/handlers.js
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/users', () => {
    return HttpResponse.json([
      { id: 1, name: 'John' },
      { id: 2, name: 'Jane' },
    ]);
  }),
  
  http.post('/api/login', async ({ request }) => {
    const { email, password } = await request.json();
    
    if (email === 'test@example.com' && password === 'password123') {
      return HttpResponse.json({
        user: { id: 1, email },
        token: 'jwt-token',
      });
    }
    
    return HttpResponse.json(
      { message: 'Invalid credentials' },
      { status: 401 }
    );
  }),
];

Best Practices

Do Don't
Test behavior, not implementation Test implementation details
Use meaningful test names Use vague names like "test1"
Follow AAA pattern (Arrange, Act, Assert) Mix multiple tests
Test happy path and edge cases Only test happy path
Keep tests independent Rely on test order
Mock external dependencies Make real API calls

Coverage Report

Run coverage to see what's tested:

npm run test:coverage

View detailed report in coverage/index.html.