Разделяем интерфейсы для юнит-тестирования

Разделяем интерфейсы для юнит-тестирования

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

Чуть-чуть о себе

Я занимаюсь в компании иви.ру frontend-разработкой. Мы используем то же API, что и мобильные приложения, поэтому реализация всей основной логики поведения и отображения ложится на клиентскую часть. Если учесть, что экранов у нас достаточно много, то получается довольно большая база кода, за качеством которого нужно как-то следить. Поэтому у нас активно практикуется TDD. Ну, а так как мы все ООП-маньяки, то тесты организовываются в соответствии со строгими объектно-ориентированными канонами.

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

Немного теории

NB! Здесь и далее слова «модуль», «класс» и «подсистема» используются как синонимы, хотя на деле это не всегда так.

Связность модулей

В проектировании ПО часто можно встретить две характеристики, описывающие качество разбиения кода на модули — Coupling и Cohesion. Обычно говорят о принципах «Low Coupling» и «High Cohesion». Так что же это значит?

    Low Coupling, или низкое сопряжение, обозначает, что модуль приложения минимально зависит от других и осведомлен только о том функционале, который ему необходим. Это значит, что при правильном проектировании, при изменении одного модуля, нам не придется править другие или эти изменения будут минимальными.

Unit-тестирование

Unit-тестирование – это тестирование отдельных модулей системы по принципу «черного ящика». То есть берется класс или набор классов, отвечающих за определенную функцию, ему на вход подаются тестовые данные, и результат работы сравнивается с эталонным.

Для реализации Unit-тестов вместо реальных внешних зависимостей модуля используются так называемые mock-объекты, то есть объекты, подменяющие «настоящий» функционал на тестовый.

Довольно часто используются техники (TDD, BDD), в которых сначала пишутся тесты на еще не существующий код, а потом сам модуль, реализующий тестируемый функционал. Это полезно не только с точки зрения тестового покрытия, но и с точки зрения правильной архитектурной организации модулей, потому что сначала мы проектируем внешние интерфейсы «черного ящика», а затем уже с головой погружаемся в реализацию.

Многие архитектурные ошибки можно выявить на этапе написания тестов, потому что, с большой долей вероятности, если код удобно тестировать, то он как раз и обладает низким сопряжением и высокой связностью. Если у тестируемого кода будет высокое сопряжение, то при реализации тестов получатся сложные, насыщенные логикой mock-объекты, а если низкая связность — то множество похожих или сложных case’ов и комбинаций входных и выходных данных.

Много практики

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

Примеры приведены на языке TypeScript, однако подход справедлив для любого строго типизированного объектно-ориентированного языка (Java, C++, ObjC).

Итак, рассмотрим простейшую прикладную задачу:

Пусть у нас есть helloworld-класс A. Его код выглядит так:

Как вы можете заметить, у этого класса есть внешняя зависимость – B.

Наша задача – покрыть весь функционал класса A тестами.

Тестируем все

Самой простой метод — «в лоб», то есть протестировать сразу всю логику:

Плюсы и минусы этого подхода вполне очевидны:

  • + Такой код писать просто.
  • + Удобно в случаях, когда тестов в проекте немного и используются они для ловли сложных багов.
  • - Тестируется не сам класс A, а целый пласт функционала. Если пласт этот большой, а функционал сложный — тесты получатся слишком объемные и запутанные. По большому счету, это не unit-тест, а I&T.
  • - При изменении кода B, придется править все тесты модулей, использующих его.
  • - Такие тесты не побуждают разработчика правильно разбивать код на модули.

Переопределяем метод «на лету»

«Ладно» — скажете вы — «тогда давайте просто переопределим нужное нам поле и все.» Например, так:

Казалось бы, проблема решена, но нет: в случае, если поле b создается внутри класса динамически, то мы должны постоянно за этим следить и подсовывать наше тестовое значение. В итоге:

  • + Не нужно тестировать внешние зависимости.
  • - Нарушается принцип «черного ящика» — нужно править приватное поле класса.
  • - Необходимо следить в тесте за тем, чтобы подмененное поле всегда было актуально, то есть чтобы сама реализация класса не затерла его значение.
  • - В «настоящих» строго типизированных языках так сделать невозможно.
  • - Все это не добавляет тестам читаемости
Наследуемся от тестируемого класса

Фактически, это тот же метод, что и в прошлом примере, только адаптированный для языков со строгой типизацией. Сначала делаем поле b в классе A не private, а protected, и создаем mock-класс, обертку над A:

Тестировать мы будем этот новый класс:

  • + Строго типизированный вариант предыдущего подхода.
  • - Проблемы это не решило.
Инъекция зависимости

Разумеется, задача управления зависимостями не нова, и решение её существует. Вы уже, наверное, слышали про Dependency Injection, если кратко — то это подход, при котором модуль не сам управляет своими зависимостями, а они сами приходят к нему извне (например, через конструктор).

В нашем случае это выглядит так:

Тогда в самом тесте мы можем обернуть уже класс B:

И передать нашу моковую обёртку в A:

  • + Тестирование честно ведется по принципу «черного ящика».
  • + Код правильно разбит на модули.
  • - Наследоваться от реального класса все-таки не всегда удобно (об этом подробнее ниже).
Инъекция зависимости с использованием интерфейса

Не всегда сделать extend от класса так просто, да и функционал, который в нем реализован, может оказывать паразитные (для данного теста) side-эффекты. Решить эту проблему нам поможет объявление интерфейса модуля, который мы используем как зависимость:

Тогда вместо того, чтобы наследоваться от реального класса B, мы просто имплементируем его интерфейс:

Тестирование будет выглядеть так же, как и в предыдущем примере:

  • + Тесты тестируют только один модуль и зависят только от его реализации
  • - Работает только до тех пор, пока проект небольшой и подсистемы маленькие
Разделяем интерфейсы

Мы переходим непосредственно к тому, ради чего затевалась эта статья, а именно к разделению интерфейсов одной подсистемы. В зарубежной литературе это иногда называется «Interface Decoupling»

Давайте теперь представим, что у нас большой проект с большим количеством модулей. Пусть класс A по-прежнему использует только один метод из B, но его и друге методы (которых может быть много) активно используют другие модули. В этом случае, интерфейс IB оказывается довольно объемным:

Теперь для того, чтобы сделать mock-объект для тестируемого класса A, нам потребуется определить еще несколько ненужных нам методов:

Представьте, какие wall of text мы получим, если модуль зависит от пары-тройки других модулей с 10+ методами. Более того, из-за этого мы получаем высокое сопряжение, связанное с тем, что модуль «знает» о методах другого модуля, которые не использует. Это приводит к тому, что при изменении сигнатуры одного из методов, код придется менять во всех тестах, а не только в тех, которые используют измененный метод.

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

Объединение всех этих интерфейсов и должен реализовывать класс B:

Класс A, в свою очередь, зависит не от всего интерфейса IB, а только от своего:

Таким образом, каждый модуль для каждой своей зависимости имеет интерфейс, описывающий то и только то, что используется в данном модуле.

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

Вместо заключения

Как всегда оказывается на практике, удобнее всего использовать некий гибридный подход. Например, на нашем проекте мы используем разделение интерфейсов только для крупных подсистем, а внутри них для классов делаем mock-объекты простым extend'ом.

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

Все описанные здесь примеры можно посмотреть на github.

Огромная благодарность darkartur за помощь в написании статьи.

📎📎📎📎📎📎📎📎📎📎