Testing Strategies¶
Last Updated: 2025-01-22
This guide outlines the testing strategies, tools, and best practices for ensuring code quality in the AccessALI Customer Portal.
Overview¶
AccessALI employs a comprehensive testing strategy covering unit tests, integration tests, and end-to-end tests to ensure reliability, maintainability, and confidence in deployments.
Testing Philosophy¶
- Test behavior, not implementation - Focus on what the code does, not how
- Fast feedback - Unit tests run quickly for rapid development
- Realistic scenarios - Integration tests use actual dependencies
- User-centric E2E - Test real user workflows
Testing Stack¶
| Test Type | Tool | Purpose |
|---|---|---|
| Unit Tests | Jest | Test individual functions and components |
| Component Tests | React Testing Library | Test React components |
| Integration Tests | Jest + Supertest | Test API routes and database |
| E2E Tests | Playwright | Test complete user flows |
| Type Checking | TypeScript | Catch type errors at compile time |
Unit Testing¶
Setup¶
// jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/__tests__/**/*.test.ts'],
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/**/__tests__/**',
],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
}
Testing Repositories¶
// src/lib/repositories/__tests__/user-repository.test.ts
import { getUserProfile, createUser } from '../user-repository'
import { prisma } from '@/lib/prisma'
// Mock Prisma
jest.mock('@/lib/prisma', () => ({
prisma: {
user: {
findUnique: jest.fn(),
create: jest.fn(),
},
},
}))
describe('UserRepository', () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe('getUserProfile', () => {
it('should return user profile with property count', async () => {
// Arrange
const mockUser = {
id: 'user-123',
email: 'john@example.com',
firstName: 'John',
lastName: 'Doe',
_count: { properties: 2 },
}
;(prisma.user.findUnique as jest.Mock).mockResolvedValue(mockUser)
// Act
const result = await getUserProfile('user-123')
// Assert
expect(result).toEqual(mockUser)
expect(prisma.user.findUnique).toHaveBeenCalledWith({
where: { id: 'user-123' },
select: expect.any(Object),
})
})
it('should return null when user not found', async () => {
;(prisma.user.findUnique as jest.Mock).mockResolvedValue(null)
const result = await getUserProfile('non-existent')
expect(result).toBeNull()
})
it('should throw error on database failure', async () => {
;(prisma.user.findUnique as jest.Mock).mockRejectedValue(
new Error('Database connection failed')
)
await expect(getUserProfile('user-123')).rejects.toThrow(
'Failed to get user profile'
)
})
})
describe('createUser', () => {
it('should create user successfully', async () => {
const userData = {
email: 'new@example.com',
firstName: 'Jane',
lastName: 'Doe',
role: 'CUSTOMER',
status: 'ACTIVE',
}
const createdUser = { id: 'user-456', ...userData }
;(prisma.user.create as jest.Mock).mockResolvedValue(createdUser)
const result = await createUser(userData)
expect(result).toEqual(createdUser)
expect(prisma.user.create).toHaveBeenCalledWith({ data: userData })
})
})
})
Testing Use Cases¶
// src/lib/use-cases/__tests__/dashboard.test.ts
import { getDashboardDataUseCase } from '../dashboard'
import { getUserProfile } from '@/lib/repositories/user-repository'
import { getUserProperties } from '@/lib/repositories/property-repository'
// Mock repositories
jest.mock('@/lib/repositories/user-repository')
jest.mock('@/lib/repositories/property-repository')
describe('getDashboardDataUseCase', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('should return complete dashboard data', async () => {
// Arrange
const mockUser = {
id: 'user-123',
firstName: 'John',
lastName: 'Doe',
email: 'john@example.com',
}
const mockProperties = [
{ id: 'prop-1', projectName: 'Alveo Veranda' },
{ id: 'prop-2', projectName: 'Avida Towers' },
]
;(getUserProfile as jest.Mock).mockResolvedValue(mockUser)
;(getUserProperties as jest.Mock).mockResolvedValue(mockProperties)
// Act
const result = await getDashboardDataUseCase('user-123')
// Assert
expect(result.user).toBeDefined()
expect(result.properties).toHaveLength(2)
expect(result.notifications).toBeDefined()
expect(result.messages).toBeDefined()
})
it('should throw error when user not found', async () => {
;(getUserProfile as jest.Mock).mockResolvedValue(null)
await expect(getDashboardDataUseCase('non-existent')).rejects.toThrow(
'User not found'
)
})
})
Component Testing¶
Testing React Server Components¶
// src/components/__tests__/dashboard-header.test.tsx
import { render, screen } from '@testing-library/react'
import { DashboardHeader } from '../dashboard-header'
describe('DashboardHeader', () => {
it('should render user name', () => {
render(<DashboardHeader userName="John Doe" propertyCount={2} />)
expect(screen.getByText(/Welcome, John Doe/i)).toBeInTheDocument()
})
it('should display property count', () => {
render(<DashboardHeader userName="John Doe" propertyCount={3} />)
expect(screen.getByText(/3 properties/i)).toBeInTheDocument()
})
it('should handle zero properties', () => {
render(<DashboardHeader userName="Jane Doe" propertyCount={0} />)
expect(screen.getByText(/No properties/i)).toBeInTheDocument()
})
})
Testing Client Components¶
// src/components/__tests__/search-input.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { SearchInput } from '../search-input'
describe('SearchInput', () => {
it('should call onSearch after debounce delay', async () => {
const onSearch = jest.fn()
const user = userEvent.setup()
render(<SearchInput onSearch={onSearch} debounce={300} />)
const input = screen.getByPlaceholderText(/search/i)
await user.type(input, 'Alveo')
// Should not call immediately
expect(onSearch).not.toHaveBeenCalled()
// Should call after debounce
await waitFor(() => expect(onSearch).toHaveBeenCalledWith('Alveo'), {
timeout: 400,
})
})
it('should clear results when input is cleared', async () => {
const onSearch = jest.fn()
const user = userEvent.setup()
render(<SearchInput onSearch={onSearch} />)
const input = screen.getByPlaceholderText(/search/i)
await user.type(input, 'Test')
await user.clear(input)
await waitFor(() => expect(onSearch).toHaveBeenCalledWith(''))
})
})
Integration Testing¶
Testing API Routes¶
// src/app/api/__tests__/dashboard.test.ts
import { GET } from '../dashboard/route'
import { auth } from '@/lib/auth'
import { getDashboardDataUseCase } from '@/lib/use-cases/dashboard'
// Mock dependencies
jest.mock('@/lib/auth')
jest.mock('@/lib/use-cases/dashboard')
describe('GET /api/dashboard', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('should return dashboard data for authenticated user', async () => {
// Mock authenticated session
;(auth as jest.Mock).mockResolvedValue({
user: { id: 'user-123', email: 'john@example.com' },
})
// Mock dashboard data
const mockData = {
user: { id: 'user-123', firstName: 'John' },
properties: [],
notifications: [],
messages: [],
quickActionBadges: { pay: 0, schedule: 0, view: 0, contact: 0 },
}
;(getDashboardDataUseCase as jest.Mock).mockResolvedValue(mockData)
// Call route handler
const request = new Request('http://localhost:3000/api/dashboard')
const response = await GET(request)
// Assert
expect(response.status).toBe(200)
const json = await response.json()
expect(json.success).toBe(true)
expect(json.data).toEqual(mockData)
})
it('should return 401 when not authenticated', async () => {
;(auth as jest.Mock).mockResolvedValue(null)
const request = new Request('http://localhost:3000/api/dashboard')
const response = await GET(request)
expect(response.status).toBe(401)
const json = await response.json()
expect(json.success).toBe(false)
})
it('should handle errors gracefully', async () => {
;(auth as jest.Mock).mockResolvedValue({
user: { id: 'user-123' },
})
;(getDashboardDataUseCase as jest.Mock).mockRejectedValue(
new Error('Database error')
)
const request = new Request('http://localhost:3000/api/dashboard')
const response = await GET(request)
expect(response.status).toBe(500)
const json = await response.json()
expect(json.success).toBe(false)
expect(json.error).toBeDefined()
})
})
Testing Server Actions¶
// src/app/actions/__tests__/dashboard.test.ts
import { getDashboardData } from '../dashboard'
import { auth } from '@/lib/auth'
import { getDashboardDataUseCase } from '@/lib/use-cases/dashboard'
jest.mock('@/lib/auth')
jest.mock('@/lib/use-cases/dashboard')
describe('getDashboardData', () => {
it('should return dashboard data for authenticated user', async () => {
;(auth as jest.Mock).mockResolvedValue({
user: { id: 'user-123' },
})
const mockData = {
user: { id: 'user-123', firstName: 'John' },
properties: [],
notifications: [],
messages: [],
quickActionBadges: { pay: 0, schedule: 0, view: 0, contact: 0 },
}
;(getDashboardDataUseCase as jest.Mock).mockResolvedValue(mockData)
const result = await getDashboardData()
expect(result).toEqual(mockData)
})
it('should throw error when not authenticated', async () => {
;(auth as jest.Mock).mockResolvedValue(null)
await expect(getDashboardData()).rejects.toThrow('Unauthorized')
})
})
End-to-End Testing¶
Playwright Setup¶
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
],
webServer: {
command: 'pnpm dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
})
E2E Test Example¶
// tests/e2e/dashboard.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Dashboard', () => {
test.beforeEach(async ({ page }) => {
// Login
await page.goto('/auth/login')
await page.fill('input[name="email"]', 'test@example.com')
await page.fill('input[name="password"]', 'password123')
await page.click('button[type="submit"]')
// Wait for redirect to dashboard
await page.waitForURL('/dashboard')
})
test('should display user properties', async ({ page }) => {
// Verify dashboard loaded
await expect(page.locator('h1')).toContainText('Dashboard')
// Verify properties are displayed
const properties = page.locator('[data-testid="property-card"]')
await expect(properties).toHaveCount(2)
})
test('should search properties', async ({ page }) => {
// Enter search query
const searchInput = page.locator('input[placeholder*="Search"]')
await searchInput.fill('Alveo')
// Wait for results
await page.waitForSelector('[data-testid="search-results"]')
// Verify results
const results = page.locator('[data-testid="search-result"]')
await expect(results).toHaveCount(1)
await expect(results.first()).toContainText('Alveo')
})
test('should navigate to property details', async ({ page }) => {
// Click first property
await page.click('[data-testid="property-card"]:first-child')
// Verify navigation
await expect(page).toHaveURL(/\/properties\/[a-z0-9-]+/)
// Verify property details loaded
await expect(page.locator('h1')).toBeVisible()
})
})
Testing Best Practices¶
Unit Tests¶
Do
- Test one thing at a time
- Use descriptive test names
- Follow Arrange-Act-Assert pattern
- Mock external dependencies
- Test edge cases and error scenarios
Don't
- Test implementation details
- Create interdependent tests
- Use magic numbers without explanation
- Skip error case testing
- Test private functions directly
Integration Tests¶
Do
- Test realistic scenarios
- Use actual database (with test data)
- Test authentication and authorization
- Verify error responses
- Clean up test data
Don't
- Mock everything
- Share state between tests
- Use production database
- Skip cleanup
- Test UI in integration tests
E2E Tests¶
Do
- Test critical user flows
- Use data-testid for selectors
- Test on multiple browsers
- Verify visual elements
- Test error states
Don't
- Test every possible scenario
- Use fragile CSS selectors
- Skip mobile testing
- Ignore slow tests
- Test implementation details
Test Coverage¶
Coverage Goals¶
| Layer | Target Coverage | Notes |
|---|---|---|
| Repositories | 90%+ | Critical data access |
| Use Cases | 85%+ | Business logic |
| Server Actions | 80%+ | Entry points |
| API Routes | 80%+ | HTTP endpoints |
| Components | 70%+ | UI elements |
| Utilities | 90%+ | Helper functions |
Running Coverage¶
# Run tests with coverage
pnpm test --coverage
# View coverage report
open coverage/lcov-report/index.html
Common Testing Patterns¶
Testing Async Functions¶
it('should fetch user data', async () => {
const user = await getUserProfile('user-123')
expect(user).toBeDefined()
expect(user.id).toBe('user-123')
})
Testing Errors¶
it('should throw error when user not found', async () => {
await expect(getUserProfile('invalid')).rejects.toThrow(
'User not found'
)
})
Testing with Mocks¶
it('should call repository with correct params', async () => {
const mockGetUser = jest.fn().mockResolvedValue(mockUser)
await someFunction()
expect(mockGetUser).toHaveBeenCalledWith('user-123')
expect(mockGetUser).toHaveBeenCalledTimes(1)
})
Testing Forms¶
it('should submit form data', async () => {
const user = userEvent.setup()
const onSubmit = jest.fn()
render(<Form onSubmit={onSubmit} />)
await user.type(screen.getByLabelText(/name/i), 'John Doe')
await user.click(screen.getByRole('button', { name: /submit/i }))
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({ name: 'John Doe' })
})
})
Continuous Integration¶
GitHub Actions Workflow¶
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: pnpm install
- name: Run type check
run: pnpm type-check
- name: Run linter
run: pnpm lint
- name: Run unit tests
run: pnpm test
- name: Run E2E tests
run: pnpm test:e2e
- name: Upload coverage
uses: codecov/codecov-action@v3
Test Commands¶
# Run all tests
pnpm test
# Run tests in watch mode
pnpm test:watch
# Run specific test file
pnpm test user-repository.test.ts
# Run tests with coverage
pnpm test --coverage
# Run E2E tests
pnpm test:e2e
# Run E2E tests in UI mode
pnpm test:e2e:ui
# Type check
pnpm type-check
# Lint
pnpm lint
Related Documentation¶
- Coding Conventions - Code standards
- Error Handling - Error patterns
- API Routes - API testing
- Repositories - Repository testing
Next Steps¶
- Set up test environment
- Write unit tests for new features
- Add integration tests for API routes
- Create E2E tests for critical flows
- Monitor coverage metrics