Skip to content

Testing

This guide covers testing strategies, tools, and best practices for the LMS project.

Testing Stack

  • Vitest - Unit and integration testing
  • Testing Library - React component testing
  • jsdom - DOM environment for tests
  • MSW (optional) - API mocking

Test Structure

apps/client-frontend/
├── src/
│   ├── components/
│   │   ├── Button.tsx
│   │   └── Button.test.tsx       # Component tests
│   │
│   ├── hooks/
│   │   ├── useAuth.ts
│   │   └── useAuth.test.ts       # Hook tests
│   │
│   └── utilities/
│       ├── validation.ts
│       └── validation.test.ts    # Unit tests

├── vitest.config.ts              # Vitest configuration
└── setupTests.ts                 # Test setup

Running Tests

bash
# Run all tests
pnpm test

# Run tests in watch mode
pnpm test:watch

# Run tests with coverage
pnpm coverage

# Run tests in UI mode
pnpm test:ui

# Run tests for specific package
cd apps/client-frontend
pnpm test

# Run specific test file
pnpm test Button.test.tsx

# Run tests matching pattern
pnpm test --grep "Button"

Unit Testing

Testing Utilities

typescript
// utilities/validation.test.ts
import { describe, it, expect } from "vitest";
import { isValidEmail, isStrongPassword } from "./validation";

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

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

  it("handles empty string", () => {
    expect(isValidEmail("")).toBe(false);
  });
});

describe("isStrongPassword", () => {
  it("returns true for strong password", () => {
    expect(isStrongPassword("StrongPass123")).toBe(true);
  });

  it("returns false for weak password", () => {
    expect(isStrongPassword("weak")).toBe(false);
    expect(isStrongPassword("12345678")).toBe(false);
    expect(isStrongPassword("NoNumbers")).toBe(false);
  });
});

Testing Hooks

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

// Mock Redux
vi.mock("react-redux", () => ({
  useSelector: vi.fn(),
  useDispatch: vi.fn(),
}));

describe("useAuth", () => {
  it("returns authentication state", () => {
    const { result } = renderHook(() => useAuth());

    expect(result.current).toHaveProperty("user");
    expect(result.current).toHaveProperty("isAuthenticated");
  });

  it("requireAuth redirects when not authenticated", () => {
    const { result } = renderHook(() => useAuth());

    act(() => {
      const canAccess = result.current.requireAuth();
      expect(canAccess).toBe(false);
    });
  });
});

Component Testing

Basic Component Test

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

describe('Button', () => {
  it('renders button text', () => {
    render(<Button>Click me</Button>);

    expect(screen.getByRole('button')).toHaveTextContent('Click me');
  });

  it('calls onClick when clicked', async () => {
    const handleClick = vi.fn();
    const user = userEvent.setup();

    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 { container } = render(<Button variant="destructive">Delete</Button>);

    expect(container.firstChild).toHaveClass('bg-destructive');
  });
});

Testing with Context

typescript
// components/UserProfile.test.tsx
import { render, screen } from '@testing-library/react';
import { Provider } from 'react-redux';
import { store } from '@/redux/store';
import { UserProfile } from './UserProfile';

function renderWithProviders(ui: React.ReactElement) {
  return render(
    <Provider store={store}>
      {ui}
    </Provider>
  );
}

describe('UserProfile', () => {
  it('displays user name', () => {
    renderWithProviders(<UserProfile />);

    expect(screen.getByText(/welcome/i)).toBeInTheDocument();
  });
});

Testing Async Components

typescript
// components/CourseList.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { CourseList } from './CourseList';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: false,
    },
  },
});

function renderWithQuery(ui: React.ReactElement) {
  return render(
    <QueryClientProvider client={queryClient}>
      {ui}
    </QueryClientProvider>
  );
}

describe('CourseList', () => {
  it('shows loading state', () => {
    renderWithQuery(<CourseList />);

    expect(screen.getByText(/loading/i)).toBeInTheDocument();
  });

  it('displays courses after loading', async () => {
    renderWithQuery(<CourseList />);

    await waitFor(() => {
      expect(screen.getByText('Course 1')).toBeInTheDocument();
    });
  });

  it('shows error message on failure', async () => {
    // Mock API error
    renderWithQuery(<CourseList />);

    await waitFor(() => {
      expect(screen.getByText(/error/i)).toBeInTheDocument();
    });
  });
});

Mocking

Mocking Modules

typescript
import { vi } from "vitest";

// Mock entire module
vi.mock("@repo/api", () => ({
  apiClient: {
    GET: vi.fn(),
    POST: vi.fn(),
  },
}));

// Mock specific function
vi.mock("./utils", () => ({
  formatDate: vi.fn(() => "2024-01-01"),
}));

Mocking API Calls

typescript
import { apiClient } from "@repo/api";

describe("useCourses", () => {
  it("fetches courses", async () => {
    // Mock API response
    vi.mocked(apiClient.GET).mockResolvedValue({
      data: [
        { id: 1, name: "Course 1" },
        { id: 2, name: "Course 2" },
      ],
      error: undefined,
    });

    const { result } = renderHook(() => useCourses());

    await waitFor(() => {
      expect(result.current.data).toHaveLength(2);
    });
  });
});

Mocking Timers

typescript
import { vi } from "vitest";

describe("debounced function", () => {
  vi.useFakeTimers();

  it("debounces calls", () => {
    const fn = vi.fn();
    const debounced = debounce(fn, 1000);

    debounced();
    debounced();
    debounced();

    expect(fn).not.toHaveBeenCalled();

    vi.advanceTimersByTime(1000);

    expect(fn).toHaveBeenCalledTimes(1);
  });

  vi.useRealTimers();
});

Test Coverage

Running Coverage

bash
# Generate coverage report
pnpm coverage

# Generate coverage with specific reporters
pnpm coverage -- --reporter=html --reporter=json

Coverage Configuration

typescript
// vitest.config.ts
export default defineConfig({
  test: {
    coverage: {
      provider: "v8",
      reporter: ["text", "json", "html"],
      include: ["src/**/*.{ts,tsx}"],
      exclude: [
        "src/**/*.test.{ts,tsx}",
        "src/**/*.spec.{ts,tsx}",
        "src/vite-env.d.ts",
      ],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 80,
        statements: 80,
      },
    },
  },
});

Integration Testing

Testing Flows

typescript
describe('Course creation flow', () => {
  it('creates course and navigates to course page', async () => {
    const user = userEvent.setup();

    render(<App />);

    // Navigate to create course
    await user.click(screen.getByText('Create Course'));

    // Fill form
    await user.type(screen.getByLabelText('Course Name'), 'New Course');
    await user.type(screen.getByLabelText('Course Code'), 'CS101');

    // Submit
    await user.click(screen.getByText('Create'));

    // Verify navigation
    await waitFor(() => {
      expect(screen.getByText('New Course')).toBeInTheDocument();
    });
  });
});

Backend Testing

Service Tests

typescript
// services/course.service.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { CourseService } from "./course.service";
import { DataSource } from "typeorm";

describe("CourseService", () => {
  let service: CourseService;
  let dataSource: DataSource;

  beforeEach(async () => {
    // Setup test database
    dataSource = await createTestDataSource();
    service = new CourseService(dataSource);
  });

  it("creates a course", async () => {
    const course = await service.createCourse({
      name: "Test Course",
      code: "TEST101",
    });

    expect(course.id).toBeDefined();
    expect(course.name).toBe("Test Course");
  });

  it("throws error when course not found", async () => {
    await expect(service.getCourse(999)).rejects.toThrow("Course not found");
  });
});

Controller Tests

typescript
// controllers/course.controller.test.ts
import { describe, it, expect, vi } from "vitest";
import { CourseController } from "./course.controller";

describe("CourseController", () => {
  it("returns courses for authenticated user", async () => {
    const mockService = {
      getUserCourses: vi.fn().mockResolvedValue([{ id: 1, name: "Course 1" }]),
    };

    const controller = new CourseController(mockService);
    const req = { user: { id: 1 } };

    const courses = await controller.getCourses(req);

    expect(courses).toHaveLength(1);
    expect(mockService.getUserCourses).toHaveBeenCalledWith(1);
  });
});

Test Helpers

Test Utilities

typescript
// test/utils.tsx
import { ReactElement } from 'react';
import { render } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Provider } from 'react-redux';
import { BrowserRouter } from 'react-router';

export function renderWithProviders(
  ui: ReactElement,
  options?: {
    queryClient?: QueryClient;
    store?: Store;
  }
) {
  const queryClient = options?.queryClient || new QueryClient({
    defaultOptions: {
      queries: { retry: false },
    },
  });

  const store = options?.store || createTestStore();

  return render(
    <Provider store={store}>
      <QueryClientProvider client={queryClient}>
        <BrowserRouter>
          {ui}
        </BrowserRouter>
      </QueryClientProvider>
    </Provider>
  );
}

export function createTestStore(initialState = {}) {
  return configureStore({
    reducer: rootReducer,
    preloadedState: initialState,
  });
}

Best Practices

  1. Test behavior, not implementation
  2. Write descriptive test names
  3. Use AAA pattern (Arrange, Act, Assert)
  4. Keep tests independent
  5. Mock external dependencies
  6. Test edge cases
  7. Maintain high coverage (>80%)
  8. Run tests in CI/CD

CI/CD Integration

yaml
# .github/workflows/test.yml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: 20
      - uses: pnpm/action-setup@v2

      - run: pnpm install
      - run: pnpm test
      - run: pnpm coverage

      - name: Upload coverage
        uses: codecov/codecov-action@v3
        with:
          files: ./coverage/coverage-final.json