TestingTesting Fundamentals

Testing Fundamentals

Understanding different types of testing is essential for building a comprehensive test strategy. This guide covers the fundamental testing types every developer should know.

Unit Testing

Unit testing focuses on testing individual units of code in isolation, typically functions, methods, or components.

Characteristics

  • Isolated: Tests one piece of functionality at a time
  • Fast: Execute in milliseconds
  • Deterministic: Same input always produces same output
  • Independent: No dependencies on external systems

Example: Testing a Utility Function

// src/utils/calculator.ts
export function add(a: number, b: number): number {
  return a + b
}
 
export function divide(a: number, b: number): number {
  if (b === 0) {
    throw new Error('Cannot divide by zero')
  }
  return a / b
}
// src/utils/calculator.test.ts
import { describe, it, expect } from 'vitest'
import { add, divide } from './calculator'
 
describe('Calculator Utils', () => {
  describe('add', () => {
    it('should add two positive numbers', () => {
      expect(add(2, 3)).toBe(5)
    })
 
    it('should handle negative numbers', () => {
      expect(add(-5, 3)).toBe(-2)
    })
 
    it('should handle zero', () => {
      expect(add(0, 5)).toBe(5)
    })
  })
 
  describe('divide', () => {
    it('should divide two numbers', () => {
      expect(divide(10, 2)).toBe(5)
    })
 
    it('should throw error when dividing by zero', () => {
      expect(() => divide(10, 0)).toThrow('Cannot divide by zero')
    })
  })
})

Unit tests should focus on the what (behavior) not the how (implementation). Test the public interface, not internal details.

When to Use Unit Tests

  • Testing business logic and algorithms
  • Testing utility functions and helpers
  • Testing pure functions (no side effects)
  • Testing component rendering logic
  • Testing state management reducers

Integration Testing

Integration testing verifies that different parts of your application work together correctly.

Characteristics

  • Multiple Units: Tests interactions between components
  • Moderate Speed: Slower than unit tests, faster than E2E
  • Real Dependencies: May use real databases, APIs, or file systems
  • Higher Confidence: Catches issues unit tests might miss

Example: Testing an API Route

// pages/api/users.ts
import type { NextApiRequest, NextApiResponse } from 'next'
import { db } from '@/lib/db'
 
export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  if (req.method === 'GET') {
    const users = await db.user.findMany()
    return res.status(200).json(users)
  }
 
  if (req.method === 'POST') {
    const { name, email } = req.body
    const user = await db.user.create({
      data: { name, email },
    })
    return res.status(201).json(user)
  }
 
  res.status(405).json({ message: 'Method not allowed' })
}
// pages/api/users.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import request from 'supertest'
import { setupTestDatabase, cleanupTestDatabase } from '@/test/helpers'
 
describe('Users API', () => {
  beforeEach(async () => {
    await setupTestDatabase()
  })
 
  afterEach(async () => {
    await cleanupTestDatabase()
  })
 
  it('should return all users', async () => {
    const response = await request('/api/users').get('/')
 
    expect(response.status).toBe(200)
    expect(response.body).toBeInstanceOf(Array)
  })
 
  it('should create a new user', async () => {
    const userData = {
      name: 'John Doe',
      email: 'john@example.com',
    }
 
    const response = await request('/api/users').post('/').send(userData)
 
    expect(response.status).toBe(201)
    expect(response.body).toMatchObject(userData)
  })
})

When to Use Integration Tests

  • Testing API endpoints
  • Testing database operations
  • Testing authentication flows
  • Testing service interactions
  • Testing component composition

End-to-End (E2E) Testing

E2E testing validates complete user workflows from start to finish, testing the entire application stack.

Characteristics

  • Full Stack: Tests UI, backend, database, and external services
  • Slow: Can take minutes per test
  • Real Environment: Runs against production-like setup
  • High Confidence: Tests real user scenarios

Example: Testing User Login Flow

// e2e/login.spec.ts
import { test, expect } from '@playwright/test'
 
test.describe('User Login', () => {
  test('should login successfully with valid credentials', async ({ page }) => {
    await page.goto('https://myapp.com/login')
 
    // Fill in login form
    await page.fill('input[name="email"]', 'user@example.com')
    await page.fill('input[name="password"]', 'password123')
 
    // Click login button
    await page.click('button[type="submit"]')
 
    // Verify redirect to dashboard
    await expect(page).toHaveURL('https://myapp.com/dashboard')
 
    // Verify user is logged in
    await expect(page.locator('text=Welcome back!')).toBeVisible()
  })
 
  test('should show error with invalid credentials', async ({ page }) => {
    await page.goto('https://myapp.com/login')
 
    await page.fill('input[name="email"]', 'wrong@example.com')
    await page.fill('input[name="password"]', 'wrongpassword')
    await page.click('button[type="submit"]')
 
    // Should stay on login page
    await expect(page).toHaveURL(/.*login/)
 
    // Should show error message
    await expect(page.locator('text=Invalid credentials')).toBeVisible()
  })
})
⚠️

E2E tests are expensive to write and maintain. Reserve them for critical user journeys and happy paths. Don’t test every edge case with E2E tests.

When to Use E2E Tests

  • Testing critical business workflows
  • Testing user authentication and authorization
  • Testing payment and checkout processes
  • Testing multi-step forms
  • Testing cross-browser compatibility

Functional Testing

Functional testing verifies that the software performs its intended functions according to requirements.

Key Aspects

  • Requirement-Based: Tests against functional specifications
  • User Perspective: Focuses on what users can do
  • Input/Output: Validates correct outputs for given inputs
  • Black Box: No knowledge of internal implementation required

Example

// Testing a search feature
test('search functionality', async () => {
  const searchInput = screen.getByRole('searchbox')
  const searchButton = screen.getByRole('button', { name: /search/i })
 
  // Enter search query
  await userEvent.type(searchInput, 'React testing')
  await userEvent.click(searchButton)
 
  // Verify search results appear
  expect(await screen.findByText('Search Results')).toBeInTheDocument()
  expect(screen.getAllByTestId('result-item').length).toBeGreaterThan(0)
})

Non-Functional Testing

Non-functional testing evaluates how the system performs rather than what it does.

Types of Non-Functional Testing

Performance Testing

Measures system speed, responsiveness, and stability.

import { performance } from 'perf_hooks'
 
test('data processing completes within 100ms', () => {
  const start = performance.now()
 
  processLargeDataset(mockData)
 
  const duration = performance.now() - start
  expect(duration).toBeLessThan(100)
})

Security Testing

Identifies vulnerabilities and ensures data protection.

test('should sanitize user input to prevent XSS', () => {
  const maliciousInput = '<script>alert("XSS")</script>'
  const sanitized = sanitizeInput(maliciousInput)
 
  expect(sanitized).not.toContain('<script>')
  expect(sanitized).toBe('alert("XSS")')
})

Usability Testing

Evaluates user experience and interface intuitiveness.

Reliability Testing

Ensures system operates without failure over time.

Regression Testing

Regression testing ensures that new changes don’t break existing functionality.

Purpose

  • Verify bug fixes don’t introduce new bugs
  • Ensure new features don’t break old features
  • Maintain software quality over time

Best Practices

// Regression test for a bug fix
test('should handle empty array without crashing (Bug #123)', () => {
  const emptyArray: number[] = []
 
  // This used to crash before the fix
  expect(() => processArray(emptyArray)).not.toThrow()
  expect(processArray(emptyArray)).toEqual([])
})

Automation Strategy

  • Add test for every bug fix
  • Run full regression suite before releases
  • Integrate with CI/CD pipeline
  • Prioritize tests for critical functionality

Every time you fix a bug, write a test that would have caught it. This prevents the same bug from reappearing.

Smoke Testing

Smoke testing is a preliminary test to check if the basic functionality works.

Characteristics

  • Quick: Runs in minutes
  • Surface-Level: Tests critical paths only
  • Build Verification: Confirms build is stable enough for testing
  • Go/No-Go Decision: Determines if deeper testing should proceed

Example

// smoke.test.ts
describe('Smoke Tests', () => {
  test('application starts without errors', async () => {
    const app = await startApplication()
    expect(app.isRunning()).toBe(true)
  })
 
  test('homepage loads successfully', async () => {
    const response = await fetch('http://localhost:3000')
    expect(response.status).toBe(200)
  })
 
  test('database connection works', async () => {
    const isConnected = await db.ping()
    expect(isConnected).toBe(true)
  })
 
  test('critical API endpoint responds', async () => {
    const response = await fetch('http://localhost:3000/api/health')
    expect(response.status).toBe(200)
  })
})

When to Run Smoke Tests

  • After deploying to a new environment
  • Before running full test suite
  • After major build or configuration change
  • As first step in CI/CD pipeline

Sanity Testing

Sanity testing is a narrow, focused test to verify specific functionality after changes.

Characteristics

  • Targeted: Tests specific area of change
  • Quick: Faster than full regression
  • Rational Check: Verifies changes make sense
  • Subset of Regression: Not comprehensive

Example

// After adding a new payment method
describe('Sanity Tests - New Payment Method', () => {
  test('PayPal option appears in payment methods', () => {
    render(<PaymentForm />)
 
    const paypalOption = screen.getByLabelText('PayPal')
    expect(paypalOption).toBeInTheDocument()
  })
 
  test('PayPal payment can be processed', async () => {
    const result = await processPayment({
      method: 'paypal',
      amount: 100,
      email: 'user@example.com',
    })
 
    expect(result.success).toBe(true)
  })
})

Comparison Table

TypeScopeSpeedConfidenceWhen to Use
UnitSingleFastLowIndividual functions/logic
IntegrationMultipleMediumMediumComponent interactions
E2EFull StackSlowHighCritical user flows
FunctionalFeatureVariesMediumFeature verification
RegressionAllSlowHighAfter changes
SmokeCriticalVery FastLowQuick health check
SanitySpecificFastLowVerify specific functionality

Testing Strategy Recommendations

For New Projects

  1. Start with unit tests for business logic
  2. Add integration tests for critical paths
  3. Implement E2E tests for core workflows
  4. Set up smoke tests for deployments

For Existing Projects

  1. Add tests when fixing bugs (regression)
  2. Test new features as they’re added
  3. Gradually increase coverage of critical paths
  4. Refactor tests as code evolves

General Guidelines

  • 70% unit tests
  • 20% integration tests
  • 10% E2E tests
  • Run smoke tests on every deployment
  • Run full regression suite before releases
⚠️

Don’t aim for 100% coverage just for the sake of it. Focus on testing the right things – critical paths, complex logic, and edge cases.

Next Steps