Обучение-через-тесты (Test-driven education)
Недавно в разговоре с одним из моих наставников по 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().
Подход получше:
- Тестировать
foo()сparam1равнымnull. - Тестировать
foo()сparam1равным какой-нибудь строке. - Тестировать
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-среде.
В итоге можно ли сказать, что тесты тестируют код, а код тестирует тесты? По моему опыту — да. Поломка обеих частей этого цикла — тестов и реализации - дает понимание их поведения. Делая это, я делаю лучше и тесты, и код, который они проверяют.