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

Тесты управляют проектированием и улучшают дизайн

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

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

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



23.10.2009

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

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

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

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

Рабочий процесс TDD

Ключевым словом в определении "разработка через тестирование" является слово "через", указывающее, что именно тестирование управляет процессом разработки. Рабочий процесс TDD показан на рисунке 1:

Рисунок 1. Рабочий процесс TDD
Рабочий процесс TDD

Рабочий процесс, показанный на рисунке 1, включает следующие шаги:

  1. Написание теста, на котором написанный код дает ошибку.
  2. Написание кода, с которым тест выполняется.
  3. Повторение шагов 1 и 2.
  4. Попутный активный рефакторинг.
  5. Если придумывать новые тесты больше не удается, процесс завершен.

TDD против пост-тестов (test after)

Принцип разработки через тестирование требует, чтобы тесты появлялись первыми. Только после того как тесты написаны (и не пройдены кодом), пишется код под этот тест. Многие разработчики используют разновидность тестирования, называемую пост-тестированием (test-after development, TAD), когда сначала пишется код, а потом юнит-тесты. В этом случае присутствуют тесты, но отсутствуют аспекты стихийного проектирования TDD. Ничто не мешает написать уродливый код и затем пребывать в недоумении, не зная, как его протестировать. В код, который пишется сначала, вы вкладываете своё заранее выработанное представление о том, как этот код будет работать, а затем — тестируете его. TDD требует ровно противоположного: сначала должен быть написан тест, который даст вам понять, как написать код, который пройдёт тест успешно. Чтобы проиллюстрировать это важное отличие, я дам расширенный пример.


Совершенные числа

Чтобы показать преимущества дизайна TDD, нам необходима проблема для решения. В своей книге Разработка через тестирование (см. Ресурсы), Кент Бек (Kent Beck) использует для примера пересчет валют — вполне годная, но слегка упрощенная иллюстрация для TDD. По-настоящему сложная задача — это найти пример, не настолько запутанный, чтобы затеряться в предметной области, но и достаточно сложный, чтобы выявить подлинный потенциал метода.

Для этой цели я выбрал совершенные числа. Для тех, кто не силен в основах математики, напомню, что это понятие концепция восходит к доэвклидовым временам (Эвклид вывел одно из самых ранних доказательств существования совершенных чисел). Совершенное число — это число, равное сумме всех своих собственных делителей. Например, 6 является совершенным числом, потому что делители 6 (исключая само 6) — это 1, 2, и 3, а сумма 1 + 2 + 3 равна 6. Более алгоритмическим определением совершенного числа будет "число, равное сумме всех своих собственных делителей (т. е. всех положительных делителей, отличных от самого числа)". Здесь цепочка вычислений такова: 1 + 2 + 3 + 6 - 6 = 6.

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

Для этого примера я реализую программу, находящую совершенные числа, на языке Java (версии 5 или новее, потому что в тестах будут использоваться комментарии), использую JUnit 4.x (самой новой версии) и сопоставители Hamcrest из кода Google (см. Ресурсы), представляющие "синтаксический сахар" с человеческим лицом поверх стандартного JUnit. Например, вместо assertEquals(expected, actual) можно написать assertEquals(actual, is(expected)), что читается более осмысленно. Сопоставители Hamcrest поставляются вместе с JUnit 4.x (в виде статического импорта); если вы все еще используете JUnit 3.x, то можно скачать совместимую версию.

Пост-тест (Test after)

В листинге 1 показана первая версия PerfectNumberFinder:

Листинг 1. PerfectNumberFinder для пост-тестирования
public class PerfectNumberFinder1 {
    public static boolean isPerfect(int number) {
        // получим делители
        List<Integer> factors = new ArrayList<Integer>();
        factors.add(1);
        factors.add(number);
        for (int i = 2; i < number; i++)
            if (number % i == 0)
                factors.add(i);

        // просуммируем делители
        int sum = 0;
        for (int n : factors)
            sum += n;

        // проверим, совершенное ли число
        return sum - number == number;
    }
}

Ничего особенного, но свою работу этот код выполняет. Сначала создается динамический список всех делителей (ArrayList), куда добавляется единица и конечное число (придерживаемся вышеприведенной формулы, так что список всех делителей включает единицу и само число). Затем перебираются все кандидаты на делители вплоть до самого числа, с проверкой на каждом шаге, является ли число делителем на самом деле. Если это так, число добавляется в список. Далее суммируем найденные числа и в конечном итоге имеем выражение для определения совершенности числа, указанное выше, записанное на языке Java.

Теперь мне необходим юнит-тест для пост-тестирования, чтобы выяснить, работает программа или нет. Нужны как минимум два теста: один, чтобы видеть, что совершенные числа определяются верно, и другой, чтобы предотвратить ложные срабатывания. Юнит-тесты показаны в листинге 2:

Листинг 2. Юнит-тесты для PerfectNumberFinder
public class PerfectNumberFinderTest {
    private static Integer[] PERFECT_NUMS = {6, 28, 496, 8128, 33550336};

    @Test public void test_perfection() {
        for (int i : PERFECT_NUMS)
            assertTrue(PerfectNumberFinder1.isPerfect(i));
    }

    @Test public void test_non_perfection() {
        List<Integer>expected = new ArrayList<Integer>(
                Arrays.asList(PERFECT_NUMS));
        for (int i = 2; i < 100000; i++) {
            if (expected.contains(i))
                assertTrue(PerfectNumberFinder1.isPerfect(i));
            else
                assertFalse(PerfectNumberFinder1.isPerfect(i));
        }
    }

    @Test public void test_perfection_for_2nd_version() {
        for (int i : PERFECT_NUMS)
            assertTrue(PerfectNumberFinder2.isPerfect(i));
    }

    @Test public void test_non_perfection_for_2nd_version() {
        List<Integer> expected = new ArrayList<Integer>(Arrays.asList(PERFECT_NUMS));
        for (int i = 2; i < 100000; i++) {
            if (expected.contains(i))
                assertTrue(PerfectNumberFinder2.isPerfect(i));
            else
                assertFalse(PerfectNumberFinder2.isPerfect(i));
        }
        assertTrue(PerfectNumberFinder2.isPerfect(PERFECT_NUMS[4]));
    }
}

Зачем "_" в именах тестов?

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

Этот код правильно выводит совершенные числа, но из-за большого количества проверяемых чисел работает очень медленно на негативных тестах. Вопросы быстродействия, всплывшие из юнит-тестов, возвращают меня к коду: что ещё можно подправить? Сейчас, когда я выбираю делители, цикл всегда идет до целевого числа включительно. Но нужно ли так далеко заходить? Нет, если делители можно выбирать парами. Все делители ходят парами (например, если для целевого числа 28 найден делитель 2, то можно взять и 14). Поэтому количество необходимых шагов будет равно квадратному корню из числа. Ввиду этого улучшаем алгоритм и рефакторим код до получения показанного в листинге 3:

Листинг 3. Улучшенная версия алгоритма
public class PerfectNumberFinder2 {
    public static boolean isPerfect(int number) {
        // получим делители
        List<Integer> factors = new ArrayList<Integer>();
        factors.add(1);
        factors.add(number);
        for (int i = 2; i <= sqrt(number); i++)
            if (number % i == 0) {
                factors.add(i);
                factors.add(number / i);
            }

        // просуммируем делители
        int sum = 0;
        for (int n : factors)
            sum += n;

        // проверим, совершенное ли число
        return sum - number == number;
    }
}

Этот код отрабатывает за достойное время, но проваливает пару тестов. Оказывается, при выборке чисел парами мы учитываем квадратный корень дважды, если исходное число является полным квадратом. Например, для числа 16 квадратным корнем будет 4, и оно добавляется в список дважды. Это легко исправляется защитным условием, как показано в листинге 4:

Листинг 4. Исправленный улучшенный алгоритм
for (int i = 2; i <= sqrt(number); i++)
    if (number % i == 0) {
        factors.add(i);
        if (number / i !=  i)
            factors.add(number / i);
    }

Итак, у нас есть пост-тестовая версия для определения совершенных чисел. Код работает, но тут и там торчат "хвосты" проблемного дизайна. Во-первых, код разделен на секции комментариями. Это недвусмысленный «запашок» дурного кода — код вопиет о рефакторинге на отдельные методы. Последнее добавление, возможно, требует комментария, разъясняющего назначение нашего защитного условия, но я пока не буду этим заниматься. Самая большая проблема кроется в размере. Мое эмпирическое правило для проектов на Java гласит, что не должно быть методов длиннее десяти строк. Если это количество превышено, то наверняка метод решает больше одной проблемы, чего следует избегать. Этот код очевидно нарушает мое правило, потому я сделаю следующую попытку, на этот раз используя TDD.


Стихийное проектирование через TDD

Мантра кодирования через TDD такова: "Дайте мне простейшее действие, для которого можно написать тест". Можно ли в данном случае выбрать вопрос "является ли совершенным данное число?". Нет, вопрос поставлен слишком общо. Необходимо разобрать проблему и подумать, что значит "совершенное число". Несколько шагов, необходимых для обнаружения совершенного числа, формулируются легко:

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

Возвращаясь к идее простейшего действия, какой из элементов списка выглядит самым простым? Полагаю, это выяснение того, является ли число делителем, поэтому вот мой первый тест, показанный в листинге 5:

Листинг 5. Тест "Это число — делитель?"
public class Classifier1Test {

    @Test public void is_1_a_factor_of_10() {
        assertTrue(Classifier1.isFactor(1, 10));
    }
}

Этот тест прост до примитивности, именно то, что мне надо. Чтобы скомпилировать этот код, нужен класс Classifier1 с методом isFactor()Поэтому необходимо создать каркасную структуру класса, прежде чем мы получим хотя бы ошибочный результат. Написание предельно примитивных тестов позволяет создать структуру до того, как вы начнете хоть сколько-нибудь серьёзно размышлять собственно над предметной областью. Я хочу в каждый момент времени обдумывать только одну проблему, а это даёт возможность работать над каркасом, не беспокоясь о нюансах решаемой задачи. Скомпилировав тест и добившись того, чтобы он срабатывал на ошибку, я могу наконец написать код, показанный в листинге 6:

Листинг 6. Первый проход метода "Делитель?"
public class Classifier1 {
    public static boolean isFactor(int factor, int number) {
        return number % factor == 0;
    }
}

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

Листинг 7. Следующий тест: делители числа
@Test public void factors_for() {
    int[] expected = new int[] {1};
    assertThat(Classifier1.factorsFor(1), is(expected));
}

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

Листинг 8. Простой метод factorsFor()
public static int[] factorsFor(int number) {
    return new int[] {number};
}

Этот метод хоть и работает, но намертво тормозит мое продвижение. Казалось правильным сделать метод isFactor() статическим, т.к. он всего лишь возвращает данные на основе входной информации. Однако теперь я сделал статическим еще и метод factorsFor(), а, значит, необходимо передавать обоим методам параметр number. Код становится чрезмерно процедурным — побочный эффект избыточной статики. Чтобы это исправить, я проведу рефакторинг двух имеющихся методов, что нетрудно из-за небольшого объёма кода. Реструктурированный класс Classifier представлен в листинге 9:

Листинг 9. Улучшенный класс Classifier class
public class Classifier2 {
    private int _number;

    public Classifier2(int number) {
        _number = number;
    }

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

Я сделал число членом класса Classifier2, что позволило избежать его передачи в виде параметра множеству статических методов.

Следующая задача в моем списке декомпозиции говорит, что нужно найти делители для числа. Таким образом, следующий тест должен проверить это условие (см. листинг 10):

Листинг 10. Следующий тест: делители числа
@Test public void factors_for_6() {
    int[] expected = new int[] {1, 2, 3, 6};
    Classifier2 c = new Classifier2(6);
    assertThat(c.getFactors(), is(expected));
}

Теперь я попытаюсь реализовать метод, возвращающий массив делителей для заданного параметра (листинг 11):

Листинг 11. Первый проход метода getFactors()
public int[] getFactors() {
    List<Integer> factors = new ArrayList<Integer>();
    factors.add(1);
    factors.add(_number);
    for (int i = 2; i < _number; i++) {
        if (isFactor(i))
            factors.add(i);
    }
    int[] intListOfFactors = new int[factors.size()];
    int i = 0;
    for (Integer f : factors)
        intListOfFactors[i++] = f.intValue();
    return intListOfFactors;
}

Этот код успешно проходит тест, но, если подумать, он ужасен! Такое иногда случается при реализации кода с помощью тестов. Что страшного в этом коде? Во-первых, он длинный и запутанный и страдает от проблемы "больше, чем что-то одно". Инстинкт подсказал мне возвращать int[], но это сильно усложнило фундамент и ничего нам не принесло. Начать думать о подгонке под будущие методы, которые могут вызывать существующий — значит заведомо вставать на скользкую дорожку. Нужны очень веские причины для того, чтобы добавить нечто настолько сложное в этот стык, а таких обоснований у нас сейчас нет. Глядя на код, можно предположить, что, возможно, множители также должны входить во внутреннее состояние класса, что позволит разделить на части функциональность этого метода.

Одна из полезных особенностей, которые порождают тесты — это по-настоящему связные методы. Кент Бек (Kent Beck) написал об этом в своей авторитетной книге Smalltalk Best Practice Patterns (см. Ресурсы). В этой книге Кент вводит шаблон под названием комбинированный метод. Шаблон этого метода содержит три ключевых утверждения:

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

Комбинированный метод — это одна из полезных особенностей дизайна, проистекающего из TDD, и я явно нарушил его в методе getFactors() из листинга 11. Исправить это можно, проделав следующие шаги:

  1. Перевести factors во внутреннее состояние.
  2. Переместить инициализацию factors в конструктор.
  3. Избавиться от драгоценного преобразования в int[] (подумаем о нем позже, если оно потребуется).
  4. Добавить еще один тест для addFactors().

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

Отложим на некоторое время серьёзную проблему с getFactors() и возьмёмся за другую, менее важную. Итак, вот следующий тест addFactors() (показан в листинге 12):

Листинг 12. Тест для addFactors()
@Test public void add_factors() {
    Classifier3 c = new Classifier3(6);
    c.addFactor(2);
    c.addFactor(3);
    assertThat(c.getFactors(), is(Arrays.asList(1, 2, 3, 6)));
}

Код теста, представленный в листинге 13 — сама простота:

Листинг 13. Простой код для добавления делителей
public void addFactor(int factor) {
    _factors.add(factor);
}

Запускаю мой юнит-тест в полной уверенности, что увижу зеленую полоску, но он проваливается! Как может провалиться такой простой тест? Главная причина показана на рисунке 2:

Рисунок 2. Основная причина проваленного юнит-теста
Основная причина проваленного юнит-теста

Мы ожидали списка со значениями 1, 2, 3, 6, но полученный результат выдал 1, 6, 2, 3. Ах, так это потому что мы изменили код в сторону добавления 1 и самого числа в конструкторе! Одним из решений этой проблемы будет всегда записывать мои ожидаемые предположения с учётом того, что 1 и число всегда будут первыми. Но правильное ли это решение? Нет. Проблема лежит много глубже. Являются ли делители списком чисел? Нет, это набор чисел. Мое первое (неверное) предположение привело к использованию списка из целых чисел для делителей, но это плохое абстрагирование. Перестроив код на использование наборов вместо списков, мы также улучшили решение в целом, прибегнув к более точному абстрагированию.

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


Заключение

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

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

Ресурсы

Научиться

  • Оригинал статьи Evolutionary architecture and emergent design: Test-driven design, Part 1 (EN).
  • Hamcrest matchers: Библиотека сопоставителей объектов, позволяющая декларативно задавать "match" при использовании в других программных инфраструктурах. (EN)
  • Test-Driven Development (Kent Beck, Addison-Wesley, 2003): Кент Бек, создатель экстремального программирования, разъясняет принцип TDD на примере с деньгами. (EN)
  • Smalltalk Best Practice Patterns (Kent Beck, Prentice Hall, 1996): Узнайте больше о шаблоне composed method. (EN)
  • The Productive Programmer (Neal Ford, O'Reilly Media, 2008): Более развернутая версия примера из этой статьи рассмотрена в главе "Test Driven Development" новой книги Нила Форда. (EN)
  • "Emergent Optimization in Test Driven Design" (Michael Feathers): как тестирование помогает избежать преждевременной оптимизации. (EN)
  • Книжный магазин Safari: книги по этой и другим техническим тематикам. (EN)
  • Раздел Java-технологий на developerWorks: сотни статей по всем аспектам программирования на Java.

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

  • JUnit: Скачайте JUnit.(EN)

Обсудить

Комментарии

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=438858
ArticleTitle=Эволюционирующая архитектура и стихийное проектирование: Проектирование через тестирование, Часть 1
publish-date=10232009