Как полюбить тестирование?

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

Как полюбить тестирование?

TL; DR

Дальше — 3195 знаков о том, что не так с обычным подходом к тестированию, и какие подходы можно использовать вместо него.

Если вам это не очень интересно, то сразу переходите к практической части.

А что не так с тестированием?

Как и всё в процессе разработки, чем позже внедряется автоматическое тестирование, тем дороже оно будет стоить. А внедряют его обычно, когда код уже написан. Это логично: написали код — написали тесты — радуемся, что все работает. Но часто тестирование воспринимают как дополнительную нагрузку и даже просто формальность. Разработчик знает, что тесты спасут его позже, но это бывает сложно объяснить менеджеру — всё и так уже работает, зачем тратить время ещё и на них? Поэтому, чтобы не было соблазна пропустить этот этап, лучше заранее думать о тестировании и заложить на него дополнительное время.

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

Помимо проблемы с «дополнительным» временем, при внедрении тестов после реализации, у разработчика может возникнуть когнитивное искажение, и он начнет писать кейсы не к системе, а к своему коду. На первый взгляд одно и тоже, но на деле это не всегда так. Например, задача звучала так: «нужно отправить письмо с приветствием на адрес пользователя, который он ввёл через форму». Разработчик пишет код, по ходу вручную его тестирует, и в конце добавляет один-единственный тест, проверяющий, что письмо ушло на введённый адрес. Но что будет, если ввести не адрес, а что-нибудь другое? А если введённого адреса не существует? А если злоумышленники написали скрипт, который теперь рассылает письма сотням пользователей в секунду?

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

Тидиди-бидиди-бу

Удобнее построить процесс так, чтобы внедрение тестирования было его неотъемлемой частью. Тогда менеджер не будет спрашивать зачем это всё нужно, и разработчик не будет относиться к тестам как к дополнительной нагрузке. В этом поможет подход TDD (test-driven development), где мы сначала пишем тесты, а потом реализацию. В некоторых случаях используют BDD (behaviour-driven development) — расширение TDD, где сценарий тестирования разрабатывается от лица пользователя.

Тут внимательный читатель, не знакомый с этими терминами, задастся справедливым вопросом — «Как тестировать то, чего ещё нет?». Путаница возникает из-за названия, и было бы гораздо проще понять этот концепт, если бы он назывался как-нибудь вроде specification-driven development. Суть в том, что сначала мы пишем формальную спецификацию в виде тест-кейсов, и только потом пишем по ней код.

Такой подход не только сделает тестирование обязательной частью цикла разработки, но и поможет сформулировать формальные требования к реализуемому модулю. Все равно при написании метода разработчик задаётся вопросами: зачем он мне нужен? какие у него аргументы? что он должен вернуть? Кейсы и будут ответами, формально записанными в коде. А главное, с их помощью можно будет сразу проверить, что метод написан верно.

Ну что, давайте начнём?

Для примера возьмём упомянутую систему для отправки писем. Фазу проектирования для краткости рассматривать не будем. Допустим, мы знаем, что одним из модулей системы должен быть валидатор полей формы. Это класс, у которого есть методы для проверки значений на разные условия. Например:

  • Является ли значение строкой?
  • Является ли строка email адресом?
  • Является ли значение числом?
  • Попадает ли число в указанный интервал?

Можно придумать много вариантов, но для начала хватит и этого. Поскольку автор фронтендер, примеры коды написаны на TypeScript, а тест-кейсы описаны для тест-фреймворков вроде Mocha или Jest.

1. Основные сущности

Чтобы сразу использовать необходимые классы в тест-кейсах, нужно описать их в коде. Это не противоречит TDD, так как эти классы уже описаны в проекте системы (то есть них уже есть спецификация). В том числе, в проекте должны быть сигнатуры методов. Опишем класс InputValidator в файл InputValidator.ts:

class InputValidator { static isString(value: unknown): value is string { return false; } static isEmail(value: unknown): boolean { return false; } static isNumber(value: unknown): value is number { return false; } static isAtRange(value: unknown, range: [number, number]): boolean { return false; } }

Обратите внимание: в примере присутствует только описание методов класса, без реализации. Возвращаемое false добавлено для того, чтобы код успешно компилировался.

2. Спецификация

Можно переходить к написанию формальной спецификации. В соответствии с TDD подходом, нужно описать все возможные тест-кейсы, которые можно получить из требований к модулю. Есть много разных методов для выделения кейсов: классы эквивалентности, граничные значения, попарное тестирование и т.д. и т.п. Описывать их здесь не имеет смысла; каждый заслуживает отдельного разбора. Поэтому в примере будем руководствоваться методом «здравого смысла». Писать тесты будем в файле рядом с тестируемым классом, добавив суффикс spec —InputValidator.spec.ts.

Возьмем, например, метод isString(value). Тут всё просто, передали строку — ожидаем true, передали не строку — ожидаем false.

describe("InputValidator", () => { describe(".isString(value)", () => { it("should return true if value is a string", () => { expect(InputValidator.isString("some string")).toEqual(true); }); it("should return false if value is not a string", () => { expect(InputValidator.isString(12345)).toEqual(false); }); }); });

С методом isEmail(value) немножко сложнее. Если переданная строка является почтовым адресом, то ожидаем true, и ждём false, если нет. Если же передали не строку, то в любом случае ожидаем false.

describe("InputValidator", () => { describe(".isEmail(value)", () => { it("should return true if value is a string and an email", () => { expect(InputValidator.isEmail("email@e.mail")).toEqual(true); }); it("should return false if value is a string but not an email", () => { expect(InputValidator.isEmail("not an email string")).toEqual(false); }); it("should return false if value is not a string", () => { expect(InputValidator.isEmail(54321)).toEqual(false); }); }); });

Примерно то же самое проделываем с isNumber(value) и isAtRange(value, range).

describe("InputValidator", () => { describe(".isNumber(value)", () => { it("should return true if value is a number", () => { expect(InputValidator.isNumber(1234)).toEqual(true); }); it("should return false if value is not a number", () => { expect(InputValidator.isNumber(NaN)).toEqual(false); }); }); describe(".isAtRange(value, range)", () => { it("should return true if value is a number and at given range", () => { expect(InputValidator.isAtRange(3, [0, 5])).toEqual(true); }); it("should return false if value is a number but not at given range", () => { expect(InputValidator.isAtRange(10, [3, 8])).toEqual(false); }); it("should return false if value is not a number", () => { expect(InputValidator.isAtRange("string", [0, 10])).toEqual(false); }); }); });

Можно попробовать запустить тесты, но половина из них, конечно, провалится — реализации еще нет.

Опытный тестировщик найдет ещё тест-кейсы, но задача инструкции все же рассказать о подходе, нежели о методах самого тестирования.

3. Реализация

Теперь, когда у нас есть спецификация на каждый из методов, можно начинать писать реализацию. В Jest (как и во многих других фреймворках для тестирования) есть удобные методы для локализации кейса. Нам пригодятся describe.only и test.only — они же fdescribe и fit. Выделим набор кейсов, который собираемся реализовать.

fdescribe(".isString(value)", () => { it("should return true if value is a string", () => { expect(InputValidator.isString("some string")).toEqual(true); }); it("should return false if value is not a string", () => { expect(InputValidator.isString(12345)).toEqual(false); }); });

Если сейчас запустить тесты, то выполнится только набор, который мы выбрали.

Выделим конкретный кейс.

fit("should return true if value is a string", () => { expect(InputValidator.isString("some string")).toEqual(true); });

Теперь можно сосредоточиться только на этом кейсе и написать под него код:

static isString(value: unknown): value is string { return typeof value === 'string' || value instanceof String; }

Запустив тесты, убедимся, что возвращается ожидаемое значение; затем можно переместить fit на следующий кейс. Для него ничего дополнительно делать не нужно: наш код и так вернет false, если в метод передана не строка. В более сложных кейсах после реализации и успешного тестирования, можно при необходимости заняться рефакторингом. Если в процессе что-то сломается, это можно будет быстро понять по проваленным тестам.

Немного интереснее будет с методом isEmail(). Первые два кейса покроет следующий код:

static isEmail(value: unknown): boolean { const emailRegExp = /\S+@\S+\.\S+/; return emailRegExp.test(value as string); }

Когда очередь дойдёт до последнего кейса, код покроет и его:

fit("should return false if value is not a string", () => { expect(InputValidator.isEmail(54321)).toEqual(false); });

Но наличие этого кейса подскажет разработчику, что нужно дополнительно проверить, является ли значение строкой:

static isEmail(value: unknown): boolean { if (!InputValidator.isString(value)) { return false; } const emailRegExp = /\S+@\S+\.\S+/; return emailRegExp.test(value); }

То же самое проделываем с оставшимися методами. Реализацию можно посмотреть по ссылке. Там же можно найти файл со спецификацией.

4. Подведём итог

Итак, резюмируем преимущества TDD подхода к разработке:

  1. Автоматическое тестирование встроено в начало цикла разработки, что сокращает расходы на внедрение;
  2. Разработчик формально описывает требования к реализуемому классу/модулю/фиче, что позволяет ещё до имплементации избежать возможных багов и в очередной раз проверить адекватность требований и проекта системы;
  3. Разработчикам становится проще писать код: не нужно держать в голове все требования, и при этом код можно быстро протестировать на соответствие им;
  4. Поскольку тесты пишутся раньше кода, покрытие стремится к 100%, а значит что-то незаметно сломать при внедрении новых фич становится труднее.

Ничто не сравнится с чувством, когда код наконец заработал! Но если без TDD позже оно омрачается необходимостью дописывать тесты, то c TDD — бум! — они уже есть.

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

TDD будет работать как для waterfall модели (пишем спецификацию сразу для всех частей системы, а затем реализуем), так и для гибких моделей, где мы пишем спецификацию под модуль или фичу, над которой собираемся работать в спринте.

5. Что-нибудь ещё?

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

Вот ещё несколько подсказок, который могут помочь при тестировании кода.

Изучайте инструменты

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

Тесты это тоже код

Тесты это такой же код — их пишут и читают разработчики и QA инженеры. Поэтому к ним стоит применять те же правила, что и к коду: линтеры, статические анализаторы, типизация и прочее. И, конечно, их можно и нужно рефакторить. Если участки кода повторяются — вынесите их в отдельный метод. Тест проходит слишком долго — оптимизируйте его выполнение. Вам могут хорошо помочь возможности используемых инструментов, поэтому см. абзац выше.

Тесты это документация

Тесты — не только код, тест-кейсы также формально описывают требования к вашей системе. Поэтому нужно понятно формулировать названия кейсов — они должны чётко описывать, что именно здесь проверяется. Их, как и код, нужно поддерживать актуальными и читабельными.

Тесты это лакмусовая бумажка для системы

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

P.S.

Целью этого материала, конечно, не было заставить всех полюбить тестирование. Но интересно было бы узнать, повлиял ли он как-то на ваше отношение к нему.

Что скажете про TDD?
Пользуюсь, нравится
Пользуюсь, не нравится
Пишу тесты не по TDD, появилось желание попробовать
Пишу тесты не по TDD, так и продолжу
Не пишу тесты, теперь попробую TDD
Тесты не пишу и не буду
55
Начать дискуссию