DEV Community

Alex Chen
Alex Chen

Posted on

Testing JavaScript: Practical Guide to Confident Code (2026)

Testing JavaScript: Practical Guide to Confident Code (2026)

Testing isn't about catching bugs — it's about having the confidence to change code without fear. Here's how to write tests that actually matter.

The Testing Pyramid

                    ╱╲
                   ╱ E2E╲           ← Few, slow, expensive
                  ╱──────╲
                 ╱ Integration╲    ← Some, medium speed
                ╱──────────────╲
               ╱   Unit Tests    ╲ ← Many, fast, cheap
              ╱──────────────────╲

Rule of thumb:
- 70% Unit tests (fast, isolated)
- 20% Integration tests (test component interaction)
- 10% E2E tests (critical user flows only)

Unit test: Does this function work? (ms per test)
Integration: Do these modules work together? (seconds per test)
E2E: Can a user complete a purchase? (minutes per test)
Enter fullscreen mode Exit fullscreen mode

Unit Testing with Vitest/Jest

// Setup (vitest.config.js or jest.config.js):
import { defineConfig } from 'vitest/config';
export default defineConfig({
  test: {
    environment: 'node',
    globals: true,
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      include: ['src/**/*.js'],
      exclude: ['src/**/*.test.js', 'src/config/**'],
      thresholds: {
        branches: 80,
        functions: 80,
        lines: 80,
        statements: 80,
      },
    },
  },
});

// Basic unit test:
import { describe, it, expect, beforeEach } from 'vitest';
import { Calculator } from '../calculator.js';

describe('Calculator', () => {
  let calc;

  beforeEach(() => {
    calc = new Calculator();
  });

  describe('add()', () => {
    it('should add two positive numbers', () => {
      expect(calc.add(2, 3)).toBe(5);
    });

    it('should handle negative numbers', () => {
      expect(calc.add(-1, -1)).toBe(-2);
    });

    it('should handle decimal numbers', () => {
      expect(calc.add(0.1, 0.2)).toBeCloseTo(0.3); // Floating point!
    });

    it('should throw on non-number input', () => {
      expect(() => calc.add('a', 2)).toThrow('Numbers only');
    });
  });
});

// Async testing:
describe('API Service', () => {
  it('should fetch user data', async () => {
    const user = await userService.findById('abc123');
    expect(user).toBeDefined();
    expect(user.id).toBe('abc123');
  });

  it('should throw on not found', async () => {
    await expect(userService.findById('nonexistent'))
      .rejects.toThrow('User not found');
  });

  // Mocking external dependencies:
  it('should call API with correct params', async () => {
    const mockFetch = vi.fn().mockResolvedValue({
      json: async () => ({ id: '123', name: 'Test' })
    });

    global.fetch = mockFetch;
    const result = await apiClient.getUser('123');

    expect(mockFetch).toHaveBeenCalledWith('/api/users/123');
    expect(result.name).toBe('Test');
  });
});
Enter fullscreen mode Exit fullscreen mode

Test Doubles: Mocks, Stubs & Spies

// Understanding when to use each:

// STUB: Replace real implementation with a fixed response
// Use when: You need to control dependencies' behavior deterministically
const dbStub = {
  findById: vi.fn()
    .mockResolvedValueOnce({ id: '1', name: 'Alice' })
    .mockResolvedValueOnce({ id: '2', name: 'Bob' })
    .mockRejectedValueOnce(new Error('DB error')),
};

// MOCK: Verify behavior (was it called? with what args?)
// Use when: You need to assert HOW your code uses dependencies
const emailServiceMock = {
  sendWelcomeEmail: vi.fn(),
};

await registerNewUser({ email: 'test@example.com' });
expect(emailServiceMock.sendWelcomeEmail).toHaveBeenCalledTimes(1);
expect(emailServiceMock.sendWelcomeEmail).toHaveBeenCalledWith(
  'test@example.com',
  'Welcome!'
);

// SPY: Wrap real function to track calls without replacing behavior
// Use when: You want to keep original behavior but verify usage
const realLogger = console.log;
const logSpy = vi.spyOn(console, 'log');

processData();
expect(logSpy).toHaveBeenCalledWith('Processing started');

// Best practices for mocking:
// ✅ Mock at boundaries (database, file system, network, time)
// ✅ Keep mocks close to the code they mock (same folder or __mocks__/)
// ❌ Don't mock everything — if you mock everything, you're testing nothing
// ❌ Don't mock implementation details — test behavior, not internals

// Time mocking (essential for timers/deadlines):
vi.useFakeTimers();
const callback = vi.fn();
setTimeout(callback, 5000);

// Fast-forward 5 seconds instantly!
vi.advanceTimersByTime(5000);
expect(callback).toHaveBeenCalled();

// Or advance tick by tick:
vi.advanceTimersByTime(1000);
expect(callback).not.toHaveBeenCalled(); // Not yet!

vi.advanceTimersByTime(4000);
expect(callback).toHaveBeenCalled(); // Now!
vi.useRealTimers(); // Always restore!
Enter fullscreen mode Exit fullscreen mode

Integration Testing

// Test that components work together correctly:

describe('Auth Flow Integration', () => {
  // Use a test database (not production!)
  beforeAll(async () => {
    await testDb.migrate();
    await testDb.seed(['users', 'sessions']);
  });

  afterAll(async () => {
    await testDb.close();
  });

  it('should complete full login flow', async () => {
    // Step 1: Register
    const regRes = await request(app)
      .post('/api/auth/register')
      .send({ email: 'test@example.com', password: 'Password123!' })
      .expect(201);

    expect(regRes.body.data.email).toBe('test@example.com');
    expect(regRes.body.data.token).toBeDefined();

    // Step 2: Login with same credentials
    const loginRes = await request(app)
      .post('/api/auth/login')
      .send({ email: 'test@example.com', password: 'Password123!' })
      .expect(200);

    // Step 3: Access protected endpoint with token
    const profileRes = await request(app)
      .get('/api/profile')
      .set('Authorization', `Bearer ${loginRes.body.token}`)
      .expect(200);

    expect(profileRes.body.data.email).toBe('test@example.com');
  });

  it('should reject expired token', async () => {
    const expiredToken = jwt.sign({ sub: 'user1' }, secret, { expiresIn: '-1h' });

    await request(app)
      .get('/api/profile')
      .set('Authorization', `Bearer ${expiredToken}`)
      .expect(401);
  });
});

// Database integration with test containers:
// Using testcontainers-js for real DB in tests:
import { PostgreSqlContainer } from '@testcontainers/postgresql';

let container;
let pool;

beforeAll(async () => {
  container = await new PostgreSqlContainer().start();
  pool = new Pool({ connectionString: container.getConnectionUri() });
  await pool.query(`CREATE TABLE users (id UUID PRIMARY KEY, name TEXT)`);
});

afterAll(async () => {
  await pool.end();
  await container.stop();
});
Enter fullscreen mode Exit fullscreen mode

What to Test (and What NOT to Test)

// ✅ TEST THIS:
// - Business logic and calculations
// - Authentication and authorization rules
// - Input validation and sanitization
// - Error handling paths (what happens when things go wrong?)
// - Critical user flows (login, checkout, data export)
// - Edge cases: empty arrays, null values, boundary numbers, unicode strings
// - Performance-sensitive operations (are they within acceptable time?)

// ❌ DON'T TEST THIS:
// - Third-party libraries (they have their own tests)
// - Framework internals (Express routing works, don't test it)
// - Implementation details (private methods, internal state)
// - Trivial getters/setters (one-liners)
// - Things you can't control (external APIs without mocking)

// The "useful test" litmus test:
// If this test fails, will it tell me exactly what's wrong?
// If I refactor the code (without changing behavior), will this test still pass?
// If the answer to both is YES → good test!
// If either is NO → reconsider the test.

// Test naming convention that tells a story:
it('should deny access when token has expired');         // Clear!
it('auth-001');                                         // Unclear — what does this test?
it('returns 401 for expired tokens');                     // Better but vague
it('POST /api/protected → 401 when JWT exp < now');     // BEST — specific + actionable
Enter fullscreen mode Exit fullscreen mode

What's your testing philosophy? TDD first, or test after? How much coverage do you aim for?

Follow @armorbreak for more practical developer guides.

Top comments (0)