Testing Guide
Comprehensive testing strategies and practices for ensuring portfolio quality and reliability.
๐งช Testing Stack
| Tool | Purpose | Use Cases |
|---|---|---|
| Jest | Unit testing | Components, utilities, services |
| Cypress | E2E testing | User flows, integration tests |
| Testing Library | Component testing | React component behavior |
| ESLint | Static analysis | Code quality, best practices |
| TypeScript | Type checking | Compile-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}',
},
});Navigation Test
// 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.htmlCoverage 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.tsWriting Good Tests
-
Follow AAA Pattern
- Arrange: Set up test data
- Act: Execute the code
- Assert: Verify the result
-
Use Descriptive Names
it('displays error message when API call fails', () => { // test code }); -
Test Behavior, Not Implementation
// Good expect(screen.getByText('Submit')).toBeInTheDocument(); // Bad expect(component.state.isSubmitting).toBe(false); -
Avoid Testing Third-Party Code
- Don't test Next.js routing
- Don't test React behavior
- Focus on your application logic