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

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

0ce3da7e1ddd4d93af61694f10b7555a.jpg

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


Я занимаюсь в компании иви.ру 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 за помощь в написании статьи.

© Habrahabr.ru