Разделяем интерфейсы для юнит-тестирования
Привет, Хабр! По блогу нашей компании может создаться впечатление, что мы занимаемся только data mining'ом и сетями. Поэтому я, как представитель девелоперского цеха, не смог отказать себе в удовольствии написать статью про то, как круто организовано unit-тестирование и разделение кода на модули у нас во фронтенде.
Чуть-чуть о себе
Я занимаюсь в компании иви.ру frontend-разработкой. Мы используем то же API, что и мобильные приложения, поэтому реализация всей основной логики поведения и отображения ложится на клиентскую часть. Если учесть, что экранов у нас достаточно много, то получается довольно большая база кода, за качеством которого нужно как-то следить. Поэтому у нас активно практикуется TDD. Ну, а так как мы все ООП-маньяки, то тесты организовываются в соответствии со строгими объектно-ориентированными канонами.
О том, какую боль мы испытывали при организации unit-тестов, и как с ней справились и пойдет речь дальше.
Немного теории
NB! Здесь и далее слова «модуль», «класс» и «подсистема» используются как синонимы, хотя на деле это не всегда так.
Связность модулей
В проектировании ПО часто можно встретить две характеристики, описывающие качество разбиения кода на модули — Coupling и Cohesion. Обычно говорят о принципах «Low Coupling» и «High Cohesion». Так что же это значит?
- Low Coupling, или низкое сопряжение, обозначает, что модуль приложения минимально зависит от других и осведомлен только о том функционале, который ему необходим. Это значит, что при правильном проектировании, при изменении одного модуля, нам не придется править другие или эти изменения будут минимальными.
- High Cohesion, или высокая связность говорит о том, что внутри модуля весь функционал согласован и сфокусирован на решении какой-то узкой проблемы. Это значит, что при правильном проектировании модули получаются компактными и понятными, не содержат «лишнего» кода и побочных эффектов.
Unit-тестирование
Unit-тестирование – это тестирование отдельных модулей системы по принципу «черного ящика». То есть берется класс или набор классов, отвечающих за определенную функцию, ему на вход подаются тестовые данные, и результат работы сравнивается с эталонным.
Для реализации Unit-тестов вместо реальных внешних зависимостей модуля используются так называемые mock-объекты, то есть объекты, подменяющие «настоящий» функционал на тестовый.
Довольно часто используются техники (TDD, BDD), в которых сначала пишутся тесты на еще не существующий код, а потом сам модуль, реализующий тестируемый функционал. Это полезно не только с точки зрения тестового покрытия, но и с точки зрения правильной архитектурной организации модулей, потому что сначала мы проектируем внешние интерфейсы «черного ящика», а затем уже с головой погружаемся в реализацию.
Многие архитектурные ошибки можно выявить на этапе написания тестов, потому что, с большой долей вероятности, если код удобно тестировать, то он как раз и обладает низким сопряжением и высокой связностью. Если у тестируемого кода будет высокое сопряжение, то при реализации тестов получатся сложные, насыщенные логикой mock-объекты, а если низкая связность — то множество похожих или сложных case’ов и комбинаций входных и выходных данных.
Много практики
Основная проблема, которую мы будем решать в этой статье — это вопрос о том, как именно организовать код так, чтобы unit-тестирование получилось простым, а код — аккуратным.
Примеры приведены на языке TypeScript, однако подход справедлив для любого строго типизированного объектно-ориентированного языка (Java, C++, ObjC).
Итак, рассмотрим простейшую прикладную задачу:
Пусть у нас есть helloworld-класс A. Его код выглядит так:
class A {
greeting(): string {
return 'Hello, ' + this.b.getName() + '!';
}
private b: B = new B();
}
Как вы можете заметить, у этого класса есть внешняя зависимость – B.
class B {
getName(): string {
return 'Habr';
}
}
Наша задача – покрыть весь функционал класса A тестами.
Тестируем все
Самой простой метод — «в лоб», то есть протестировать сразу всю логику:
it('test', ()=>{
var a: A = new A();
expect(a.greeting()).toBe('Hello, Habr!');
});
Плюсы и минусы этого подхода вполне очевидны:
- + Такой код писать просто.
- + Удобно в случаях, когда тестов в проекте немного и используются они для ловли сложных багов.
- - Тестируется не сам класс A, а целый пласт функционала. Если пласт этот большой, а функционал сложный — тесты получатся слишком объемные и запутанные. По большому счету, это не unit-тест, а I&T.
- - При изменении кода B, придется править все тесты модулей, использующих его.
- - Такие тесты не побуждают разработчика правильно разбивать код на модули.
Переопределяем метод «на лету»
«Ладно» — скажете вы — «тогда давайте просто переопределим нужное нам поле и все.» Например, так:
it('test', ()=>{
var a: A = new A();
a['b'] = {
getName: ()=>'Test'
};
expect(a.greeting()).toBe('Hello, Test!');
});
Казалось бы, проблема решена, но нет: в случае, если поле b создается внутри класса динамически, то мы должны постоянно за этим следить и подсовывать наше тестовое значение. В итоге:
- + Не нужно тестировать внешние зависимости.
- - Нарушается принцип «черного ящика» — нужно править приватное поле класса.
- - Необходимо следить в тесте за тем, чтобы подмененное поле всегда было актуально, то есть чтобы сама реализация класса не затерла его значение.
- - В «настоящих» строго типизированных языках так сделать невозможно.
- - Все это не добавляет тестам читаемости
Наследуемся от тестируемого класса
Фактически, это тот же метод, что и в прошлом примере, только адаптированный для языков со строгой типизацией. Сначала делаем поле b в классе A не private, а protected, и создаем mock-класс, обертку над A:
class MockA extends A {
constructor() {
super();
this.b = {
getName: ()=>'Test'
};
}
}
Тестировать мы будем этот новый класс:
it('test', ()=>{
var a: A = new MockA();
expect(a.greeting()).toBe('Hello, Test!');
});
- + Строго типизированный вариант предыдущего подхода.
- - Проблемы это не решило.
Инъекция зависимости
Разумеется, задача управления зависимостями не нова, и решение её существует. Вы уже, наверное, слышали про Dependency Injection, если кратко — то это подход, при котором модуль не сам управляет своими зависимостями, а они сами приходят к нему извне (например, через конструктор).
В нашем случае это выглядит так:
class A {
constructor(private b: B) {}
greeting(): string {
return 'Hello, ' + this.b.getName() + '!';
}
}
Тогда в самом тесте мы можем обернуть уже класс B:
class MockB extends B {
public getName() {
return 'Test';
}
}
И передать нашу моковую обёртку в A:
it('test', ()=>{
var a: A = new A(new MockB());
expect(a.greeting()).toBe('Hello, Test!');
});
- + Тестирование честно ведется по принципу «черного ящика».
- + Код правильно разбит на модули.
- - Наследоваться от реального класса все-таки не всегда удобно (об этом подробнее ниже).
Инъекция зависимости с использованием интерфейса
Не всегда сделать extend от класса так просто, да и функционал, который в нем реализован, может оказывать паразитные (для данного теста) side-эффекты. Решить эту проблему нам поможет объявление интерфейса модуля, который мы используем как зависимость:
interface IB {
getName(): string;
}
Тогда вместо того, чтобы наследоваться от реального класса B, мы просто имплементируем его интерфейс:
class MockB implements IB {
getName() {
return 'Test';
}
}
Тестирование будет выглядеть так же, как и в предыдущем примере:
it('test', ()=>{
var a: A = new A(new MockB());
expect(a.greeting()).toBe('Hello, Test!');
});
- + Тесты тестируют только один модуль и зависят только от его реализации
- - Работает только до тех пор, пока проект небольшой и подсистемы маленькие
Разделяем интерфейсы
Мы переходим непосредственно к тому, ради чего затевалась эта статья, а именно к разделению интерфейсов одной подсистемы. В зарубежной литературе это иногда называется «Interface Decoupling»
Давайте теперь представим, что у нас большой проект с большим количеством модулей. Пусть класс A по-прежнему использует только один метод из B, но его и друге методы (которых может быть много) активно используют другие модули. В этом случае, интерфейс IB оказывается довольно объемным:
interface IB {
getName(): string;
getLastName(): string;
getBirthDate(): Date;
}
Теперь для того, чтобы сделать mock-объект для тестируемого класса A, нам потребуется определить еще несколько ненужных нам методов:
class MockB implements IB {
getName() {
return 'Test';
}
getLastName():string {
return undefined;
}
getBirthDate():Date {
return undefined;
}
}
Представьте, какие wall of text мы получим, если модуль зависит от пары-тройки других модулей с 10+ методами. Более того, из-за этого мы получаем высокое сопряжение, связанное с тем, что модуль «знает» о методах другого модуля, которые не использует. Это приводит к тому, что при изменении сигнатуры одного из методов, код придется менять во всех тестах, а не только в тех, которые используют измененный метод.
Для того, чтобы избежать этой излишней осведомленности, мы будем разделять интерфейсы для конкретных подсистем. Выделим из интерфейса IB наборы методов, которые использует каждый из модулей, и сгруппируем их в отдельные интерфейсы. В нашем случае это выглядит так:
export interface IBForA {
getName(): string;
}
export interface IBForSomeOtherModule {
getLastName(): string;
getBirthDate(): Date;
}
Объединение всех этих интерфейсов и должен реализовывать класс B:
export interface IB extends IBForA, IBForSomeOtherModule {
}
class B implements IB {
public getName(): string {
return 'Habr';
}
public getLastName():string {
return 'last';
}
public getBirthDate():Date {
return new Date();
}
}
Класс A, в свою очередь, зависит не от всего интерфейса IB, а только от своего:
class A {
constructor(private b: IBForA) {
}
greeting(): string {
return 'Hello, ' + this.b.getName() + '!';
}
}
Таким образом, каждый модуль для каждой своей зависимости имеет интерфейс, описывающий то и только то, что используется в данном модуле.
- + Каждый модуль знает о других только то, что ему необходимо знать.
- + Любые локальные изменения одного из модулей затронут только тесты на этот модуль.
- + Изменение одного из методов приведет к изменению только тех модулей, которые непосредственно пользуются этим интерфейсом.
- - Большое количество интерфейсов и моковых классов затрудняет ориентирование в коде.
Вместо заключения
Как всегда оказывается на практике, удобнее всего использовать некий гибридный подход. Например, на нашем проекте мы используем разделение интерфейсов только для крупных подсистем, а внутри них для классов делаем mock-объекты простым extend'ом.
В любом случае, описанные паттерны существенно облегчают жизнь при работе по TDD. Как я уже писал выше, правильно организованные тесты помогают выявить архитектурную проблему до ее реализации, а это сэкономленные человеко-часы разработчиков и нервы менеджеров.
Все описанные здесь примеры можно посмотреть на github
Огромная благодарность darkartur за помощь в написании статьи.