This guide covers testing strategies, tools, and best practices for the OpenLN project.
OpenLN follows a comprehensive testing approach:
- Unit Tests: Test individual functions and components in isolation
- Integration Tests: Test how different parts work together
- End-to-End Tests: Test complete user workflows
- API Tests: Test backend endpoints and business logic
- Vitest: Fast unit test runner
- React Testing Library: Component testing utilities
- jsdom: DOM simulation for Node.js
- MSW: API mocking
- Playwright: End-to-end testing
- Jest: JavaScript testing framework
- Supertest: HTTP assertion library
- MongoDB Memory Server: In-memory database for testing
/// <reference types="vitest" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/tests/setup.ts',
css: true,
},
})import '@testing-library/jest-dom'
import { cleanup } from '@testing-library/react'
import { afterEach, vi } from 'vitest'
// Cleanup after each test
afterEach(() => {
cleanup()
})
// Mock environment variables
vi.mock('import.meta.env', () => ({
VITE_API_URL: 'http://localhost:5000/api/v1',
VITE_NODE_ENV: 'test'
}))
// Mock fetch globally
global.fetch = vi.fn()
// Mock IntersectionObserver
global.IntersectionObserver = vi.fn(() => ({
disconnect: vi.fn(),
observe: vi.fn(),
unobserve: vi.fn(),
}))// src/components/common/Button/Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { Button } from './Button'
describe('Button', () => {
it('should render with correct text', () => {
render(<Button>Click me</Button>)
expect(screen.getByText('Click me')).toBeInTheDocument()
})
it('should handle click events', () => {
const handleClick = vi.fn()
render(<Button onClick={handleClick}>Click me</Button>)
fireEvent.click(screen.getByText('Click me'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('should be disabled when disabled prop is true', () => {
render(<Button disabled>Click me</Button>)
expect(screen.getByRole('button')).toBeDisabled()
})
it('should apply correct CSS classes', () => {
render(<Button variant="primary" size="large">Button</Button>)
const button = screen.getByRole('button')
expect(button).toHaveClass('btn-primary')
expect(button).toHaveClass('btn-large')
})
})// src/tests/test-utils.tsx
import React from 'react'
import { render, RenderOptions } from '@testing-library/react'
import { BrowserRouter } from 'react-router-dom'
import { AuthProvider } from '../context/AuthContext'
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
initialEntries?: string[]
}
const AllTheProviders: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return (
<BrowserRouter>
<AuthProvider>
{children}
</AuthProvider>
</BrowserRouter>
)
}
const customRender = (ui: React.ReactElement, options?: CustomRenderOptions) =>
render(ui, { wrapper: AllTheProviders, ...options })
export * from '@testing-library/react'
export { customRender as render }// src/hooks/useAuth.test.ts
import { renderHook, act } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { useAuth } from './useAuth'
import { AuthProvider } from '../context/AuthContext'
const wrapper = ({ children }: { children: React.ReactNode }) => (
<AuthProvider>{children}</AuthProvider>
)
describe('useAuth', () => {
it('should return initial auth state', () => {
const { result } = renderHook(() => useAuth(), { wrapper })
expect(result.current.user).toBeNull()
expect(result.current.loading).toBe(false)
expect(result.current.error).toBeNull()
})
it('should handle login', async () => {
const { result } = renderHook(() => useAuth(), { wrapper })
await act(async () => {
await result.current.login('test@example.com', 'password')
})
expect(result.current.user).toBeDefined()
expect(result.current.error).toBeNull()
})
})import { rest } from 'msw'
export const handlers = [
// Auth endpoints
rest.post('/api/v1/auth/login', (req, res, ctx) => {
const { email, password } = req.body as any
if (email === 'test@example.com' && password === 'password') {
return res(
ctx.status(200),
ctx.json({
success: true,
data: {
token: 'mock-jwt-token',
user: {
id: '1',
email: 'test@example.com',
name: 'Test User'
}
}
})
)
}
return res(
ctx.status(401),
ctx.json({
success: false,
message: 'Invalid credentials'
})
)
}),
// Users endpoints
rest.get('/api/v1/users/me', (req, res, ctx) => {
const authHeader = req.headers.get('authorization')
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res(
ctx.status(401),
ctx.json({ success: false, message: 'Unauthorized' })
)
}
return res(
ctx.status(200),
ctx.json({
success: true,
data: {
id: '1',
email: 'test@example.com',
name: 'Test User'
}
})
)
}),
// Courses endpoints
rest.get('/api/v1/courses', (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
success: true,
data: {
items: [
{
id: '1',
title: 'React Fundamentals',
description: 'Learn React basics'
}
],
pagination: {
currentPage: 1,
totalPages: 1,
totalItems: 1
}
}
})
)
})
]import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)// src/tests/setup.ts (add to existing setup)
import { server } from './mocks/server'
// Start server before all tests
beforeAll(() => server.listen())
// Reset handlers after each test
afterEach(() => server.resetHandlers())
// Clean up after all tests
afterAll(() => server.close())export default {
testEnvironment: 'node',
setupFilesAfterEnv: ['<rootDir>/tests/setup.js'],
testMatch: ['**/__tests__/**/*.js', '**/?(*.)+(spec|test).js'],
collectCoverageFrom: [
'controllers/**/*.js',
'services/**/*.js',
'middleware/**/*.js',
'models/**/*.js',
'!**/node_modules/**'
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
}
}import { MongoMemoryServer } from 'mongodb-memory-server'
import mongoose from 'mongoose'
let mongoServer
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create()
const mongoUri = mongoServer.getUri()
await mongoose.connect(mongoUri)
})
afterAll(async () => {
await mongoose.disconnect()
await mongoServer.stop()
})
beforeEach(async () => {
const collections = mongoose.connection.collections
for (const key in collections) {
await collections[key].deleteMany({})
}
})// tests/models/User.test.js
import { User } from '../../models/User.js'
describe('User Model', () => {
it('should create a user with valid data', async () => {
const userData = {
email: 'test@example.com',
password: 'hashedpassword',
name: 'Test User'
}
const user = new User(userData)
const savedUser = await user.save()
expect(savedUser._id).toBeDefined()
expect(savedUser.email).toBe(userData.email)
expect(savedUser.name).toBe(userData.name)
})
it('should not create user without required fields', async () => {
const user = new User({})
await expect(user.save()).rejects.toThrow()
})
it('should not create user with invalid email', async () => {
const userData = {
email: 'invalid-email',
password: 'password',
name: 'Test User'
}
const user = new User(userData)
await expect(user.save()).rejects.toThrow()
})
})// tests/services/userService.test.js
import { userService } from '../../services/userService.js'
import { User } from '../../models/User.js'
describe('UserService', () => {
const userData = {
email: 'test@example.com',
password: 'password123',
name: 'Test User'
}
describe('create', () => {
it('should create a new user', async () => {
const user = await userService.create(userData)
expect(user.email).toBe(userData.email)
expect(user.name).toBe(userData.name)
expect(user.password).not.toBe(userData.password) // Should be hashed
})
it('should throw error for duplicate email', async () => {
await userService.create(userData)
await expect(userService.create(userData)).rejects.toThrow('User already exists')
})
})
describe('findById', () => {
it('should find user by ID', async () => {
const createdUser = await userService.create(userData)
const foundUser = await userService.findById(createdUser.id)
expect(foundUser.id).toBe(createdUser.id)
expect(foundUser.email).toBe(userData.email)
})
it('should return null for non-existent user', async () => {
const nonExistentId = new mongoose.Types.ObjectId()
const user = await userService.findById(nonExistentId)
expect(user).toBeNull()
})
})
})// tests/integration/auth.test.js
import request from 'supertest'
import app from '../../app.js'
import { User } from '../../models/User.js'
describe('Auth Endpoints', () => {
describe('POST /api/v1/auth/register', () => {
it('should register a new user', async () => {
const userData = {
email: 'test@example.com',
password: 'password123',
name: 'Test User'
}
const response = await request(app)
.post('/api/v1/auth/register')
.send(userData)
.expect(201)
expect(response.body.success).toBe(true)
expect(response.body.data.user.email).toBe(userData.email)
expect(response.body.data.token).toBeDefined()
})
it('should not register user with invalid email', async () => {
const userData = {
email: 'invalid-email',
password: 'password123',
name: 'Test User'
}
const response = await request(app)
.post('/api/v1/auth/register')
.send(userData)
.expect(400)
expect(response.body.success).toBe(false)
})
})
describe('POST /api/v1/auth/login', () => {
beforeEach(async () => {
await User.create({
email: 'test@example.com',
password: await bcrypt.hash('password123', 12),
name: 'Test User'
})
})
it('should login with valid credentials', async () => {
const response = await request(app)
.post('/api/v1/auth/login')
.send({
email: 'test@example.com',
password: 'password123'
})
.expect(200)
expect(response.body.success).toBe(true)
expect(response.body.data.token).toBeDefined()
})
it('should not login with invalid credentials', async () => {
const response = await request(app)
.post('/api/v1/auth/login')
.send({
email: 'test@example.com',
password: 'wrongpassword'
})
.expect(401)
expect(response.body.success).toBe(false)
})
})
})// tests/integration/users.test.js
import request from 'supertest'
import jwt from 'jsonwebtoken'
import app from '../../app.js'
import { User } from '../../models/User.js'
describe('User Endpoints', () => {
let user
let token
beforeEach(async () => {
user = await User.create({
email: 'test@example.com',
password: 'hashedpassword',
name: 'Test User'
})
token = jwt.sign({ id: user._id }, process.env.JWT_SECRET)
})
describe('GET /api/v1/users/me', () => {
it('should get current user profile', async () => {
const response = await request(app)
.get('/api/v1/users/me')
.set('Authorization', `Bearer ${token}`)
.expect(200)
expect(response.body.success).toBe(true)
expect(response.body.data.email).toBe(user.email)
})
it('should return 401 without token', async () => {
const response = await request(app)
.get('/api/v1/users/me')
.expect(401)
expect(response.body.success).toBe(false)
})
})
})import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
})// e2e/tests/auth.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Authentication', () => {
test('should login successfully', async ({ page }) => {
await page.goto('/login')
await page.fill('[data-testid=email-input]', 'test@example.com')
await page.fill('[data-testid=password-input]', 'password123')
await page.click('[data-testid=login-button]')
await expect(page).toHaveURL('/dashboard')
await expect(page.locator('[data-testid=user-menu]')).toBeVisible()
})
test('should show error for invalid credentials', async ({ page }) => {
await page.goto('/login')
await page.fill('[data-testid=email-input]', 'test@example.com')
await page.fill('[data-testid=password-input]', 'wrongpassword')
await page.click('[data-testid=login-button]')
await expect(page.locator('[data-testid=error-message]')).toContainText('Invalid credentials')
})
})# Run tests with coverage
npm run test -- --coverage
# View coverage report
open coverage/index.html# Run tests with coverage
npm test -- --coverage
# Generate coverage report
npx jest --coverage --coverageReporters=html// package.json
{
"scripts": {
"test:coverage": "vitest run --coverage",
"test:coverage:ui": "vitest --ui --coverage"
}
}# Frontend tests
cd client
# Run all tests
npm test
# Run tests in watch mode
npm run test:watch
# Run specific test file
npm test -- Button.test.tsx
# Run tests with coverage
npm run test:coverage
# Backend tests
cd server
# Run all tests
npm test
# Run tests in watch mode
npm run test:watch
# Run integration tests only
npm test -- --testPathPattern=integration
# Run with coverage
npm run test:coverage# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test-frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
- run: cd client && npm ci
- run: cd client && npm run test:coverage
test-backend:
runs-on: ubuntu-latest
services:
mongodb:
image: mongo:6.0
ports:
- 27017:27017
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
- run: cd server && npm ci
- run: cd server && npm run test:coverage- Test behavior, not implementation
- Write descriptive test names
- Use AAA pattern (Arrange, Act, Assert)
- Keep tests independent and isolated
- Mock external dependencies
- Test edge cases and error conditions
- Use
data-testidattributes for reliable element selection - Test user interactions, not component internals
- Mock API calls consistently
- Test loading and error states
- Focus on accessibility in tests
- Use in-memory database for speed
- Test business logic thoroughly
- Validate request/response formats
- Test authentication and authorization
- Mock external services
A comprehensive testing strategy ensures code quality, reduces bugs, and provides confidence when making changes. Regular testing is essential for maintaining a reliable application.