Недавно в разговоре с одним из моих наставников по fullstack разработке я объяснял, как изучал разные фреймворки и библиотеки. Посмотрев на мой подход, он заметил: «Это выглядит как… Обучение через тесты…».

Я считаю себя разработчиком, ориентированным на TDD (Test-Driven Development). И не подозревал, что TDD помогает мне не только писать новый код, но и глубже понимать существующий или сторонний код — будь то библиотека или что-то другое.

Буду откровенен, я не люблю читать документацию. Мне это сложно — засыпаю, теряю фокус. Зато я люблю ломать вещи. В детстве мне нравилось бросать предметы с высоты, чтобы посмотреть, как они разбиваются. Хорошая новость для меня: в разработке сложно что-то по-настоящему сломать — в худшем случае тесты становятся красными.

На более практическом уровне: документация может быть устаревшей, не совпадать с версией используемого ПО или просто содержать ошибки. Поскольку единственным источником истины является сам код, я предпочитаю работать напрямую с ним.

Я начинаю с минимального теста и минимальной реализации (которая может включать сторонний или легаси-код) — как и положено в TDD. Но прежде чем двигаться дальше, я ломаю тесты. И вот здесь начинается интересное — как ломать тесты? В целом я нашел три способа:

  • Сломать входные данные теста
  • Сломать ожидаемый результат теста
  • Сломать код

Разберем на примере.

function oddOrEven(x: number): string {
  if (x % 2 === 1) {
    return "odd";
  }
  return "even";
}

describe("oddOrEven() tests", () => {
  it("should be odd", () => {
    const result = oddOrEven(3);
    expect(result).toEqual("odd");
  });

  it("should be even", () => {
    const result = oddOrEven(6);
    expect(result).toEqual("even");
  });
});

Сломать ожидаемый результат

Самый простой способ — просто изменить ожидаемый результат на неверный:

it("should be odd", () => {
  const result = oddOrEven(3);
  expect(result).toEqual("even"); // сломано
});

Зачем это делать? Причина простая: убедиться, что тест вообще запускается. Я не доверяю тесту, пока не видел, как он падает. Наблюдать за падением тестов бесценно — это доказывает, что они выполняются и проверки работают. То есть:

  • Тест проходит, когда ожидаемый результат совпадает с фактическим.
  • Тест падает в противном случае.

Второй пункт, пожалуй, даже важнее. Тест существует, чтобы падать, когда что-то идет не так. Еще одна причина, по которой я сначала хочу видеть, как тесты падают.

Сломать входные данные

Этот способ еще проще:

it("should be odd", () => {
  const result = oddOrEven(4); // сломано
  expect(result).toEqual("odd");
});

Если изменение входных данных не влияет на результат теста — что-то не так: либо с тестом, либо с кодом. Используются ли входные данные вообще? Доходит ли выполнение до той части, где они важны?

Это особенно важно при тестировании функций с несколькими параметрами. Например:

function foo(param1: string | null, param2: number): number {
  if (param1 === null) {
    return 0;
  }
  return bar(param1, param2);
}

Нет смысла тестировать foo() с разными значениями param2, когда param1 равен null. Если тест делает это, и изменение param2 ни на что не влияет — это верный признак, что чего-то не хватает. В таком случае настоящее поведение нужно искать внутри bar().

Подход получше:

  1. Тестировать foo() с param1 равным null.
  2. Тестировать foo() с param1 равным какой-нибудь строке.
  3. Тестировать bar() напрямую с разными значениями param1 и param2.

Сломать код

Это мое любимое — находить способы сломать тесты, модифицируя сам код. Если у нас два теста, как сломать оба?

function oddOrEven(x: number): string {
  if (x % 2 !== 1) {
    // сломано: !== вместо ===
    return "odd";
  }
  return "even";
}

Кто-то скажет, что раз падают оба теста, то одного теста достаточно. В простых случаях это кажется очевидным. Но с более сложным или незнакомым кодом — далеко не всегда.

Чтобы опровергнуть тезис «одного теста достаточно», можно сломать код так, чтобы упал только один тест:

function oddOrEven(x: number): string {
  if (x % 2 === 1) {
    return "even"; // сломано: 'even' вместо 'odd'
  }
  return "even";
}

Или:

function oddOrEven(x: number): string {
  if (x % 2 === 1) {
    return "odd";
  }
  return "odd"; // сломано: 'odd' вместо 'even'
}

Еще один способ сломать оба теста:

function oddOrEven(x: number): string {
  return "notOddNotEven"; // сломано
  if (x % 2 !== 1) {
    return "odd";
  }
  return "even";
}

Этот способ более тонкий. Суть в том, что поломка кода в разных местах и разными способами проливает свет на поведение, покрытое тестами. Если поломка кода не приводит к падению тестов — это повод задуматься о покрытии тестами, оно явно недостаточное, и тесты нужно пересмотреть до продолжения разработки.

Часто этот процесс приводит к улучшению тестов. Иногда он вынуждает полностью пересмотреть архитектуру, когда понимаю, что первоначальный подход вообще не работает — будь то структура тестов или архитектурное решение.

В любом случае каждое падение дает ценную информацию. Я узнаю больше о том, как ведет себя код.

Тесты тестируют код, код тестирует тесты

Мы говорим, что тесты тестируют код. Но тесты тоже написаны на коде — обычно на том же языке, что и сам код (приложение). Так где проходит граница между «кодом тестов» и «кодом приложения»? Без фреймворка тестов (например, Jest) и то, и другое — просто код!

Некоторые скажут, что «код приложения» запускается в продакшене, а «код тестов» — нет. Но мы запускаем тесты на том самом коде приложения, часто в CI/CD-пайплайнах. Мы также проводим интеграционные, exploratory, canary и smoke-тесты в dev или prod-среде.

В итоге можно ли сказать, что тесты тестируют код, а код тестирует тесты? По моему опыту — да. Поломка обеих частей этого цикла — тестов и реализации - дает понимание их поведения. Делая это, я делаю лучше и тесты, и код, который они проверяют.