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