Skip to content

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


Next Steps

  1. Set up test environment
  2. Write unit tests for new features
  3. Add integration tests for API routes
  4. Create E2E tests for critical flows
  5. Monitor coverage metrics