JavaScript Testing Fundamentals - Complete Guide

Testing is essential for building reliable software. This guide covers testing fundamentals, popular frameworks, and best practices for JavaScript testing.

Types of Tests #

Unit Tests #

Test individual functions or components in isolation.

// sum.js
function sum(a, b) {
  return a + b;
}

// sum.test.js
test('adds 1 + 2 to equal 3', () => {
  expect(sum(1, 2)).toBe(3);
});

Integration Tests #

Test how multiple units work together.

// userService.js
class UserService {
  constructor(database) {
    this.db = database;
  }

  async createUser(name, email) {
    const user = { name, email };
    await this.db.insert('users', user);
    return user;
  }
}

// userService.test.js
test('creates user in database', async () => {
  const mockDb = {
    insert: jest.fn()
  };

  const service = new UserService(mockDb);
  const user = await service.createUser('Alice', 'alice@example.com');

  expect(mockDb.insert).toHaveBeenCalledWith('users', user);
});

End-to-End Tests #

Test complete user workflows.

// e2e/login.test.js
test('user can login', async () => {
  await page.goto('http://localhost:3000/login');
  await page.fill('input[name="email"]', 'user@example.com');
  await page.fill('input[name="password"]', 'password');
  await page.click('button[type="submit"]');
  await expect(page).toHaveURL('http://localhost:3000/dashboard');
});

Jest Basics #

Setup #

npm install --save-dev jest
// package.json
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  }
}

Basic Test Structure #

describe('Calculator', () => {
  test('adds numbers', () => {
    expect(sum(1, 2)).toBe(3);
  });

  test('subtracts numbers', () => {
    expect(subtract(5, 2)).toBe(3);
  });

  test('multiplies numbers', () => {
    expect(multiply(2, 3)).toBe(6);
  });
});

Matchers #

// Equality
expect(value).toBe(4);
expect(value).toEqual({ a: 1, b: 2 });
expect(value).not.toBe(null);

// Truthiness
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();

// Numbers
expect(value).toBeGreaterThan(3);
expect(value).toBeGreaterThanOrEqual(3.5);
expect(value).toBeLessThan(5);
expect(value).toBeLessThanOrEqual(4.5);
expect(value).toBeCloseTo(0.3); // Floating point

// Strings
expect('team').not.toMatch(/I/);
expect('Christoph').toMatch(/stop/);

// Arrays
expect(['apple', 'banana']).toContain('apple');
expect([1, 2, 3]).toHaveLength(3);

// Objects
expect({ a: 1, b: 2 }).toHaveProperty('a');
expect({ a: 1, b: 2 }).toMatchObject({ a: 1 });

// Exceptions
expect(() => {
  throw new Error('Error');
}).toThrow();
expect(() => {
  throw new Error('Network error');
}).toThrow('Network error');

Async Testing #

Promises #

test('fetches user data', () => {
  return fetchUser(1).then(user => {
    expect(user.name).toBe('Alice');
  });
});

// Or use async/await
test('fetches user data', async () => {
  const user = await fetchUser(1);
  expect(user.name).toBe('Alice');
});

Async Matchers #

test('resolves to user', async () => {
  await expect(fetchUser(1)).resolves.toEqual({ id: 1, name: 'Alice' });
});

test('rejects with error', async () => {
  await expect(fetchUser(-1)).rejects.toThrow('Invalid user ID');
});

Callbacks #

test('callback is called', done => {
  function callback(data) {
    expect(data).toBe('success');
    done();
  }

  fetchData(callback);
});

Mocking #

Mock Functions #

const mockFn = jest.fn();

// Call mock
mockFn('arg1', 'arg2');
mockFn('arg3');

// Assertions
expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2');
expect(mockFn).toHaveBeenLastCalledWith('arg3');

// Return values
mockFn.mockReturnValue(42);
expect(mockFn()).toBe(42);

mockFn.mockReturnValueOnce(1).mockReturnValueOnce(2);
expect(mockFn()).toBe(1);
expect(mockFn()).toBe(2);

// Implementation
mockFn.mockImplementation((a, b) => a + b);
expect(mockFn(1, 2)).toBe(3);

Mock Modules #

// api.js
export async function fetchUser(id) {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
}

// user.test.js
jest.mock('./api');
import { fetchUser } from './api';

test('gets user', async () => {
  fetchUser.mockResolvedValue({ id: 1, name: 'Alice' });

  const user = await getUser(1);
  expect(user.name).toBe('Alice');
});

Partial Mocks #

import * as api from './api';

jest.spyOn(api, 'fetchUser').mockResolvedValue({ id: 1, name: 'Alice' });

// Only fetchUser is mocked, other functions work normally

Mock Timers #

jest.useFakeTimers();

test('delays execution', () => {
  const callback = jest.fn();

  setTimeout(callback, 1000);

  expect(callback).not.toHaveBeenCalled();

  jest.advanceTimersByTime(1000);

  expect(callback).toHaveBeenCalled();
});

test('runs all timers', () => {
  const callback = jest.fn();

  setTimeout(callback, 1000);
  setInterval(callback, 500);

  jest.runAllTimers();

  expect(callback).toHaveBeenCalled();
});

Setup and Teardown #

Before/After Hooks #

beforeAll(() => {
  // Runs once before all tests
  return initializeDatabase();
});

beforeEach(() => {
  // Runs before each test
  clearDatabase();
});

afterEach(() => {
  // Runs after each test
  cleanupResources();
});

afterAll(() => {
  // Runs once after all tests
  closeDatabase();
});

Scoped Hooks #

describe('User operations', () => {
  beforeEach(() => {
    // Runs before each test in this describe block
  });

  test('creates user', () => {
    // ...
  });

  describe('nested', () => {
    beforeEach(() => {
      // Runs after parent beforeEach
    });

    test('updates user', () => {
      // ...
    });
  });
});

Testing Patterns #

AAA Pattern #

Arrange, Act, Assert.

test('user service creates user', async () => {
  // Arrange
  const mockDb = { insert: jest.fn() };
  const service = new UserService(mockDb);

  // Act
  const user = await service.createUser('Alice', 'alice@example.com');

  // Assert
  expect(user).toEqual({ name: 'Alice', email: 'alice@example.com' });
  expect(mockDb.insert).toHaveBeenCalledWith('users', user);
});

Test Data Builders #

class UserBuilder {
  constructor() {
    this.user = {
      id: 1,
      name: 'Test User',
      email: 'test@example.com',
      role: 'user'
    };
  }

  withId(id) {
    this.user.id = id;
    return this;
  }

  withName(name) {
    this.user.name = name;
    return this;
  }

  asAdmin() {
    this.user.role = 'admin';
    return this;
  }

  build() {
    return this.user;
  }
}

test('admin user has permissions', () => {
  const admin = new UserBuilder()
    .withName('Admin')
    .asAdmin()
    .build();

  expect(hasPermission(admin, 'delete')).toBe(true);
});

Test Fixtures #

// fixtures/users.js
export const users = [
  { id: 1, name: 'Alice', email: 'alice@example.com' },
  { id: 2, name: 'Bob', email: 'bob@example.com' }
];

// test file
import { users } from './fixtures/users';

test('finds user by id', () => {
  const user = findUserById(users, 1);
  expect(user.name).toBe('Alice');
});

Testing React Components #

React Testing Library #

import { render, screen, fireEvent } from '@testing-library/react';
import Counter from './Counter';

test('increments counter', () => {
  render(<Counter />);

  const button = screen.getByText('Increment');
  const count = screen.getByText('Count: 0');

  fireEvent.click(button);

  expect(screen.getByText('Count: 1')).toBeInTheDocument();
});

Testing User Events #

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

test('submits form', async () => {
  const handleSubmit = jest.fn();
  render(<LoginForm onSubmit={handleSubmit} />);

  await userEvent.type(screen.getByLabelText('Email'), 'user@example.com');
  await userEvent.type(screen.getByLabelText('Password'), 'password');
  await userEvent.click(screen.getByText('Login'));

  expect(handleSubmit).toHaveBeenCalledWith({
    email: 'user@example.com',
    password: 'password'
  });
});

Testing Async Components #

import { render, screen, waitFor } from '@testing-library/react';

test('loads and displays user', async () => {
  render(<UserProfile userId={1} />);

  expect(screen.getByText('Loading...')).toBeInTheDocument();

  await waitFor(() => {
    expect(screen.getByText('Alice')).toBeInTheDocument();
  });
});

Snapshot Testing #

import { render } from '@testing-library/react';

test('matches snapshot', () => {
  const { container } = render(<Button>Click me</Button>);
  expect(container.firstChild).toMatchSnapshot();
});

// Update snapshots with: jest --updateSnapshot

Coverage #

Configuration #

// jest.config.js
module.exports = {
  collectCoverage: true,
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80
    }
  },
  coveragePathIgnorePatterns: [
    '/node_modules/',
    '/tests/'
  ]
};

Running Coverage #

npm test -- --coverage

Test Organization #

File Structure #

src/
  components/
    Button.js
    Button.test.js
  utils/
    validation.js
    validation.test.js
tests/
  integration/
    api.test.js
  e2e/
    user-flow.test.js

Naming Conventions #

// Describe what is being tested
describe('UserService', () => {
  // Use "should" or describe expected behavior
  test('should create user with valid data', () => {
    // ...
  });

  test('should throw error with invalid email', () => {
    // ...
  });
});

Testing Best Practices #

Keep Tests Simple #

// Bad - Testing multiple things
test('user operations', async () => {
  const user = await createUser({ name: 'Alice' });
  expect(user.name).toBe('Alice');

  const updated = await updateUser(user.id, { name: 'Bob' });
  expect(updated.name).toBe('Bob');

  await deleteUser(user.id);
  const deleted = await findUser(user.id);
  expect(deleted).toBeNull();
});

// Good - One test per behavior
test('creates user with name', async () => {
  const user = await createUser({ name: 'Alice' });
  expect(user.name).toBe('Alice');
});

test('updates user name', async () => {
  const user = await createUser({ name: 'Alice' });
  const updated = await updateUser(user.id, { name: 'Bob' });
  expect(updated.name).toBe('Bob');
});

Don’t Test Implementation Details #

// Bad - Testing internal state
test('counter increments state', () => {
  const counter = new Counter();
  counter.increment();
  expect(counter._value).toBe(1);  // Private implementation
});

// Good - Testing public interface
test('counter increments value', () => {
  const counter = new Counter();
  counter.increment();
  expect(counter.getValue()).toBe(1);
});

Use Descriptive Test Names #

// Bad
test('test1', () => { });

// Good
test('returns empty array when no users exist', () => { });
test('throws error when email is invalid', () => { });
test('updates user profile successfully', () => { });

Test-Driven Development (TDD) #

Red-Green-Refactor #

// 1. Red - Write failing test
test('validates email format', () => {
  expect(isValidEmail('invalid')).toBe(false);
  expect(isValidEmail('test@example.com')).toBe(true);
});

// 2. Green - Write minimal code to pass
function isValidEmail(email) {
  return email.includes('@');
}

// 3. Refactor - Improve implementation
function isValidEmail(email) {
  const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return regex.test(email);
}

Common Testing Pitfalls #

  1. Testing trivial code - Don’t test getters/setters
  2. Flaky tests - Tests that pass/fail randomly
  3. Slow tests - Avoid unnecessary setup
  4. Too many mocks - Over-mocking hides issues
  5. Brittle tests - Tests break with minor changes
  6. No edge cases - Test boundary conditions
  7. Ignoring failures - Don’t skip broken tests

Best Practices Summary #

  1. Write tests first - TDD approach
  2. Test behavior, not implementation - Focus on what, not how
  3. Keep tests independent - No shared state
  4. Use descriptive names - Tests are documentation
  5. Test edge cases - Null, empty, negative values
  6. Mock external dependencies - Database, API calls
  7. Run tests frequently - Continuous integration
  8. Maintain test code - Refactor tests like production code
  9. Measure coverage - Aim for high coverage
  10. Fast feedback - Keep tests fast

Testing is crucial for software quality. Write tests, run them often, and use them to drive design decisions.