logodev atlas
5 min read

Testing Fundamentals with Jest


Testing Pyramid

       ╔═══════════════╗
       ║   E2E Tests   ║  ← few, slow, expensive
       ╠═══════════════╣
       ║  Integration  ║  ← some, moderate speed
       ╠═══════════════╣
       ║  Unit Tests   ║  ← many, fast, cheap
       ╚═══════════════╝

Unit:        Test individual functions/classes in isolation (mock dependencies)
Integration: Test multiple components together (real DB, real HTTP)
E2E:         Test full user flows (real browser, real services)

Jest Configuration

typescript// jest.config.ts
import type { Config } from 'jest';

const config: Config = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  rootDir: '.',
  testMatch: ['**/*.test.ts', '**/*.spec.ts'],
  collectCoverage: true,
  coverageDirectory: 'coverage',
  coveragePathIgnorePatterns: ['/node_modules/', '/dist/'],
  coverageThreshold: {
    global: { branches: 80, functions: 80, lines: 80, statements: 80 }
  },
  clearMocks: true,     // clear mock.calls between tests
  resetMocks: true,     // reset mock implementations between tests
  setupFilesAfterFramework: ['./tests/setup.ts'],
};

export default config;

Unit Testing

typescript// src/user/user.service.ts
export class UserService {
  constructor(
    private repo: UserRepository,
    private mailer: EmailService,
    private logger: Logger
  ) {}

  async createUser(data: CreateUserDto): Promise<User> {
    const existing = await this.repo.findByEmail(data.email);
    if (existing) throw new ConflictError('Email already exists');

    const user = await this.repo.save(data);
    await this.mailer.sendWelcome(user.email);
    this.logger.info('User created', { userId: user.id });
    return user;
  }
}

// tests/user/user.service.test.ts
import { UserService } from '../../src/user/user.service';
import { ConflictError } from '../../src/errors';

describe('UserService', () => {
  let service: UserService;
  let mockRepo: jest.Mocked<UserRepository>;
  let mockMailer: jest.Mocked<EmailService>;
  let mockLogger: jest.Mocked<Logger>;

  beforeEach(() => {
    // Create mocks:
    mockRepo = {
      findByEmail: jest.fn(),
      save: jest.fn(),
      findById: jest.fn(),
    } as jest.Mocked<UserRepository>;

    mockMailer = { sendWelcome: jest.fn() } as jest.Mocked<EmailService>;
    mockLogger = { info: jest.fn(), error: jest.fn() } as jest.Mocked<Logger>;

    service = new UserService(mockRepo, mockMailer, mockLogger);
  });

  describe('createUser', () => {
    const userData = { name: 'Alice', email: 'alice@example.com' };
    const savedUser = { id: '1', ...userData };

    it('creates and returns a new user', async () => {
      mockRepo.findByEmail.mockResolvedValue(null); // no existing user
      mockRepo.save.mockResolvedValue(savedUser);
      mockMailer.sendWelcome.mockResolvedValue(undefined);

      const result = await service.createUser(userData);

      expect(result).toEqual(savedUser);
      expect(mockRepo.save).toHaveBeenCalledWith(userData);
      expect(mockMailer.sendWelcome).toHaveBeenCalledWith(savedUser.email);
      expect(mockLogger.info).toHaveBeenCalledWith('User created', { userId: '1' });
    });

    it('throws ConflictError if email exists', async () => {
      mockRepo.findByEmail.mockResolvedValue(savedUser); // user exists

      await expect(service.createUser(userData))
        .rejects.toThrow(ConflictError);

      expect(mockRepo.save).not.toHaveBeenCalled();
      expect(mockMailer.sendWelcome).not.toHaveBeenCalled();
    });

    it('propagates repository errors', async () => {
      mockRepo.findByEmail.mockResolvedValue(null);
      mockRepo.save.mockRejectedValue(new Error('DB connection failed'));

      await expect(service.createUser(userData))
        .rejects.toThrow('DB connection failed');
    });
  });
});

Mocking Strategies

typescript// 1. jest.fn() — mock individual functions
const fn = jest.fn().mockReturnValue(42);
const asyncFn = jest.fn().mockResolvedValue({ data: [] });
const failFn = jest.fn().mockRejectedValue(new Error('fail'));

// Check calls:
expect(fn).toHaveBeenCalled();
expect(fn).toHaveBeenCalledTimes(2);
expect(fn).toHaveBeenCalledWith('arg1', 'arg2');
expect(fn).toHaveBeenLastCalledWith('arg1');

// 2. jest.mock() — mock entire module
jest.mock('../../src/database', () => ({
  query: jest.fn().mockResolvedValue([]),
  transaction: jest.fn().mockImplementation(async (fn) => fn({})),
}));

// 3. jest.spyOn() — spy on existing method (can restore)
const spy = jest.spyOn(Math, 'random').mockReturnValue(0.5);
// ... test code ...
spy.mockRestore(); // restore original

// 4. Manual mock — create __mocks__ directory
// src/__mocks__/email.service.ts:
export const EmailService = jest.fn().mockImplementation(() => ({
  sendWelcome: jest.fn().mockResolvedValue(undefined),
}));

// 5. Mock modules with factory:
jest.mock('jsonwebtoken', () => ({
  sign: jest.fn().mockReturnValue('fake-token'),
  verify: jest.fn().mockReturnValue({ sub: 'user-1', role: 'admin' }),
}));

Integration Testing with Supertest

typescript// tests/integration/user.routes.test.ts
import request from 'supertest';
import { createApp } from '../../src/app';
import { db } from '../../src/database';

describe('User Routes', () => {
  let app: Express;

  beforeAll(async () => {
    app = createApp();
    await db.migrate.latest();
    await db.seed.run();
  });

  afterAll(async () => {
    await db.migrate.rollback();
    await db.destroy();
  });

  beforeEach(async () => {
    await db('users').truncate(); // clean state per test
  });

  describe('POST /users', () => {
    it('creates a user and returns 201', async () => {
      const response = await request(app)
        .post('/users')
        .send({ name: 'Alice', email: 'alice@example.com' })
        .expect(201);

      expect(response.body.data).toMatchObject({
        id: expect.any(String),
        name: 'Alice',
        email: 'alice@example.com',
      });
    });

    it('returns 422 for invalid email', async () => {
      const response = await request(app)
        .post('/users')
        .send({ name: 'Alice', email: 'not-an-email' })
        .expect(422);

      expect(response.body.error).toBe('VALIDATION_ERROR');
    });

    it('returns 409 for duplicate email', async () => {
      await request(app).post('/users').send({ name: 'Alice', email: 'alice@example.com' });

      await request(app)
        .post('/users')
        .send({ name: 'Alice2', email: 'alice@example.com' })
        .expect(409);
    });
  });

  describe('GET /users/:id', () => {
    it('returns 401 without auth token', async () => {
      await request(app).get('/users/1').expect(401);
    });

    it('returns user for valid token', async () => {
      const { body: { data: user } } = await request(app)
        .post('/users')
        .send({ name: 'Alice', email: 'alice@example.com' });

      const { body: loginResponse } = await request(app)
        .post('/auth/login')
        .send({ email: 'alice@example.com', password: 'password123' });

      await request(app)
        .get(`/users/${user.id}`)
        .set('Authorization', `Bearer ${loginResponse.data.token}`)
        .expect(200);
    });
  });
});

Testing Async Code

typescript// Promises:
it('resolves with user data', async () => {
  const user = await userService.getUser('1');
  expect(user.name).toBe('Alice');
});

it('rejects with error', async () => {
  await expect(userService.getUser('nonexistent'))
    .rejects.toThrow(NotFoundError);
});

// Callbacks (use done):
it('calls callback with data', (done) => {
  legacyFn('arg', (err, result) => {
    expect(err).toBeNull();
    expect(result).toBe(42);
    done();
  });
});

// Timers (fake timers):
jest.useFakeTimers();

it('calls debounced function after delay', () => {
  const fn = jest.fn();
  const debounced = debounce(fn, 1000);

  debounced();
  debounced();
  debounced();

  expect(fn).not.toHaveBeenCalled(); // not yet

  jest.advanceTimersByTime(1000);

  expect(fn).toHaveBeenCalledTimes(1); // called once
});

afterEach(() => {
  jest.useRealTimers();
});

Test Quality Patterns

typescript// Good: descriptive names, single assertion per test concept
describe('PasswordValidator', () => {
  it('accepts passwords with 8+ characters, number, and special char', () => {
    expect(validate('P@ssw0rd!')).toBe(true);
  });
  it('rejects passwords shorter than 8 characters', () => {
    expect(validate('Ab1!')).toBe(false);
  });
  it('rejects passwords without numbers', () => {
    expect(validate('Password!')).toBe(false);
  });
});

// Parameterized tests (test.each):
test.each([
  ['abc123', false, 'no special char'],
  ['abcDEF!', false, 'no number'],
  ['Ab1!', false, 'too short'],
  ['Abc123!@', true, 'valid'],
])('validate(%s) = %s (%s)', (password, expected) => {
  expect(validate(password)).toBe(expected);
});

// Test isolation — use beforeEach/afterEach, not shared state
// Avoid test order dependency — each test should be independent
// Use realistic test data — not just 'foo', 'bar', '1', '2'

Interview Questions

Q: What is the difference between unit, integration, and E2E tests? A: Unit tests test a single function/class in isolation with all dependencies mocked — fast, many of them. Integration tests test multiple components together (real DB, real HTTP) — slower, fewer. E2E tests test the full system through the user interface — slowest, fewest. The testing pyramid: many unit, some integration, few E2E.

Q: How do you test code that calls a database? A: Unit tests: mock the repository/database client — test business logic in isolation. Integration tests: use a real test database (Docker, TestContainers), run migrations, clean state between tests, test actual DB interactions. Never share state between tests — each test should set up its own data.

Q: What is the difference between jest.fn(), jest.mock(), and jest.spyOn()? A: jest.fn() creates a standalone mock function with no original implementation. jest.mock('module') replaces an entire module with mocks — affects all files that import it. jest.spyOn(obj, 'method') replaces a method on an existing object with a spy but keeps the original implementation by default — useful when you want to verify calls but still run the real code.

Q: How do you test for errors in async functions? A: Use await expect(asyncFn()).rejects.toThrow(ErrorType). Don't use try/catch in tests unless testing the catch block itself — it can hide uncaught errors. If you expect a rejection but it resolves, the test passes (silently wrong). The rejects matcher ensures the test fails if the promise resolves unexpectedly.

[prev·next]