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 setupRunning 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=jsonCoverage 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
- Test behavior, not implementation
- Write descriptive test names
- Use AAA pattern (Arrange, Act, Assert)
- Keep tests independent
- Mock external dependencies
- Test edge cases
- Maintain high coverage (>80%)
- 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.jsonRelated Documentation
- Frontend Components - Component testing
- Backend Components - Backend testing
- Getting Started - Running tests