JavaScript — Testing & Debugging
Testing and debugging are fundamental to writing reliable JavaScript applications. Testing provides a safety net for refactoring, documents expected behavior, and catches regressions before they reach production. Debugging is the skill of systematically identifying and resolving issues when they arise. Together, they form the quality assurance foundation of professional JavaScript development.
The JavaScript testing ecosystem has matured significantly. Modern testing frameworks (Vitest, Jest, Playwright) provide fast, reliable test runners with excellent developer experience. The testing pyramid — unit tests at the base, integration tests in the middle, end-to-end tests at the top — guides investment across different test types. Each level provides different confidence at different costs.
Debugging has also evolved beyond console.log. Browser DevTools offer sophisticated debuggers, performance profilers, memory inspectors, and network analyzers. Node.js provides built-in debugging via the --inspect flag. The key is knowing which tool to use for which class of problem.
| 1 | // The testing mindset — write tests that fail before they pass |
| 2 | // Red-Green-Refactor cycle: |
| 3 | // 1. RED: Write a failing test that describes the desired behavior |
| 4 | // 2. GREEN: Write the minimum code to make it pass |
| 5 | // 3. REFACTOR: Improve the code while keeping tests green |
| 6 | |
| 7 | // Example: TDD approach |
| 8 | import { describe, it, expect } from 'vitest'; |
| 9 | |
| 10 | // RED phase — test defines the API first |
| 11 | describe('add()', () => { |
| 12 | it('should add two positive numbers', () => { |
| 13 | const result = add(2, 3); |
| 14 | expect(result).toBe(5); |
| 15 | }); |
| 16 | |
| 17 | it('should handle negative numbers', () => { |
| 18 | expect(add(-1, -2)).toBe(-3); |
| 19 | }); |
| 20 | |
| 21 | it('should throw on non-number input', () => { |
| 22 | expect(() => add('a', 1)).toThrow('Invalid input'); |
| 23 | }); |
| 24 | }); |
| 25 | |
| 26 | // GREEN phase — implement to pass tests |
| 27 | function add(a, b) { |
| 28 | if (typeof a !== 'number' || typeof b !== 'number') { |
| 29 | throw new Error('Invalid input'); |
| 30 | } |
| 31 | return a + b; |
| 32 | } |
Unit tests verify the smallest testable parts of an application in isolation — individual functions, methods, or modules. They are fast, deterministic, and provide precise failure localization. A good unit test covers the happy path, edge cases, error conditions, and boundary values. Mocking isolates the unit under test from its dependencies.
| Framework | Runtime | Features |
|---|---|---|
| Vitest | Node/Vite | Fast, Jest-compatible API, ESM native, TypeScript built-in |
| Jest | Node | Mature, large ecosystem, built-in mocking, snapshot testing |
| Mocha | Node | Flexible, minimal, requires assertion library (Chai, expect) |
| Node:test | Node 20+ | Built-in test runner (assert, mock, describe/it) |
| 1 | // Vitest — modern unit testing (Jest-compatible) |
| 2 | import { describe, it, expect, vi, beforeEach } from 'vitest'; |
| 3 | |
| 4 | import { fetchUserData, processOrder, calculateDiscount } from './services'; |
| 5 | |
| 6 | // Mocking external dependencies |
| 7 | vi.mock('./api', () => ({ |
| 8 | get: vi.fn(), |
| 9 | post: vi.fn(), |
| 10 | })); |
| 11 | |
| 12 | import { api } from './api'; |
| 13 | |
| 14 | beforeEach(() => { |
| 15 | vi.clearAllMocks(); |
| 16 | }); |
| 17 | |
| 18 | describe('fetchUserData', () => { |
| 19 | it('should return formatted user data on success', async () => { |
| 20 | vi.mocked(api.get).mockResolvedValue({ |
| 21 | data: { id: 1, name: 'Alice', email: 'alice@example.com' }, |
| 22 | }); |
| 23 | |
| 24 | const result = await fetchUserData(1); |
| 25 | |
| 26 | expect(result).toEqual({ |
| 27 | id: 1, |
| 28 | name: 'Alice', |
| 29 | email: 'alice@example.com', |
| 30 | }); |
| 31 | expect(api.get).toHaveBeenCalledWith('/users/1'); |
| 32 | }); |
| 33 | |
| 34 | it('should throw when user is not found', async () => { |
| 35 | vi.mocked(api.get).mockRejectedValue(new Error('Not found')); |
| 36 | |
| 37 | await expect(fetchUserData(999)).rejects.toThrow('User not found'); |
| 38 | }); |
| 39 | }); |
| 40 | |
| 41 | describe('calculateDiscount', () => { |
| 42 | it('should apply 10% discount for orders over $100', () => { |
| 43 | expect(calculateDiscount(150)).toBe(135); |
| 44 | }); |
| 45 | |
| 46 | it('should apply 20% discount for VIP customers', () => { |
| 47 | expect(calculateDiscount(50, { isVip: true })).toBe(40); |
| 48 | }); |
| 49 | |
| 50 | it('should not apply discount for orders under $20', () => { |
| 51 | expect(calculateDiscount(15)).toBe(15); |
| 52 | }); |
| 53 | |
| 54 | it('should throw on negative amounts', () => { |
| 55 | expect(() => calculateDiscount(-10)).toThrow('Invalid amount'); |
| 56 | }); |
| 57 | }); |
| 58 | |
| 59 | // Snapshot testing — verify output hasn't changed |
| 60 | it('should match the order summary snapshot', () => { |
| 61 | const order = processOrder({ items: ['apple', 'banana'], total: 25 }); |
| 62 | expect(order).toMatchSnapshot(); |
| 63 | }); |
| 64 | |
| 65 | // Testing async operations with timers |
| 66 | vi.useFakeTimers(); |
| 67 | |
| 68 | it('should debounce the search call', () => { |
| 69 | const searchSpy = vi.fn(); |
| 70 | const debouncedSearch = vi.fn(debounce(searchSpy, 300)); |
| 71 | |
| 72 | debouncedSearch('a'); |
| 73 | debouncedSearch('ap'); |
| 74 | debouncedSearch('app'); |
| 75 | |
| 76 | vi.advanceTimersByTime(300); |
| 77 | |
| 78 | expect(searchSpy).toHaveBeenCalledTimes(1); |
| 79 | expect(searchSpy).toHaveBeenCalledWith('app'); |
| 80 | }); |
info
Integration tests verify that different parts of the application work together correctly. Unlike unit tests, integration tests exercise real interactions between modules, databases, APIs, and external services. They catch issues that unit tests miss — incorrect API contracts, serialization mismatches, authentication flows, and database query errors.
| 1 | // API integration testing with supertest |
| 2 | import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 3 | import request from 'supertest'; |
| 4 | import { createApp } from './app'; |
| 5 | import { db } from './database'; |
| 6 | |
| 7 | const app = createApp(); |
| 8 | |
| 9 | beforeAll(async () => { |
| 10 | await db.migrate.latest(); |
| 11 | await db.seed.run(); |
| 12 | }); |
| 13 | |
| 14 | afterAll(async () => { |
| 15 | await db.destroy(); |
| 16 | }); |
| 17 | |
| 18 | describe('POST /api/users', () => { |
| 19 | it('should create a new user', async () => { |
| 20 | const res = await request(app) |
| 21 | .post('/api/users') |
| 22 | .send({ name: 'Alice', email: 'alice@test.com' }) |
| 23 | .expect(201); |
| 24 | |
| 25 | expect(res.body).toMatchObject({ |
| 26 | name: 'Alice', |
| 27 | email: 'alice@test.com', |
| 28 | }); |
| 29 | expect(res.body).toHaveProperty('id'); |
| 30 | expect(res.body).toHaveProperty('createdAt'); |
| 31 | }); |
| 32 | |
| 33 | it('should reject duplicate emails', async () => { |
| 34 | await request(app) |
| 35 | .post('/api/users') |
| 36 | .send({ name: 'Alice', email: 'alice@test.com' }) |
| 37 | .expect(409); |
| 38 | }); |
| 39 | |
| 40 | it('should validate required fields', async () => { |
| 41 | const res = await request(app) |
| 42 | .post('/api/users') |
| 43 | .send({ name: 'Alice' }) |
| 44 | .expect(400); |
| 45 | |
| 46 | expect(res.body.errors).toContainEqual( |
| 47 | expect.objectContaining({ field: 'email' }) |
| 48 | ); |
| 49 | }); |
| 50 | }); |
| 51 | |
| 52 | // Database integration testing |
| 53 | describe('UserRepository', () => { |
| 54 | it('should persist and retrieve user data', async () => { |
| 55 | const user = await UserRepository.create({ |
| 56 | name: 'Bob', |
| 57 | email: 'bob@test.com', |
| 58 | }); |
| 59 | |
| 60 | const found = await UserRepository.findById(user.id); |
| 61 | expect(found).toEqual(user); |
| 62 | }); |
| 63 | |
| 64 | it('should handle concurrent updates with versioning', async () => { |
| 65 | const user = await UserRepository.create({ name: 'Charlie' }); |
| 66 | |
| 67 | const [update1, update2] = await Promise.all([ |
| 68 | UserRepository.updateName(user.id, 'Charles'), |
| 69 | UserRepository.updateName(user.id, 'Chuck'), |
| 70 | ]); |
| 71 | |
| 72 | // One update succeeds, the other fails with version conflict |
| 73 | expect(update1).not.toEqual(update2); |
| 74 | }); |
| 75 | }); |
| 76 | |
| 77 | // Component integration testing with React Testing Library |
| 78 | import { render, screen, waitFor } from '@testing-library/react'; |
| 79 | import userEvent from '@testing-library/user-event'; |
| 80 | |
| 81 | it('should submit the form and show success message', async () => { |
| 82 | render(<ContactForm />); |
| 83 | |
| 84 | await userEvent.type(screen.getByLabelText('Name'), 'Alice'); |
| 85 | await userEvent.type(screen.getByLabelText('Email'), 'alice@test.com'); |
| 86 | await userEvent.click(screen.getByRole('button', { name: 'Submit' })); |
| 87 | |
| 88 | await waitFor(() => { |
| 89 | expect(screen.getByText('Message sent!')).toBeInTheDocument(); |
| 90 | }); |
| 91 | }); |
best practice
Choosing the right testing framework depends on your project type, performance requirements, and team preferences. Vitest has emerged as the leading choice for Vite-based projects, offering near-instant HMR-powered test execution. Playwright dominates end-to-end testing with cross-browser support. The landscape continues to evolve toward faster, simpler tooling.
| Category | Framework | Best For |
|---|---|---|
| Unit | Vitest | Fast unit/integration tests with Vite ecosystem |
| Unit | Jest | Large codebases, established CI pipelines |
| Component | Testing Library | User-centric React/Vue/Angular component tests |
| E2E | Playwright | Cross-browser E2E, mobile emulation, network interception |
| E2E | Cypress | Developer-friendly E2E with time-travel debugging |
| API | SuperTest | HTTP integration testing without running a server |
| Coverage | c8 / Istanbul | Code coverage reports (line, branch, function, statement) |
Debugging is the systematic process of identifying the root cause of unexpected behavior. The most effective debugging follows the scientific method: form a hypothesis, predict an outcome, test it with an experiment, and refine based on results. Modern browser DevTools provide powerful debugging capabilities beyond console.log — conditional breakpoints, logpoints, watch expressions, and call stack inspection.
| 1 | // Console-based debugging — beyond console.log |
| 2 | console.log('Simple message'); |
| 3 | console.error('Error:', err); |
| 4 | console.warn('Warning:', warning); |
| 5 | console.info('Info message'); |
| 6 | console.debug('Debug detail:', detail); |
| 7 | |
| 8 | // Object inspection |
| 9 | console.table(usersArray); // Table format for arrays |
| 10 | console.dir(element); // DOM element properties |
| 11 | console.dirxml(document.body); // HTML/XML representation |
| 12 | |
| 13 | // Timing and profiling |
| 14 | console.time('operation'); |
| 15 | heavyTask(); |
| 16 | console.timeEnd('operation'); // "operation: 145.2ms" |
| 17 | |
| 18 | console.timeLog('operation', 'step 1 complete'); |
| 19 | // Logs time elapsed since the last time/timeLog call |
| 20 | |
| 21 | // Stack traces |
| 22 | console.trace('Trace point'); |
| 23 | // Prints a stack trace to the console |
| 24 | |
| 25 | // Grouping |
| 26 | console.group('User Details'); |
| 27 | console.log('Name:', name); |
| 28 | console.log('Age:', age); |
| 29 | console.groupEnd(); |
| 30 | |
| 31 | // Conditional breakpoints — in DevTools, set breakpoints with conditions: |
| 32 | // e.g., count > 5 && type === 'error' |
| 33 | |
| 34 | // Logpoints — DevTools feature: log to console without pausing |
| 35 | // Right-click line number → "Add logpoint" → type the expression |
| 36 | |
| 37 | // Debugger statement — programmatic breakpoint |
| 38 | function suspiciousFunction(value) { |
| 39 | debugger; // execution pauses here if DevTools is open |
| 40 | return value * 2; |
| 41 | } |
| 42 | |
| 43 | // Source maps — debug compiled code in original form |
| 44 | // Enable in build config: |
| 45 | // webpack: devtool: 'source-map' |
| 46 | // vite: build.sourcemap: true |
| 47 | // TypeScript: sourceMap: true |
| 48 | |
| 49 | // Async debugging — async call stacks |
| 50 | // In Chrome DevTools, enable "Async" in Sources panel |
| 51 | // Shows the full call chain across async boundaries |
| 52 | |
| 53 | // Network debugging |
| 54 | // Use console.assert for conditional logging |
| 55 | console.assert(user.age > 0, 'Invalid age:', user); |
| 56 | // Only logs if the assertion fails |
pro tip
Browser DevTools are the most important tool in a JavaScript developer's arsenal. Chrome DevTools, Firefox Developer Tools, and Safari Web Inspector each provide a comprehensive suite for debugging, profiling, and inspecting web applications. Mastering these tools separates novice developers from experienced professionals.
| Panel | Purpose | Key Feature |
|---|---|---|
| Elements | Inspect and modify DOM/CSS | Live CSS editing, box model visualization, accessibility tree |
| Console | JavaScript REPL and logging | $0 for selected element, console.table, live expressions |
| Sources | Full-featured debugger | Breakpoints, step-through, call stack, scope variables |
| Network | Network request inspection | Waterfall timing, request/response headers, blocking, throttling |
| Performance | Runtime performance profiling | Flame charts, FPS meter, long task detection |
| Memory | Heap inspection and profiling | Heap snapshots, allocation timeline, detached DOM tree finder |
| Application | Storage, Service Workers, Caches | IndexedDB inspector, cache storage, manifest viewer |
| Lighthouse | Audit and score | Performance, accessibility, SEO, best practices scoring |
| 1 | // Console utilities in DevTools |
| 2 | // $0 — last selected DOM element in Elements panel |
| 3 | $0; // the element you last inspected |
| 4 | $0.style.backgroundColor = '#00FF41'; // modify it live |
| 5 | |
| 6 | // $_ — last evaluated expression result |
| 7 | 2 + 2; // 4 |
| 8 | $_ + 3; // 7 (uses previous result) |
| 9 | |
| 10 | // $() — document.querySelector shorthand |
| 11 | $('button.submit'); // first matching element |
| 12 | $$('li.item'); // all matching elements (querySelectorAll) |
| 13 | |
| 14 | // $x() — XPath queries |
| 15 | $x('//div[@class="main"]'); |
| 16 | |
| 17 | // monitor() / unmonitor() — log function calls |
| 18 | monitor(fetch); |
| 19 | // Every call to fetch() will log to console |
| 20 | |
| 21 | // copy() — copy to clipboard |
| 22 | copy(JSON.stringify(data, null, 2)); |
| 23 | |
| 24 | // Performance marks in DevTools |
| 25 | performance.mark('render-start'); |
| 26 | // Record in Performance panel timeline — visible in waterfall |
| 27 | |
| 28 | // Live Expressions — pin expressions to console header |
| 29 | // Click the eye icon in Console, type: document.activeElement |
| 30 | // Updates in real-time as you interact with the page |
| 31 | |
| 32 | // Coverage tab — find unused JavaScript/CSS |
| 33 | // Coverage tab → Record → interact with the page |
| 34 | // Red = unused code, Green = used code |
| 35 | // Helps identify dead code to remove |
| 36 | |
| 37 | // Rendering tab — visual debugging |
| 38 | // Show paint rectangles (flashing green = repaint regions) |
| 39 | // FPS meter (real-time frame rate) |
| 40 | // Layout shift regions (debug CLS issues) |
| 41 | |
| 42 | // Breakpoint types in Sources panel: |
| 43 | // Line breakpoint — pause at a specific line |
| 44 | // Conditional breakpoint — pause when expression is true |
| 45 | // DOM breakpoint — pause on subtree modification, attribute change, node removal |
| 46 | // XHR/fetch breakpoint — pause when URL contains a string |
| 47 | // Event listener breakpoint — pause on specific event types |
best practice
Certain testing patterns emerge repeatedly across JavaScript projects. These patterns solve common problems: testing asynchronous code, handling time-dependent logic, mocking network requests, and organizing tests for maintainability. Mastering these patterns makes test suites more reliable and easier to maintain.
| 1 | // Pattern 1: Factory functions for test data |
| 2 | function buildUser(overrides = {}) { |
| 3 | return { |
| 4 | id: crypto.randomUUID(), |
| 5 | name: 'Default User', |
| 6 | email: 'user@example.com', |
| 7 | role: 'user', |
| 8 | createdAt: new Date('2025-01-01'), |
| 9 | ...overrides, |
| 10 | }; |
| 11 | } |
| 12 | |
| 13 | it('should allow admin to delete users', () => { |
| 14 | const admin = buildUser({ role: 'admin' }); |
| 15 | const regular = buildUser({ role: 'user' }); |
| 16 | // Test with controlled, predictable data |
| 17 | }); |
| 18 | |
| 19 | // Pattern 2: Testing async error handling |
| 20 | it('should handle API errors gracefully', async () => { |
| 21 | vi.mocked(api.fetch).mockRejectedValue( |
| 22 | new HTTPError(500, 'Internal Server Error') |
| 23 | ); |
| 24 | |
| 25 | const result = await safeFetch('/data'); |
| 26 | |
| 27 | expect(result).toEqual({ |
| 28 | error: true, |
| 29 | message: 'Server error. Please try again later.', |
| 30 | retryAfter: 5000, |
| 31 | }); |
| 32 | }); |
| 33 | |
| 34 | // Pattern 3: Time-dependent logic (useFakeTimers) |
| 35 | vi.useFakeTimers(); |
| 36 | |
| 37 | it('should retry after configured delay', async () => { |
| 38 | const retrySpy = vi.fn(); |
| 39 | const sut = createRetryHandler(retrySpy, { delay: 1000 }); |
| 40 | |
| 41 | sut.execute(); |
| 42 | expect(retrySpy).toHaveBeenCalledTimes(1); |
| 43 | |
| 44 | vi.advanceTimersByTime(999); |
| 45 | expect(retrySpy).toHaveBeenCalledTimes(1); // not yet |
| 46 | |
| 47 | vi.advanceTimersByTime(1); |
| 48 | expect(retrySpy).toHaveBeenCalledTimes(2); // retried |
| 49 | }); |
| 50 | |
| 51 | // Pattern 4: Testing with localStorage |
| 52 | beforeEach(() => { |
| 53 | localStorage.clear(); |
| 54 | }); |
| 55 | |
| 56 | it('should persist user preferences', () => { |
| 57 | const prefs = { theme: 'dark', fontSize: 14 }; |
| 58 | savePreferences(prefs); |
| 59 | |
| 60 | const loaded = loadPreferences(); |
| 61 | expect(loaded).toEqual(prefs); |
| 62 | }); |
| 63 | |
| 64 | // Pattern 5: Testing random values (seed-based) |
| 65 | it('should generate unique IDs', () => { |
| 66 | const ids = new Set(); |
| 67 | for (let i = 0; i < 1000; i++) { |
| 68 | ids.add(generateId()); |
| 69 | } |
| 70 | expect(ids.size).toBe(1000); |
| 71 | }); |
| 72 | |
| 73 | // Pattern 6: Parametrized tests |
| 74 | it.each([ |
| 75 | { input: 0, expected: false }, |
| 76 | { input: 1, expected: false }, |
| 77 | { input: 2, expected: true }, |
| 78 | { input: 3, expected: true }, |
| 79 | { input: 17, expected: true }, |
| 80 | ])('should return $expected for isPrime($input)', ({ input, expected }) => { |
| 81 | expect(isPrime(input)).toBe(expected); |
| 82 | }); |
| 83 | |
| 84 | // Pattern 7: Snapshot for complex objects |
| 85 | it('should generate correct invoice PDF structure', () => { |
| 86 | const invoice = generateInvoice(invoiceData); |
| 87 | expect(invoice).toMatchSnapshot({ |
| 88 | id: expect.any(String), |
| 89 | generatedAt: expect.any(Number), |
| 90 | }); |
| 91 | }); |
pro tip