Testing Guide

Comprehensive testing strategies and practices for ensuring portfolio quality and reliability.

๐Ÿงช Testing Stack

ToolPurposeUse Cases
JestUnit testingComponents, utilities, services
CypressE2E testingUser flows, integration tests
Testing LibraryComponent testingReact component behavior
ESLintStatic analysisCode quality, best practices
TypeScriptType checkingCompile-time error detection

๐Ÿš€ Quick Start

# Run all tests
pnpm test

# Unit tests
pnpm test:unit

# Unit tests with coverage
pnpm test:coverage

# E2E tests (headless)
pnpm test:e2e:ci

# E2E tests (interactive)
pnpm test:e2e

# Component tests
pnpm test:component

# Watch mode
pnpm test:watch

๐ŸŽฏ Unit Testing with Jest

Configuration

// jest.config.cjs
module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
    '\\.(css|less|scss|sass)$': 'identity-obj-proxy',
  },
  collectCoverageFrom: [
    'src/**/*.{js,jsx,ts,tsx}',
    '!src/**/*.d.ts',
    '!src/**/*.stories.tsx',
    '!src/**/__tests__/**',
  ],
  coverageThresholds: {
    global: {
      branches: 70,
      functions: 70,
      lines: 70,
      statements: 70,
    },
  },
};

Setup File

// jest.setup.js
import '@testing-library/jest-dom';

// Mock next/navigation
jest.mock('next/navigation', () => ({
  useRouter() {
    return {
      push: jest.fn(),
      pathname: '/',
    };
  },
  useSearchParams() {
    return new URLSearchParams();
  },
}));

// Mock IntersectionObserver
global.IntersectionObserver = class IntersectionObserver {
  constructor() {}
  disconnect() {}
  observe() {}
  unobserve() {}
  takeRecords() {
    return [];
  }
};

Component Testing Example

// src/features/projects/components/__tests__/ProjectCard.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { ProjectCard } from '../ProjectCard';

const mockProject = {
  id: '1',
  title: 'Test Project',
  description: 'Test description',
  technologies: ['React', 'TypeScript'],
  githubUrl: 'https://github.com/test/project',
  liveUrl: 'https://test-project.com',
};

describe('ProjectCard', () => {
  it('renders project information', () => {
    render(<ProjectCard project={mockProject} />);
    
    expect(screen.getByText('Test Project')).toBeInTheDocument();
    expect(screen.getByText('Test description')).toBeInTheDocument();
  });

  it('displays technology badges', () => {
    render(<ProjectCard project={mockProject} />);
    
    expect(screen.getByText('React')).toBeInTheDocument();
    expect(screen.getByText('TypeScript')).toBeInTheDocument();
  });

  it('renders links when provided', () => {
    render(<ProjectCard project={mockProject} />);
    
    const githubLink = screen.getByRole('link', { name: /github/i });
    expect(githubLink).toHaveAttribute('href', mockProject.githubUrl);
    
    const liveLink = screen.getByRole('link', { name: /live demo/i });
    expect(liveLink).toHaveAttribute('href', mockProject.liveUrl);
  });

  it('handles hover state', () => {
    render(<ProjectCard project={mockProject} />);
    
    const card = screen.getByTestId('project-card');
    fireEvent.mouseEnter(card);
    
    expect(card).toHaveClass('hover');
  });
});

Utility Function Testing

// src/core/utils/__tests__/utils.test.ts
import { cn } from '../utils';

describe('cn utility', () => {
  it('merges class names', () => {
    expect(cn('foo', 'bar')).toBe('foo bar');
  });

  it('handles conditional classes', () => {
    expect(cn('foo', false && 'bar', 'baz')).toBe('foo baz');
  });

  it('resolves tailwind conflicts', () => {
    expect(cn('px-2', 'px-4')).toBe('px-4');
  });
});

Custom Hook Testing

// src/features/projects/hooks/__tests__/useProjects.test.ts
import { renderHook, waitFor } from '@testing-library/react';
import { useProjects } from '../useProjects';
import * as projectService from '../../services/projectService';

jest.mock('../../services/projectService');

describe('useProjects', () => {
  it('fetches projects on mount', async () => {
    const mockProjects = [{ id: '1', title: 'Test Project' }];
    jest.spyOn(projectService, 'getAll').mockResolvedValue(mockProjects);

    const { result } = renderHook(() => useProjects());

    expect(result.current.loading).toBe(true);

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(result.current.projects).toEqual(mockProjects);
    expect(result.current.error).toBeNull();
  });

  it('handles errors', async () => {
    const mockError = new Error('Failed to fetch');
    jest.spyOn(projectService, 'getAll').mockRejectedValue(mockError);

    const { result } = renderHook(() => useProjects());

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(result.current.error).toEqual(mockError);
    expect(result.current.projects).toEqual([]);
  });
});

๐ŸŒ E2E Testing with Cypress

Configuration

// cypress.config.ts
import { defineConfig } from 'cypress';

export default defineConfig({
  e2e: {
    baseUrl: 'http://localhost:3000',
    supportFile: 'cypress/support/e2e.ts',
    specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}',
    video: true,
    screenshotOnRunFailure: true,
    viewportWidth: 1280,
    viewportHeight: 720,
  },
  component: {
    devServer: {
      framework: 'next',
      bundler: 'webpack',
    },
    supportFile: 'cypress/support/component.ts',
    specPattern: 'src/**/*.cy.{js,jsx,ts,tsx}',
  },
});
// cypress/e2e/navigation.cy.ts
describe('Navigation', () => {
  beforeEach(() => {
    cy.visit('/');
  });

  it('navigates to projects section', () => {
    cy.get('[data-testid="nav-projects"]').click();
    cy.url().should('include', '#projects');
    cy.get('h2').should('contain', 'Projects');
  });

  it('navigates to about section', () => {
    cy.get('[data-testid="nav-about"]').click();
    cy.url().should('include', '#about');
    cy.get('h2').should('contain', 'About');
  });

  it('mobile menu works correctly', () => {
    cy.viewport('iphone-x');
    cy.get('[data-testid="mobile-menu-button"]').click();
    cy.get('[data-testid="mobile-menu"]').should('be.visible');
    cy.get('[data-testid="mobile-menu-close"]').click();
    cy.get('[data-testid="mobile-menu"]').should('not.be.visible');
  });
});

Form Interaction Test

// cypress/e2e/contact.cy.ts
describe('Contact Form', () => {
  beforeEach(() => {
    cy.visit('/#contact');
  });

  it('submits contact form successfully', () => {
    cy.get('[data-testid="contact-name"]').type('John Doe');
    cy.get('[data-testid="contact-email"]').type('john@example.com');
    cy.get('[data-testid="contact-message"]').type('Hello, this is a test message!');
    
    cy.get('[data-testid="contact-submit"]').click();
    
    cy.get('[data-testid="success-message"]')
      .should('be.visible')
      .and('contain', 'Message sent successfully');
  });

  it('validates email format', () => {
    cy.get('[data-testid="contact-email"]').type('invalid-email');
    cy.get('[data-testid="contact-submit"]').click();
    
    cy.get('[data-testid="email-error"]')
      .should('be.visible')
      .and('contain', 'Invalid email');
  });

  it('requires all fields', () => {
    cy.get('[data-testid="contact-submit"]').click();
    
    cy.get('[data-testid="name-error"]').should('be.visible');
    cy.get('[data-testid="email-error"]').should('be.visible');
    cy.get('[data-testid="message-error"]').should('be.visible');
  });
});

Theme Toggle Test

// cypress/e2e/theme.cy.ts
describe('Theme Toggle', () => {
  beforeEach(() => {
    cy.visit('/');
  });

  it('toggles between light and dark mode', () => {
    cy.get('html').should('have.class', 'dark');
    
    cy.get('[data-testid="theme-toggle"]').click();
    cy.get('html').should('not.have.class', 'dark');
    
    cy.get('[data-testid="theme-toggle"]').click();
    cy.get('html').should('have.class', 'dark');
  });

  it('persists theme preference', () => {
    cy.get('[data-testid="theme-toggle"]').click();
    cy.reload();
    cy.get('html').should('not.have.class', 'dark');
  });
});

Performance Test

// cypress/e2e/performance.cy.ts
describe('Performance', () => {
  it('loads home page within acceptable time', () => {
    cy.visit('/', {
      onBeforeLoad: (win) => {
        win.performance.mark('start');
      },
      onLoad: (win) => {
        win.performance.mark('end');
        win.performance.measure('pageLoad', 'start', 'end');
      },
    });

    cy.window().then((win) => {
      const measure = win.performance.getEntriesByName('pageLoad')[0];
      expect(measure.duration).to.be.lessThan(3000); // 3 seconds
    });
  });

  it('has acceptable Lighthouse scores', () => {
    cy.visit('/');
    cy.lighthouse({
      performance: 90,
      accessibility: 90,
      'best-practices': 90,
      seo: 90,
    });
  });
});

๐ŸŽจ Component Testing

// src/features/projects/components/ProjectCard.cy.tsx
import { ProjectCard } from './ProjectCard';

describe('<ProjectCard />', () => {
  const mockProject = {
    id: '1',
    title: 'Test Project',
    description: 'Test description',
    technologies: ['React', 'TypeScript'],
  };

  it('mounts and displays content', () => {
    cy.mount(<ProjectCard project={mockProject} />);
    cy.contains('Test Project').should('be.visible');
    cy.contains('Test description').should('be.visible');
  });

  it('handles hover interactions', () => {
    cy.mount(<ProjectCard project={mockProject} />);
    cy.get('[data-testid="project-card"]').trigger('mouseover');
    cy.get('[data-testid="project-card"]').should('have.class', 'hover');
  });
});

๐Ÿ“Š Coverage Reports

Generating Coverage

# Generate coverage report
pnpm test:coverage

# View HTML report
open coverage/lcov-report/index.html

Coverage Thresholds

Maintained in jest.config.cjs:

coverageThresholds: {
  global: {
    branches: 70,
    functions: 70,
    lines: 70,
    statements: 70,
  },
}

๐Ÿ”„ Continuous Integration

GitHub Actions

# .github/workflows/test.yml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup Node
        uses: actions/setup-node@v3
        with:
          node-version: '20'
      
      - name: Install pnpm
        run: npm install -g pnpm
      
      - name: Install dependencies
        run: pnpm install
      
      - name: Run unit tests
        run: pnpm test:unit --coverage
      
      - name: Upload coverage
        uses: codecov/codecov-action@v3
        with:
          files: ./coverage/lcov.info
      
      - name: Run E2E tests
        run: pnpm test:e2e:ci
      
      - name: Upload Cypress videos
        uses: actions/upload-artifact@v3
        if: failure()
        with:
          name: cypress-videos
          path: cypress/videos

๐Ÿ› ๏ธ Best Practices

Test Organization

src/features/projects/
โ”œโ”€โ”€ components/
โ”‚   โ”œโ”€โ”€ __tests__/
โ”‚   โ”‚   โ”œโ”€โ”€ ProjectCard.test.tsx
โ”‚   โ”‚   โ””โ”€โ”€ ProjectsGrid.test.tsx
โ”‚   โ”œโ”€โ”€ ProjectCard.tsx
โ”‚   โ””โ”€โ”€ ProjectsGrid.tsx
โ””โ”€โ”€ hooks/
    โ”œโ”€โ”€ __tests__/
    โ”‚   โ””โ”€โ”€ useProjects.test.ts
    โ””โ”€โ”€ useProjects.ts

Writing Good Tests

  1. Follow AAA Pattern

    • Arrange: Set up test data
    • Act: Execute the code
    • Assert: Verify the result
  2. Use Descriptive Names

    it('displays error message when API call fails', () => {
      // test code
    });
  3. Test Behavior, Not Implementation

    // Good
    expect(screen.getByText('Submit')).toBeInTheDocument();
    
    // Bad
    expect(component.state.isSubmitting).toBe(false);
  4. Avoid Testing Third-Party Code

    • Don't test Next.js routing
    • Don't test React behavior
    • Focus on your application logic

๐Ÿ“š Learn More

On this page