Тестирование чистых функций

Чистая функция — функция без side effects, которая всегда возвращает одинаковый результат для одинаковых аргументов; она является идеальным кандидатом для unit-тестирования: не требует моков и изолирована по определению.

Зачем нужно

Чистые функции — самые простые в тестировании: нет зависимостей, нет моков, нет async. Тест — это просто expect(fn(input)).toBe(expected). Если стремиться к чистоте функций, код автоматически становится тестируемым.

Где используется

  • Утилитарные функции (форматирование, парсинг, вычисления)
  • Redux reducers (чистые по определению)
  • Функциональные трансформации данных (map, filter, reduce pipeline)
  • Валидаторы, конвертеры, генераторы

Основной контент

Определение чистой функции

// ЧИСТАЯ — нет side effects, детерминированная
function add(a: number, b: number): number {
  return a + b;
}

function capitalize(str: string): string {
  if (!str) return str;
  return str[0].toUpperCase() + str.slice(1).toLowerCase();
}

// НЕ ЧИСТАЯ — изменяет внешнее состояние
let total = 0;
function addToTotal(value: number): void {
  total += value; // side effect!
}

// НЕ ЧИСТАЯ — недетерминированная
function getTimestamp: number {
  return Date.now(); // разный результат при каждом вызове
}

Тест чистой функции: минимум кода

// formatCurrency.test.ts
import { formatCurrency } from './formatCurrency';

describe('formatCurrency', () => {
  test('форматирует рубли', () => {
    expect(formatCurrency(1000, 'RUB')).toBe('1 000 ₽');
  });

  test('форматирует доллары', () => {
    expect(formatCurrency(1500.5, 'USD')).toBe('$1,500.50');
  });

  test('обрабатывает ноль', () => {
    expect(formatCurrency(0, 'RUB')).toBe('0 ₽');
  });

  test('обрабатывает отрицательные значения', () => {
    expect(formatCurrency(-500, 'RUB')).toBe('-500 ₽');
  });
});

Параметризованные тесты для чистых функций

// clamp.test.ts
import { clamp } from './clamp';

test.each([
  [5, 0, 10, 5],   // в диапазоне
  [-5, 0, 10, 0],  // меньше min
  [15, 0, 10, 10], // больше max
  [0, 0, 10, 0],   // граница min
  [10, 0, 10, 10], // граница max
])('clamp(%d, %d, %d) → %d', (value, min, max, expected) => {
  expect(clamp(value, min, max)).toBe(expected);
});

Redux reducer — чистая функция

// counterReducer.test.ts
import { counterReducer } from './counterReducer';

const initialState = { count: 0 };

test('INCREMENT увеличивает count', () => {
  const newState = counterReducer(initialState, { type: 'INCREMENT' });
  expect(newState.count).toBe(1);
  // Исходный state не изменился
  expect(initialState.count).toBe(0);
});

test('не мутирует исходный state', () => {
  const state = { count: 5 };
  const newState = counterReducer(state, { type: 'DECREMENT' });
  expect(newState).not.toBe(state); // новый объект
  expect(state.count).toBe(5);     // исходный не изменился
});

Тестирование pipeline трансформаций

// Функциональный pipeline
const processUsers = (users) =>
  users
    .filter((u) => u.active)
    .map((u) => ({ ...u, fullName: `${u.firstName} ${u.lastName}` }))
    .sort((a, b) => a.fullName.localeCompare(b.fullName));

test('фильтрует, маппит и сортирует пользователей', () => {
  const input = [
    { firstName: 'Bob', lastName: 'Smith', active: true },
    { firstName: 'Alice', lastName: 'Jones', active: true },
    { firstName: 'Charlie', lastName: 'Brown', active: false },
  ];

  const result = processUsers(input);

  expect(result).toHaveLength(2);
  expect(result[0].fullName).toBe('Alice Jones');
  expect(result[1].fullName).toBe('Bob Smith');
});

Частые ошибки

  • Тест мутацииconst arr = [3, 1, 2]; fn(arr); expect(arr).toEqual([1, 2, 3]) — функция мутирует аргумент; чистая функция должна возвращать новый массив
  • Нет теста иммутабельности — всегда проверяй что исходный объект/массив не изменился: expect(input).toEqual(originalInput)
  • Зависимость от Math.random или Date.now() — такие функции не чистые; для тестирования передавай seed или время как аргумент

Связанные темы

Ресурсы