Эволюционирующая архитектура и спонтанное проектирование: Проектирование на основе тестирования, часть 2

Как тесты могут помочь в развитии и улучшении архитектуры

Тестирование — это только побочный продукт разработки, основанной на тестировании (test driven development или TDD); при правильном применении TDD улучшает общее состояние кода. В этой статье из серии Эволюционирующая архитектура и спонтанное проектирование завершается обзор расширенного примера, на котором было показано, как проектные решения могут возникать из проблем, возникших при тестировании.

Нил Форд, Архитектор приложений, ThoughtWorks

Нил Форд (Neal Ford) работает архитектором приложений в ThoughtWorks, революционной компании, предоставляющей профессиональные IT-услуги и помогающей талантливым людям по всему миру эффективнее использовать программное обеспечение. Он также является проектировщиком и разработчиком приложений, учебных материалов, журнальных статей, учебных курсов, видео/DVD-презентаций и автором книг "Разработка с Delphi: Объектно-ориентированный подход", "JBuilder 3 Unleashed" и "Искусство разработки Web-приложений на Java". Он специализируется на консультациях по построению широкомасштабных корпоративных приложений. Он также является общепризнанным докладчиком и выступал на многочисленных конференциях разработчиков по всему миру. С ним можно связаться по адресу: nford@thoughtworks.com.



27.01.2012

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

Проверка качества

В листинге 1 показан тест, использующий более подходящую абстракцию на основе коллекции типа Set:

Листинг 1. Unit-тест для более удачной абстракции на основе коллекции типа Set
@Test public void add_factors() {
    Set<Integer> expected =
            new HashSet<Integer>(Arrays.asList(1, 2, 3, 6));
    Classifier4 c = new Classifier4(6);
    c.addFactor(2);
    c.addFactor(3);
    assertThat(c.getFactors(), is(expected));
}

Этот код тестирует одну из важнейших частей нашей предметной области: нахождение множителей числа. Эту функциональность необходимо очень тщательно проверить, потому что она представляет собой наиболее сложную часть проблемы, что делает её наиболее подверженной ошибкам. Однако в этом коде содержится не очень удачная конструкция: new HashSet(Arrays.asList(1, 2, 3, 6));. Даже с поддержкой современных сред разработки для ее реализации потребуется ввести много кода. Сначала надо напечатать ключевое слово new, затем ввести Has и дать оболочке понять и закончить тип коллекции; потом также ввести <Int и опять позволить оболочке завершить тип аргументов коллекции. Эту конструкцию можно (и нужно) упростить.

Об этой серии статей

Цель этой серии - показать новые точки зрения на часто обсуждаемые, но до сих пор не решённые проблемы в области архитектуры и проектирования ПО. На конкретных примерах Нил Форд поможет приобрести базовые знания по гибким (agile) методикам: эволюционирующая архитектура (evolutionary architecture) и спонтанное проектирование (emergent design). Отложив принятие важных решений об архитектуре и проектировании ПО до последнего момента, можно избежать ненужной сложности, оказывающей негативное влияние на проект.

"Сырые" тесты

Одно из правил написания хорошего кода представлено в книге The Pragmatic Programmer, написанной Энди Хантом и Дэйвом Томасом (см. раздел Ресурсы). Это принцип "сухости" - DRY (Don't Repeat Yourself! / Не повторяйтесь!). Этот принцип рекомендует удалять из кода все повторяющиеся фрагменты, так как они часто приводят к проблемам. Однако DRY-принцип неприменим к unit-тестам. Unit-тестам часто приходится проверять нюансы поведения тестируемого кода, что ведет к похожим или повторяющимся ситуациям. Хороший пример этого - скопированный и вставленный код для создания ожидаемого результата в листинге 1 (new HashSet(Arrays.asList(1, 2, 3, 6))); возможно, в различных тестах потребуются другие варианты этой же конструкции.

Эмпирическое правило, которое можно применить к TDD, гласит: "тесты должны быть сырые, но не промокшие". Это значит, что повторение некоторых фрагментов в тестах допускается (и неизбежно), но не стоит сходить с "истинного пути" и создавать некрасивые, дублирующие друг друга тесты. Поэтому можно переделать тест, добавив вспомогательный private-метод, чтобы реализовать стандартную функциональность для создания нужных объектов; как показано в листинге 2:

Листинг 2. Вспомогательный метод для "увлажнения" теста
private Set<Integer> expectationSetWith(Integer... numbers) {
    return new HashSet<Integer>(Arrays.asList(numbers));
}

Благодаря коду из листинга 2 тесты на нахождение множителей становятся более понятными, как показано в исправленном тесте из листинга 1 (см. листинг 3):

Листинг 3. Более "сырой" тест для проверки правильности нахождения множителей числа
@Test public void factors_for_6() {
    Set<Integer> expected = expectationSetWith(1, 2, 3, 6);
    Classifier4 c = new Classifier4(6);
    c.calculateFactors();
    assertThat(c.getFactors(), is(expected));
}

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

Граничные условия

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

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

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

Листинг 4. Граничные условия для разложения на множители
@Test public void factors_for_100() {
    Classifier5 c = new Classifier5(100);
    c.calculateFactors();
    assertThat(c.getFactors(),
            is(expectationSetWith(1, 100, 2, 50, 4, 25, 5, 20, 10)));
}

@Test(expected = InvalidNumberException.class)
public void cannot_classify_negative_numbers() {
    new Classifier5(-20);
}

@Test public void factors_for_max_int() {
    Classifier5 c = new Classifier5(Integer.MAX_VALUE);
    c.calculateFactors();
    assertThat(c.getFactors(), is(expectationSetWith(1, 2147483647)));
}

Число 100 интересно тем, что у него много множителей. С помощью запуска тестов для различных чисел было установлено, что нет смысла специально выделять в предметной области отрицательные числа, поэтому был написан тест (который на самом деле до исправления не выполнялся) для исключения отрицательных чисел. Мысли об отрицательных числах привели к мыслям о значении MAX_INT: должна ли программа учитывать, что пользователю могут понадобиться long-числа? Изначально предполагалось, что диапазон данных будет ограничен числами типа integer, но необходимо убедиться, что это действительно так.

Предположим, что имеется некая картина, содержащая 2 миллиона пикселов. Что будет, если сжать ее до изображения, которое будет состоять из 2000 пикселов? Будет ли она выглядеть так же? (Возможно, если это известная картина Малевича, но это редкий случай.) Сжатие с помощью удаления информации - это алгоритм сжатия с потерями. Если взять сжатую версию и попытаться восстановить её до 2 миллионов пикселей, необходимо будет проделать некоторые восстановительные действия. Иногда это будут правильные действия, но не всегда.

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

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

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

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

"Положительные" и "отрицательные" тесты

Перед началом исследования я разделил проблему на несколько подзадач. По мере написания тестов удалось обнаружить еще одну подзадачу. Ниже приведён полный список:

  1. найти множители для заданного числа;
  2. определить, является ли число множителем;
  3. определить, как добавить множитель в список множителей;
  4. просуммировать множители;
  5. определить, является ли число совершенным.

Две оставшиеся подзадачи — это суммирование множителей и проверка, того, является ли число совершенным. С этими подзадачами не произошло никаких неожиданностей; два последних теста находится в листинге 5:

Листинг 5. Два последних теста для поиска совершенных чисел
@Test public void sum() {
    Classifier5 c = new Classifier5(20);
    c.calculateFactors();
    int expected = 1 + 2 + 4 + 5 + 10 + 20;
    assertThat(c.sumOfFactors(), is(expected));
}

@Test public void perfection() {
    int[] perfectNumbers = 
        new int[] {6, 28, 496, 8128, 33550336};
    for (int number : perfectNumbers)
        assertTrue(classifierFor(number).isPerfect());
}

После сверки первых совершенных чисел с Wikipedia можно написать тест, который проверяет, что программа действительно может найти совершенные числа. Но это ещё не всё. Прохождение "положительного" теста (проверяющего, что программа может работать правильно) - это только половина дела. Также необходимо убедиться, что несовершенные числа случайно не будут причислены к совершенным. Для этого случая надо написать "отрицательный" тест (проверяющий, что программа не может работать неправильно), который показан в листинге 6:

Листинг 6. Отрицательный тест, проверяющий, что определение совершенности числа работает правильно
@Test public void test_a_bunch_of_numbers() {
    Set<Integer> expected = new HashSet<Integer>(
            Arrays.asList(PERFECT_NUMS));
    for (int i = 2; i < 33550340; i++) {
        if (expected.contains(i))
            assertTrue(classifierFor(i).isPerfect());
        else
            assertFalse(classifierFor(i).isPerfect());
    }
}

Этот код говорит о том, что алгоритм определения совершенных чисел работает правильно, но очень медленно. Можно догадаться почему, если посмотреть на метод calculateFactors() из листинга 7:

Листинг 7. Простая реализация метода getFactors().
public void calculateFactors() {
    for (int i = 2; i < _number; i++)
        if (isFactor(i))
            addFactor(i);
}

В листинге 7 имеется такая же проблема, как в версии кода из первой части, написанной до тестов: код, собирающий множители, проходит весь путь до самого числа. Можно улучшить алгоритм, собирая множители по парам, что позволит анализировать числа только до квадратного корня из исходного числа, как показано в исправленной версии в листинге 8:

Листинг 8. Исправленная версия метода calculateFactors() с лучшей производительностью.
public void calculateFactors() {
    for (int i = 2; i < sqrt(_number) + 1; i++)
        if (isFactor(i))
            addFactor(i);
}

public void addFactor(int factor) {
    _factors.add(factor);
    _factors.add(_number / factor);
}

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

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

Теперь версия программы для поиска совершенных чисел, основанная на тестах, готова. Полный код класса показан в листинге 9:

Листинг 9. Полная версия классификатора чисел, разработанного на основе TDD
public class Classifier6 {
    private Set<Integer> _factors;
    private int _number;

    public Classifier6(int number) {
        if (number < 1)
            throw new InvalidNumberException(
            "Can't classify negative numbers");
        _number = number;
        _factors = new HashSet<Integer>();
        _factors.add(1);
        _factors.add(_number);
    }

    private boolean isFactor(int factor) {
        return _number % factor == 0;
    }

    public Set<Integer> getFactors() {
        return _factors;
    }

    private void calculateFactors() {
        for (int i = 2; i < sqrt(_number) + 1; i++)
            if (isFactor(i))
                addFactor(i);
    }

    private void addFactor(int factor) {
        _factors.add(factor);
        _factors.add(_number / factor);
    }

    private int sumOfFactors() {
        int sum = 0;
        for (int i : _factors)
            sum += i;
        return sum;
    }

    public boolean isPerfect() {
        calculateFactors();
        return sumOfFactors() - _number == _number;
    }
}

Составные методы

Одно из преимуществ кода, разработанного на основе тестов, упоминаемое в первой части, — это модульность (composability), основанная на шаблоне "составной метод" Кента Бека (Kent Beck) (см. раздел Ресурсы). Составной метод предполагает сборку программного обеспечения из множества связанных методов. TDD способствует этому, т.к. для тестирования необходимо иметь небольшие фрагменты функциональности. Шаблон "составной метод" также помогает в проектировании, так как он генерирует фрагменты, пригодные для стандартного использования.

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

  • isFactor()
  • getFactors()
  • calculateFactors()
  • addFactor()
  • sumOfFactors()
  • isPerfect()

Ниже приведён пример преимуществ использования шаблона "составной метод". Допустим, ваша команда программистов написала программу для поиска совершенных чисел на основе TDD, а другая команда написала такую же программу, однако добавила тесты уже после написания основного кода (пример приведён в первой части). Теперь к вам приходят пользователи и заявляют: "Нам также требуется определять избыточность и недостаточность числа!" В избыточном числе сумма множителей больше, чем само число, а в недостаточном числе сумма множителей меньше самого числа.

Для программы, где тесты добавлялись в конце и вся логика находится в одном большом методе, потребуется переписать всё решение, выделив в коде аспекты, общие для определения избыточности, недостаточности и совершенности числа. В TDD-версии потребуется дописать два новых метода, показанных в листинге 10:

Листинг 10. Поддержка определения избыточности и недостаточности чисел
public boolean isAbundant() {
    calculateFactors();
    return sumOfFactors() - _number > _number;
}

public boolean isDeficient() {
    calculateFactors();
    return sumOfFactors() - _number < _number;
}

Последнее, что осталось сделать для этих двух методов – это перенести вызов метода calculateFactors() в конструктор класса. (Этого не требовалось, когда у нас был один метод isPerfect(), но сейчас этот код повторяется в трёх методах и потому должен быть переделан.)

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


Измерение качества кода

В первой части серии заявлялось, что TDD-версия кода будет объективно лучше, чем версия, в которую тесты были добавлены после окончания разработки. Уже было показано достаточное количество практических свидетельств, но как насчёт конкретных доказательств? Конечно, абсолютно объективных метрик для оценки качества кода не существует, но некоторые метрики все же можно использовать для оценки определенных составляющих качества кода; одна из них – цикломатическая сложность (cyclomatic complexity) (см. раздел Ресурсы), введенная Томасом Маккейбом (Thomas McCabe) для измерения сложности кода. Формула достаточно проста: число ветвей минус число узлов плюс 2, где ветви представляют собой путь исполнения, а узлы – строки кода. Рассмотрим код из листинга 11:

Листинг 11. Простой Java-метод для определения цикломатической сложности
public void doit() {
    if (c1) {
        f1();
    } else {        
        f2();
    }
    if (c2) {
        f3();
    } else {
        f4();
    }
}

Если схематически изобразить метод из листинга 11 в виде блок-схемы, можно легко сосчитать число ветвей и узлов и вычислить цикломатическую сложность, как показано на рисунке 1. Цикломатическая сложность этого метода равна 3 (8 – 7 + 2).

Рисунок 1. Узлы и ветви метода doit()
Рисунок 1. Узлы и ветви метода doit()

Чтобы измерить цикломатическую сложность обеих версий программы для поиска совершенных чисел, мы используем бесплатный инструмент для измерения цикломатической сложности Java-программ - JavaNCSS ("NCSS" означает "незакомментированные выражения в коде" ("non-commenting source statements"), которые этот инструмент тоже может подсчитывать). В разделе Ресурсы есть ссылка для загрузки этого инструмента.

Запуск JavaNCSS для кода, тесты к которому изначально отсутствовали, выдаёт результаты, показанные на рисунке 2:

Рисунок 2. Цикломатическая сложность программы, не использующей принципы TDD
Рисунок 2. Цикломатическая сложность программы, не использующей принципы TDD

В этой версии только один метод, и JavaNCSS сообщает, что методы класса в среднем содержат 13 строк кода, а цикломатическая сложность равна 5.00. Теперь можно сравнить это с результатом версии, использующей TDD, показанным на рисунке 3:

Рисунок 3. Цикломатическая сложность программы, использующей TDD
Рисунок 3. Цикломатическая сложность программы, использующей TDD

TDD-версия включает в себя намного больше методов, в среднем длиной в 3.56 строки кода, со средней цикломатической сложностью всего 1.56. Если воспользоваться этим измерением, то TDD-версия более чем в три раза проще версии, разработанной без TDD. Даже для такой маленькой задачи - это большая разница.


Заключение

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

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

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

Ресурсы

Научиться

  • Evolutionary architecture and emergent design: Test-driven design, Part 2: оригинал статьи (EN).
  • Test-driven design, Part 1(Нил Форд, developerWorks, февраль 2009 г.) : первая статья этой серии.
  • The Pragmatic Programmer (Энди Хант и Дэйв Томас (Andy Hunt and Dave Thomas), Pragmatic Programmers, 2001): Принцип DRY, выдвинутый в этой книге, можно применять не только для TDD.
  • Perfect number (EN): статья в Wikipedia, рассматривающая совершенные числа с математической точки зрения.
  • Test-Driven Development By Example (Кент Бек (Kent Beck), Addison-Wesley, 2003) (EN): в этой статье Кент Бек, создатель экстремального программирования, объясняет принципы TDD на примере из финансовой области.
  • Smalltalk Best Practice Patterns (Кент Бек, Prentice Hall, 1996) (EN): дополнительная информация о шаблоне составной метод.
  • In pursuit of code quality: Monitoring cyclomatic complexity (Эндрю Гловер (Andrew Glover), developerWorks, март 2006 г.) (EN): статья об использовании простых метрик кода и Java-инструментов для измерения цикломатической сложности.

Получить продукты и технологии

  • JavaNCSS: загрузить JavaNCSS – инструмент для измерения метрик кода.

Комментарии

developerWorks: Войти

Обязательные поля отмечены звездочкой (*).


Нужен IBM ID?
Забыли Ваш IBM ID?


Забыли Ваш пароль?
Изменить пароль

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Профиль создается, когда вы первый раз заходите в developerWorks. Информация в вашем профиле (имя, страна / регион, название компании) отображается для всех пользователей и будет сопровождать любой опубликованный вами контент пока вы специально не укажите скрыть название вашей компании. Вы можете обновить ваш IBM аккаунт в любое время.

Вся введенная информация защищена.

Выберите имя, которое будет отображаться на экране



При первом входе в developerWorks для Вас будет создан профиль и Вам нужно будет выбрать Отображаемое имя. Оно будет выводиться рядом с контентом, опубликованным Вами в developerWorks.

Отображаемое имя должно иметь длину от 3 символов до 31 символа. Ваше Имя в системе должно быть уникальным. В качестве имени по соображениям приватности нельзя использовать контактный e-mail.

Обязательные поля отмечены звездочкой (*).

(Отображаемое имя должно иметь длину от 3 символов до 31 символа.)

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Вся введенная информация защищена.


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=40
Zone=Технология Java
ArticleID=789918
ArticleTitle=Эволюционирующая архитектура и спонтанное проектирование: Проектирование на основе тестирования, часть 2
publish-date=01272012