Skip to content

Latest commit

Β 

History

History
755 lines (617 loc) Β· 17.8 KB

File metadata and controls

755 lines (617 loc) Β· 17.8 KB

πŸ§ͺ Testing Guide

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

🎯 Testing Philosophy

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

πŸ”§ Testing Stack

Frontend Testing

  • 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

Backend Testing

  • Jest: JavaScript testing framework
  • Supertest: HTTP assertion library
  • MongoDB Memory Server: In-memory database for testing

🎨 Frontend Testing

Test Setup

Vitest Configuration (client/vitest.config.ts)

/// <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,
  },
})

Test Setup File (client/src/tests/setup.ts)

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(),
}))

Component Testing

Basic Component Test

// 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')
  })
})

Testing Components with Context

// 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 }

Testing Hooks

// 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()
  })
})

API Mocking with MSW

MSW Setup (client/src/tests/mocks/handlers.ts)

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
          }
        }
      })
    )
  })
]

MSW Server Setup (client/src/tests/mocks/server.ts)

import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)

Using MSW in Tests

// 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())

βš™οΈ Backend Testing

Test Configuration

Jest Configuration (server/jest.config.js)

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
    }
  }
}

Test Setup (server/tests/setup.js)

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({})
  }
})

Unit Testing

Testing Models

// 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()
  })
})

Testing Services

// 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()
    })
  })
})

Integration Testing

Testing API Endpoints

// 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)
    })
  })
})

Testing Protected Routes

// 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)
    })
  })
})

🎭 End-to-End Testing

Playwright Setup

Playwright Configuration (e2e/playwright.config.ts)

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 Test Example

// 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')
  })
})

πŸ“Š Test Coverage

Generating Coverage Reports

Frontend Coverage

# Run tests with coverage
npm run test -- --coverage

# View coverage report
open coverage/index.html

Backend Coverage

# Run tests with coverage
npm test -- --coverage

# Generate coverage report
npx jest --coverage --coverageReporters=html

Coverage Configuration

// package.json
{
  "scripts": {
    "test:coverage": "vitest run --coverage",
    "test:coverage:ui": "vitest --ui --coverage"
  }
}

πŸƒβ€β™‚οΈ Running Tests

Development Workflow

# 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

CI/CD Testing

# .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

πŸ“‹ Testing Best Practices

General Guidelines

  • 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

Frontend Testing Tips

  • Use data-testid attributes for reliable element selection
  • Test user interactions, not component internals
  • Mock API calls consistently
  • Test loading and error states
  • Focus on accessibility in tests

Backend Testing Tips

  • 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.