По блогу нашей компании может создаться впечатление, что мы занимаемся только 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 за помощь в написании статьи.

Комментарии (53)


  1. poxu
    30.09.2015 13:14

    А вообще как сделать dependency injection на javascript? Есть решение какое-нибудь типы Spring? Чтобы руками зависимости в конструктор не передавать?


    1. koroandr
      30.09.2015 13:21

      Насколько я знаю, в Angular 2 используется подход, очень похожий на Spring. Там как раз TypeScript, начиная с версии 1.5 он поддерживает ES6-декораторы, так что это даже выглядит похоже.

      У нас в проекте используется собственная реализация паттерна Service Locator.


      1. poxu
        30.09.2015 13:26

        Хочется библиотеку, которая реализует DI и больше ничего не реализует. Неужели такой для джаваскрипта нет?


        1. Aetet
          30.09.2015 15:17

          github.com/zerkalica/immutable-di берите.
          Если вам нужен на конфигах, то:
          github.com/zerkalica/micro-di



        1. VolCh
          30.09.2015 18:50

          github.com/young-steveo/bottlejs

          В копилку :)

          А вообще не очень правильно поставленный вопрос: DI — это принцип, который можно реализовать множеством способов, среди которых так или иначе основные через конструкторы и через сеттеры/аддеры. Библиотеки типа DI-контейнеров, скрывающие от нас это, всё равно в большинстве своем их используют.


    1. Delphinum
      30.09.2015 14:27

      В языках подобных JS, где объект это всего лишь словарь (если смотреть снизу-вверх), я последнее время применяю такой подход:

      click
      // Предположим у нас есть прокси к серверной части, который работает с данными.
      function dbProxy(tableName){
        ...
      }
      
      // А этот красс использует прокси и формирует виджет списка выбора (Select) для пользователя. Причем класс знает какой именно прокси ему нужно использовать:
      function SelectWidget(){
      }
      
      SelectWidget.prototype.render = function(){
        // Если свойство заданно динамически, используем его как средство инъекции зависимостей, иначе используем дефолтный объект прокси.
        var db = (this.db !== undefined)? this.db : new dbProxy('users');
        db.getData(...);
        ...
      }
      
      // Тестируется просто:
      var obj = new SelectWidget();
      obj.db = mock('dbProxy')
      obj.db.expects(':getData');
      ...
      


  1. wheercool
    30.09.2015 13:25
    +1

    High Cohesion, или высокая связность говорит о том, что внутри модуля весь функционал согласован и сфокусирован на решении какой-то узкой проблемы. Это значит, что при правильном проектировании модули получаются компактными и понятными, не содержат «лишнего» кода и побочных эффектов.

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


    1. koroandr
      30.09.2015 14:04

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


  1. poxu
    30.09.2015 13:46

    А зачем промежуточный интерфейс IB? Почему B просто не имплементит IBForA и IBForSomeOtherModule?


    1. koroandr
      30.09.2015 14:06

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


      1. VolCh
        30.09.2015 17:53

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


  1. shuron
    30.09.2015 14:30

    Спасибо за рассмотр всех этих случаев… Везде есть свои плюсы и минусы…
    Да разделение интерфейсов и вообще их дизайн важная тема!
    Я в своей команде борюсь со старшим поколением которое вообще уверено что юнит-тесты это на практике ничего не дающая…
    И моль только интеграционные тесты надо писать, а ТестПирамида не спустилась с небес и вообще мозехт быть ерунда :((
    Так что спасибо за тему, как бальзам ;)


  1. Delphinum
    30.09.2015 14:30

    Прошу прощения за небольшой оффтоп, но мне больше нравится перевод терминов Low Coupling и High Cohesion в лоб, как: Слабая связанность и Высокая сплоченность. Как то это больше отражает их смысл.


    1. Adelf
      30.09.2015 14:38
      +3

      Помоему, классический перевод — связанность и связность.


      1. Delphinum
        30.09.2015 14:42

        Возможно, но не очень понятно что там с чем связывается и связуется (не силен в русском языке, может не верно отглаголил).


        1. Adelf
          30.09.2015 14:47
          +1

          Вики про связность Но связанность там переводят как зацепление :)


          1. Delphinum
            30.09.2015 14:54

            Я всего лишь уточнил, какие термины больше нравятся мне, не более того )


  1. Adelf
    30.09.2015 14:43
    +1

    Что мне не нравится в таких статьях, так это когда чуть ли не единственной причиной добавления интерфйесов в проект называют тестируемость кода. Это путает и пугает новичков. Сложно обьяснить нужность тестов, когда они за собой еще и интерфейсы и IoC всякие тащат. Нужна базовая часть, чтобы описать, что такой код(со слабой связанностью) и без тестов выигрышнее привычного им. Правда для хорошего примера нужен код в пару десятков сущностей и их взаимодействий. И это проблема…


    1. koroandr
      30.09.2015 14:58

      Тестируемость — вполне себе неплохая метрика качества кода. Грубо говоря, хороший код и тестировать легко. И если тестировать модуль сложно, то, скорее всего, либо у него не loose coupling, либо не high cohesion.

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

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


      1. Delphinum
        30.09.2015 15:03
        +1

        Думаю если у вас в модуле по 20 методов, стоит удивится, а почему так много?


    1. wheercool
      30.09.2015 16:18

      А что тут думать. Интерфейсы нужны как точки расширения системы, например вон через USB можно подключать как мышь, так и клавиатуру и много чего еще изначально чего не планировалось в системе иметь.
      Проблема тут в другом, что тестируя все модули и выделяя для каждого из них свой интерфейс может получиться настолько абстрактный монстр, что вместе с DI никто уже толком не сможет разобраться что в системе происходит и для чего она вообще создавалась.

      Вообще добавление тестирования к системе это большой такой + к затратам и что более важно сложности системы.


      1. Delphinum
        30.09.2015 16:23

        большой такой + к затратам и что более важно сложности системы

        А что усложняется то? Добавляется папочка tests?


        1. wheercool
          30.09.2015 17:25

          В том то и дело что создать папочку тут не достаточно )
          Взять тот же пример из статьи. У нас есть класс A, к-ый используется классами B, C, D. Причем каждый этот класс использует только 1 метод из класса А, допустим b, c и d соответственно.
          Не парясь о тестируемости мы бы просто реализовали интерфейс IA состоящий из методов b,c,d (а скорее всего даже его и не реализовывали).
          Но чтобы сделать его тестируемым нам нужно выделить еще 3 интерфейса: IAforB, IAforC, IAforD с соответствующими методами.
          Тут уже можно увидеть закономерность, что кол-во сущностей у нас уже удвоилось.

          А вот вам еще один случай, у нас появляется новый класс E, которому нужно 2 метода: b и с, или новый метод e. Что мы теперь будем делать?


          1. Delphinum
            30.09.2015 20:56

            Не парясь о тестируемости мы бы просто реализовали интерфейс IA состоящий из методов b,c,d

            Но чтобы сделать его тестируемым нам нужно выделить еще 3 интерфейса: IAforB, IAforC, IAforD с соответствующими методами

            Ужасное решение. Ниже я уже писал, что нужно уметь определять границу, после которой дробление интерфейса становится во зло.
            А вот вам еще один случай, у нас появляется новый класс E, которому нужно 2 метода: b и с, или новый метод e. Что мы теперь будем делать?

            Использовать реализацию IA, как и раньше.


        1. VolCh
          30.09.2015 18:00

          Как правило, когда тесты начинают писать на уже готовом проекте, то или тесты становятся монструозными (особенно если четко следят, чтобы модульные не являлись интеграционными), или переписывается архитектура на лету, что обычно приводит к ошибкам. А чаще и то, и другое — изменения архитектуры на микроуровне, что лишь немного помогает сократить объём тестов.


          1. Delphinum
            30.09.2015 20:58

            или переписывается архитектура на лету, что обычно приводит к ошибкам

            Моя практика показывает, что написание тестов и связанное с этим изменение архитектуры всегда ведут только к лучшему. Если вы что то тестируете и это приводит к ошибкам, значит вы делаете что то не то.


            1. VolCh
              01.10.2015 07:25

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


              1. Delphinum
                01.10.2015 13:11

                Идея тут в том, что если для написания теста под метод нужно три экрана кода, то что то не так с методом, а тест всего лишь лакмусовая бумажка.


              1. poxu
                01.10.2015 19:21

                Не, гораздо лучше написать тест, ужаснуться и потом уже рефакторить и метод и тест. И следить, чтобы тест не падал. Так проще.


                1. VolCh
                  01.10.2015 19:36

                  Тест на 3 экрана писать?


                  1. poxu
                    02.10.2015 08:30

                    Да, тест на 3 экрана. Который можно будет прогонять, чтобы убедиться, что всё работает и рефакторинг ничего не ломает. А потом в процессе рефакторинга добиться того, что бы этот тест стал меньше и красивей.


              1. koroandr
                02.10.2015 08:53

                Если у вас написание модульного теста занимает 3 экрана — значит, что-то не так с самим тестируемым модулем. Либо его сложно использовать (тогда и тесты, и «настоящий» код, использующий этот модуль, будет громоздким), а значит надо рефакторить интерфейс, либо сложно выделить его зависимости, а это значит у него проблемы с coupling'ом, и опять-таки модуль надо рефакторить.

                Есть одно исключение — сложно тестировать код, который не содержит логики, а содержит только связи (создание объектов, проброс событий, проксирование методов). Но ивыгода от тестирования такого кода сомнительна.


                1. VolCh
                  05.10.2015 09:44

                  На готовом проекте, который писался без тестов, скорее всего будет что-то не так с модулем. Скорее и то, и другое, и что-нибудь ещё. А без тестов рефакторить такой модуль опасно и долго. А тесты писать долго и опасно (можно не разобраться в каком-то хаке, который тесты не покроют, но будет ложная уверенность). Замкнутый круг.

                  Единственное, что я нашёл приемлемым на практике — создавать новый модуль (чаще модули, поскольку у унаследованных очень редко одна ответственность), инжектить туда старый, проксировать старые методы, писать тесты на новые аналоги с ожидаемым поведением, реализовывать эти аналоги по уму, в том числе пригодными к тестированию, потом в прокси-методе делать вызовы и того, и другого, при расхождениях отдавать старый результат и логировать аргументы и оба результата, на каждое расхождение писать новые тесты на новые аналоги, озеленять их и повторять пока расхождений не будет.


      1. Adelf
        30.09.2015 16:27

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


  1. Delphinum
    30.09.2015 15:05

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


    1. koroandr
      30.09.2015 15:11

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


      1. Delphinum
        30.09.2015 15:29

        Ну тут зависит от ситуации. Если разделение интерфейса создает две разные сущности, то это хорошо, но если разделение делается только ради тестов, а на деле исходный интерфейс достаточно High Cohesion, получаются спагетти.


      1. VolCh
        30.09.2015 18:47

        Не снижает в общем случае для зависимых модулей, но увеличивает для того, от которого зависит. Рассмотрим ситуацию после введения общего интерфейса: три модуля зависит от интерфейса (один имплементирует, два используют) — ровно три связи, у каждого модуля по одной. Разбили (пускай без наследования для имплементирующего). Теперь один имплементирует два интерфейса, а два используют по одному — 4 связи, у имплментирующего их стало две. Объединили через наследование: вроде вернулись к трём связям у модулей, но по сути их стало пять: теперь у самого общего интерфейса их ещё две — теперь при изменении разбитых интерфейсов нужно будет продираться через цепочку наследования.

        Было бы иначе, можно было бы просто каждый метод объявлять интерфейсом :) Как правило, разбиение интерфейса без планов либо разбиения его основной реализации, либо введение дополнительных имплементаций хотя бы одной части, только ухудшает код. Конечно, можно рассматривать создание моков в тестах как дополнительные имплементации, но обычно тесты не рассматриваются как полноценная часть приложения и при автоматизации тестирования по инициативе снизу аргумент «теперь так будет проще тестировать» выглядит неубедительно для верхов, которые скептически относились к инициативе, когда они увидят внезапно разросшийся код без всякого добавления функциональности.


        1. koroandr
          01.10.2015 08:02

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

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

          Есть еще один профит от разбиения на подинтерфейсы по принципу «востребованности» модулями (обратите внимание, я уже ниже писал, что один и тот же метод может встречаться в разных интерфейсах). Оно документирует код. Представьте, допустим у вас есть код, который позволяет купить корзину товаров. Теперь, пусть возникло новое требование: пользователь может в один клик купить один товар, то есть товар теперь реализует интерфейс, необходимый для «покупаемости». Но если мы не разбивали интерфейсы, то выяснить, что же нужно для «покупаемости», мы можем только прочитав код реализации. А при разделении у нас для этого автоматически есть готовый интерфейс, ICartForPurchase.


  1. Akela_wolf
    01.10.2015 07:16

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

    public interface Storage {
      public Integer getInt(String key);
      public String getString(String key);
      public void setInt(String key, Integer value);
      public void setString(String key, String value);
    };
    


    Допустим, тестируемый класс использует единственный метод — getInt для получения каких-то данных из хранилища. Будем ради облегчения тестирования разбивать интерфейс с выделением этого куска? Нет, поскольку такой интерфейс не обладает целостностью. Никто не может гарантировать, что в будущем этому же классу не потребуется доступ к строковым значениям. Или что не будет введено хранение, например, дат или XML-документов.

    Максимум на что можно разбить этот интерфейс — это выделить ReadableStorage, так как задача получения данных из хранилища без необходимости записи в него достаточно целостная и под это вполне можно выделить отдельный интерфейс.
    public interface ReadableStorage {
      public Integer getInt(String key);
      public String getString(String key);
    };
    
    public interface Storage implements ReadableStorage {
      public void setInt(String key, Integer value);
      public void setString(String key, String value);
    };
    


    Продолжать разбивать эти интерфейсы дальше, ИМХО, не только бессмысленно, но и просто опасно.

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


    1. VolCh
      01.10.2015 07:27

      Навскидку я бы разбил интерфейс по принципу строки и целые :)


      1. Akela_wolf
        01.10.2015 08:40
        +1

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


    1. koroandr
      01.10.2015 07:45

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

      Есть один маленький нюанс: разбиение на интерфейсы вида I{Module}For{OtherModule} не обязательно непересекающееся, то есть один и тот же метод может использоваться для разных подсистем. Язык позволяет сделать так при условии, что сигнатуры методов совпадают.


      1. Akela_wolf
        01.10.2015 08:43

        Принцип «используются вместе» порождает нецельные интерфейсы, поскольку клиент часто не использует все методы интерфейса одновременно. Но некоторые методы нельзя исключать из интерфейса по этому принципу, поскольку получим нецельный интерфейс, который будет затруднительно использовать при дальнейшем развитии системы, его придется дополнять. А изменение интерфейсов (по сути API класса/модуля) — это совсем не то с чем хочется иметь дело часто.


        1. koroandr
          01.10.2015 09:36

          Что значит «нецельный интерфейс»? На мой взгляд, «цельность» интерфейса и есть «связанность методов в нем по смыслу». Так что тут вопрос в том, как эту «цельность» определять.

          А изменение интерфейсов (по сути API класса/модуля) — это совсем не то с чем хочется иметь дело часто.

          А вот тут не согласен. API от этого не меняется, фасадный интерфейс остается тем же. А вот то, что в клиентском модуле появилась новая зависимость от метода, как раз и отражается в изменении интерфейса.


          1. Akela_wolf
            01.10.2015 10:01
            +1

            «цельность» интерфейса и есть «связанность методов в нем по смыслу»

            Правильно! По смыслу, а не «по используемости другими модулями»


            1. koroandr
              01.10.2015 10:16

              «Смысл» может быть разный:)
              Я писал об этом уже в этой же ветке:

              В качестве меры «связанности по смыслу» вполне можно использовать «используются вместе».


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

              И еще, для вашего примера возможно два вполне логичных разбиения: по типу данных (int/string) и по типу аксессора (get/set). Вполне может сложиться такая ситуация, что модулю нужен getInt и setString. Что тогда делать, использовать сразу два интерфейса?


              1. Akela_wolf
                01.10.2015 10:35

                Вполне может сложиться такая ситуация, что модулю нужен getInt и setString. Что тогда делать, использовать сразу два интерфейса?

                Проанализировать такой модуль, конечно же. А там — либо это оправданно (тогда передавать Storage), либо модуль надлежит переработать и разделить, так как у него недостаточно высокий cohesion


                1. koroandr
                  01.10.2015 11:16

                  Ну то, что код надо анализировать, я ни в коем случае не отрицаю, это необходимо независимо от используемых методик и паттернов :)

                  А вот передавать весь Storage ради двух методов, на мой взгляд, довольно сомнительное решение (в плане coupling'а).


              1. VolCh
                01.10.2015 11:09

                Вам не кажется, что если какой-то модуль использует набор методов другого модуля — то они связаны по смыслу?


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

                И еще, для вашего примера возможно два вполне логичных разбиения: по типу данных (int/string) и по типу аксессора (get/set)


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

                Что тогда делать, использовать сразу два интерфейса?

                Да. Но оценить перед разбиением как часто такая ситуация будет встречаться и если однозначного ответа нет, то вообще подумать об использование других методов рефакторинга.


      1. VolCh
        01.10.2015 10:04

        И тестирование, как видите, в этих рассуждениях совсем ни при чем.


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


        1. koroandr
          01.10.2015 11:28

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