Рефакторинг унаследованного кода: Часть 4 - Наш первый модульный тест

Содержание цикла статей "Рефакторинг унаследованного кода":

Часть 1 - Золотой мастер
Часть 2 - Магические строки и константы
Часть 3 - Сложные условия
Часть 4 - Наш первый модульный тест

Старый код. Плохой код. Сложный код. Запутанный код. Полная ерунда. Два слова - унаследованный код. Это серия статей поможет вам работать с ним.

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

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

Что такое модульный тест?

На протяжении всего периода развития автоматизированного тестирования последние лет двадцать или около того термин модульный тест определялся по-разному.

Первоначально речь шла о диапазоне кода, выполняемом внутри теста. Модульный тест определялся, как тест, который тестирует наименьшие возможные единицы конкретного языка программирования.

В этом смысле, для нашего PHP-кода модульное тестирование - это тест, который выполняет одну функцию или метод. Когда мы создаем объектно-ориентированный код, наши функции организованы в классы. Все тесты, связанные с одним классом, как правило, называют кейсом теста.

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

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

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

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

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

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

Определение кода, который будет тестироваться

Поиск изолированных методов

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

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

Мы должны проанализировать три файла, чтобы определить, что мы можем проверить, а что нет.

GameRunner.php в принципе не содержит никакой логики. Мы создали его просто, чтобы делегировать функции. Можем ли мы его проверить? Конечно, мы могли бы. Стоит ли нам это делать? Нет, не стоит.

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

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

Кроме того, она не возвращает значение, она просто выводит результат на экран, так что нам потребовалось бы захватить этот результат и сравнить строки. Это не то, что предполагают каноны модульного тестирования. В то же время, isCurrentAnswerCorrect() возвращает простое значение ложь-истина на основе некоторых условий. Можем ли мы проверить ее?

function isCurrentAnswerCorrect() {
    $minAnswerId = 0;
    $maxAnswerId = 9;
    $wrongAnswerId = 7;
    return rand($minAnswerId, $maxAnswerId) != $wrongAnswerId;
}

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

Шаг 1 - в файле GoldenMasterTest.php отмечаем все тесты, как не принимаемые во внимание. Мы не хотим запускать их в настоящий момент. Так как мы начинаем создавать модульные тесты, теперь мы будем запускать наш золотой мастер все реже и реже. Так как мы пишем новые тесты, и не изменяем рабочий код, нам более важны быстрые результаты тестов.

Шаг 2 - в папке Test создаем новый тестовый файл RunnerFunctionsTest.php, рядом с файлом GoldenMasterTest.php. Теперь подумайте о том, какой наипростейший тестовый код вы можете написать. Какой минимум нужен для того, чтобы он запускался? Ну, что-то вроде этого:

require_once __DIR__ . '/../trivia/php/RunnerFunctions.php';

class RunnerFunctionsTest extends PHPUnit_Framework_TestCase {

    function testItCanFindCorrectAnswer() {

    }

}

Мы обращаемся к файлу RunnerFunctions.php, так что убедитесь, что он может быть включен и не выдает ошибку. Остальная часть кода является чистым шаблоном, просто каркас класса и пустая тестовая функция. Но что теперь?

Что мы будем делать дальше? Вы знаете, как мы можем обмануть rand(), чтобы она возвращала то, что мы хотим? Я пока не знаю. Так что давайте исследуем, как она работает в данный конкретный момент.

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

function testItCanFindCorrectAnswer() {

    srand(0);
    var_dump(rand(0,9));
    srand(1);
    var_dump(rand(0,9));
    srand(2);
    var_dump(rand(0,9));
    srand(3);
    var_dump(rand(0,9));
    srand(4);
    var_dump(rand(0,9));

}

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

int(8)
int(8)
int(7)
int(5)
int(9)

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

Зависимости и включение зависимости

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

Но что, если мы немного обобщим это определение. Забудем о классах, забудем об объектах, сосредоточимся только на значении «зависимости». От чего зависит метод rand(min,max)? От двух значений: минимума и максимума.

Можем ли мы контролировать rand() через эти два параметра? Не будет ли предопределен результат метода, если минимальное и максимальное значение это одно и то же число? Давайте посмотрим:

function testItCanFindCorrectAnswer() {

    var_dump(rand(0,0));
    var_dump(rand(1,1));
    var_dump(rand(2,2));
    var_dump(rand(3,3));
    var_dump(rand(4,4));

}

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

int(0)
int(1)
int(2)
int(3)
int(4)

Мне кажется это достаточно предсказуемо. Задавая одно и то число в качестве минимального и максимального параметра для rand(), мы можем быть уверены, что метод сгенерирует ожидаемое число. Теперь, как нам сделать это для нашей функции? У нее нет параметров!

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

function isCurrentAnswerCorrect($minAnswerId = 0, $maxAnswerId = 9) {
    $wrongAnswerId = 7;
    return rand($minAnswerId, $maxAnswerId) != $wrongAnswerId;
}

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

Теперь мы можем протестировать isCurrentAnswerCorrect(), просто задав десять значений для каждого возможного числа, возвращаемого методом rand():

function testItCanFindCorrectAnswer() {

    $this->assertTrue(isCurrentAnswerCorrect(0,0));
    $this->assertTrue(isCurrentAnswerCorrect(1,1));
    $this->assertTrue(isCurrentAnswerCorrect(2,2));
    $this->assertTrue(isCurrentAnswerCorrect(3,3));
    $this->assertTrue(isCurrentAnswerCorrect(4,4));
    $this->assertTrue(isCurrentAnswerCorrect(5,5));
    $this->assertTrue(isCurrentAnswerCorrect(6,6));
    $this->assertFalse(isCurrentAnswerCorrect(7,7));
    $this->assertTrue(isCurrentAnswerCorrect(8,8));
    $this->assertTrue(isCurrentAnswerCorrect(9,9));

}

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

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

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

Результаты тестов выдаются настолько часто, что эти люди могут практически со 100-процентной уверенностью сказать: если что-то пошло не так - ошибка, скорее всего, в последней строке кода или команде, над которой они работали.

Хотя это может показаться уже экстремальной разработкой через тестирование, я считаю, что это весьма полезно. Особенно при создании алгоритмов.

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

Однако вернемся к нашим баранам. Этот тест с десятью операторами исполняется за 66 мс, около 6,6 мс на один оператор. Каждый оператор вызывает и выполняет часть кода. Это, кажется, соответствует тому, как мы определили модульные тесты в начале этой статьи.

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

function testItCanFindCorrectAnswer() {
    $this->assertTrue(isCurrentAnswerCorrect(0, 0));
    $this->assertTrue(isCurrentAnswerCorrect(1, 1));
    $this->assertTrue(isCurrentAnswerCorrect(2, 2));
    $this->assertTrue(isCurrentAnswerCorrect(3, 3));
    $this->assertTrue(isCurrentAnswerCorrect(4, 4));
    $this->assertTrue(isCurrentAnswerCorrect(5, 5));
    $this->assertTrue(isCurrentAnswerCorrect(6, 6));
    $this->assertTrue(isCurrentAnswerCorrect(8, 8));
    $this->assertTrue(isCurrentAnswerCorrect(9, 9));
}

function testItCanFindWrongAnser() {
    $this->assertFalse(isCurrentAnswerCorrect(7, 7));
}

Тесты рефакторинга

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

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

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

Мы могли бы извлечь номера правильных ответов в массив и использовать, его чтобы сгенерировать правильные ответы:

function testItCanFindCorrectAnswer() {
    $correctAnserIDs = [0, 1, 2, 3, 4, 5, 6, 8, 9];
    foreach ($correctAnserIDs as $id) {
        $this->assertTrue(isCurrentAnswerCorrect($id, $id));
    }
}

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

function testItCanFindCorrectAnswer() {
    $correctAnserIDs = [0, 1, 2, 3, 4, 5, 6, 8, 9];
    $this->assertAnswersAreCorrectFor($correctAnserIDs);
}

function testItCanFindWrongAnser() {
    $this->assertFalse(isCurrentAnswerCorrect(7, 7));
}

private function assertAnswersAreCorrectFor($correctAnserIDs) {
    foreach ($correctAnserIDs as $id) {
        $this->assertTrue(isCurrentAnswerCorrect($id, $id));
    }
}

Теперь, это дает нам двойную пользу. Во-первых, мы переместили логику просмотра каждого элемента массива и его проверки в закрытый метод.

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

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

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

Тест зависимостей рабочего кода

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

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

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

Конечно, ответ будет нет. Изменение простой константы в рабочем коде не будет влиять на поведение или логику. Таким образом, тесты не будут проходить.

Звучит здорово! Но как это сделать? Ну, самый простой способ - просто вынести нужную переменную в переменную открытого класса, а лучше в статическую переменную или константу.

В нашем случае, так как у нас нет класса, мы можем просто сделать ее глобальной переменной или константой:

include_once __DIR__ . '/Game.php';

const WRONG_ANSWER_ID = 7;

function isCurrentAnswerCorrect($minAnswerId = 0, $maxAnswerId = 9) {
    return rand($minAnswerId, $maxAnswerId) != WRONG_ANSWER_ID;
}

function run() {
    // ... //
}

Сначала мы изменяем файл RunnerFunctions.php так, чтобы isCurrentAnswerCorrect() использовал константу, а не локальную переменную. Затем запускаем модульные тесты. Мы хотим убедиться, что изменение, которое мы произвели в рабочем коде, ничего не сломало. Теперь пришло время для теста:

function testItCanFindWrongAnswer() {
    $this->assertFalse(isCurrentAnswerCorrect(WRONG_ANSWER_ID, WRONG_ANSWER_ID));
}

Измените testItCanFindWrongAnswer() так, чтобы использовалась та же константа. Так как файл RunnerFunctions.php включен в начале тестового файла, объявление константы будет охватываться нашим тестом.

Тесты рефакторинга (снова)

Теперь, когда мы связываем WRONG_ANSWER_ID с testItCanFindWrongAnswer(), не следует ли нам реорганизовать наш тест, чтобы testItCanFindCorrectAnswer() также был связан с той же константой? Думаю, следует. Это не только сделает наш тест более понятным, он также станет более стабильным.

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

class RunnerFunctionsTest extends PHPUnit_Framework_TestCase {

    function testItCanFindCorrectAnswer() {
        $correctAnserIDs = $this->getGoodAnswerIDs();
        $this->assertAnswersAreCorrectFor($correctAnserIDs);
    }

    private function getGoodAnswerIDs() {
        return [0, 1, 2, 3, 4, 5, 6, 8, 9];
    }

}

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

Мы можем начать с применения метода извлечения, и заключить их в отдельный собственный метод:

class RunnerFunctionsTest extends PHPUnit_Framework_TestCase {

    function testItCanFindCorrectAnswer() {
        $correctAnserIDs = $this->getGoodAnswerIDs();
        $this->assertAnswersAreCorrectFor($correctAnserIDs);
    }

    private function getGoodAnswerIDs() {
        return array_diff(range(0,9), [WRONG_ANSWER_ID]);
    }

}

Мы значительно изменили getGoodAnswerIDs(). Прежде всего, мы сгенерировали список с помощью range(), вместо того, чтобы вводить все возможные значения вручную. После этого мы вычитаем из массива элемент, содержащий WRONG_ANSWER_ID.

Теперь список идентификаторов правильных ответов также не зависит от значения, установленного в идентификаторе неправильного ответа.

Но достаточно ли этого? Как насчет минимального и максимального идентификаторов? Разве мы не можем извлечь их аналогичным образом? Что ж, давайте посмотрим:

include_once __DIR__ . '/Game.php';

const WRONG_ANSWER_ID = 7;
const MIN_ANSWER_ID = 0;
const MAX_ANSWER_ID = 9;

function isCurrentAnswerCorrect($minAnswerId = MIN_ANSWER_ID, $maxAnswerId = MAX_ANSWER_ID) {
    return rand($minAnswerId, $maxAnswerId) != WRONG_ANSWER_ID;
}

function run() {
    // ... //
}

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

В качестве небольшого бонуса, блок констант в начале файла теперь подчеркивает ключевые значения, используемые файлом isCurrentAnswerCorrect(). Отлично!

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

Теперь нам нужно обновить модульный тест, чтобы задействовать константы:

class RunnerFunctionsTest extends PHPUnit_Framework_TestCase {

    function testItCanFindCorrectAnswer() {
        $correctAnserIDs = $this->getGoodAnswerIDs();
        $this->assertAnswersAreCorrectFor($correctAnserIDs);
    }

    private function getGoodAnswerIDs() {
        return array_diff(range(MIN_ANSWER_ID,MAX_ANSWER_ID), [WRONG_ANSWER_ID]);
    }

}

Это было достаточно легко. Мы просто должны были изменить параметры в методе range().
Последнее, что мы можем сделать с нашим тестом, это вычистить беспорядок, который остался после нашего метода testItCanFindCorrectAnswer():

function testItCanFindCorrectAnswer() {
    $correctAnserIDs = $this->getGoodAnswerIDs();
    $this->assertAnswersAreCorrectFor($correctAnserIDs);
}

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

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

class RunnerFunctionsTest extends PHPUnit_Framework_TestCase {

    function testItCanFindCorrectAnswer() {
        $correctAnserIDs = $this->getCorrectAnswerIDs();
        $this->assertAnswersAreCorrectFor($correctAnserIDs);
    }

    private function getCorrectAnswerIDs() {
        return array_diff(range(MIN_ANSWER_ID,MAX_ANSWER_ID), [WRONG_ANSWER_ID]);
    }

}

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

В нашем примере переменная располагается в этом месте, потому что это предусмотрено дополнительным разъяснением того, что означают числа массива.

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

Тесты рефакторинга (снова)

Мы можем использовать метод встроенной переменной, чтобы удалить эту переменную и вызвать непосредственно метод, вместо того, чтобы использовать переменную в следующей строке:

class RunnerFunctionsTest extends PHPUnit_Framework_TestCase {

    function testItCanFindCorrectAnswer() {
        $this->assertAnswersAreCorrectFor($this->getCorrectAnswerIDs());
    }

    private function getCorrectAnswerIDs() {
        return array_diff(range(MIN_ANSWER_ID,MAX_ANSWER_ID), [WRONG_ANSWER_ID]);
    }

}

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

Нарушение выполнения

Мы закончили с RunnerFunctions.php? Ну, если я вижу в этом файле if(), это значит, что здесь есть логика. Если я вижу логику, это значит, что для нее нужно провести модульное тестирование.

У нас также есть оператор if() в методе run() цикла do-while(). Пора применить встроенные инструменты рефакторинга нашей IDE, извлечь метод, а затем протестировать его.

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

function run() {
    $notAWinner;

    $aGame = new Game();

    $aGame->add("Chet");
    $aGame->add("Pat");
    $aGame->add("Sue");

    do {
        $dice = rand(0, 5) + 1;
        $aGame->roll($dice);

        $notAWinner = getNotWinner($aGame);

    } while ($notAWinner);
}

function getNotWinner($aGame) {
    if (isCurrentAnswerCorrect()) {
        $notAWinner = $aGame->wasCorrectlyAnswered();
        return $notAWinner;
    } else {
        $notAWinner = $aGame->wrongAnswer();
        return $notAWinner;
    }
}

Хотя он выглядит довольно удовлетворительно, к тому же он был сгенерирован очень просто - всего лишь выбором одного пункта в контекстном меню нашей IDE, есть проблема, которая меня беспокоит. Объект aGame используется как в цикле do-while, так и в извлеченном методе. Что нам с этим делать?

function run() {
    $notAWinner;

    $aGame = new Game();

    $aGame->add("Chet");
    $aGame->add("Pat");
    $aGame->add("Sue");

    do {
        $dice = rand(0, 5) + 1;
        $notAWinner = getNotWinner($aGame, $dice);

    } while ($notAWinner);
}

function getNotWinner($aGame, $dice) {
    $aGame->roll($dice);

    if (isCurrentAnswerCorrect()) {
        $notAWinner = $aGame->wasCorrectlyAnswered();
        return $notAWinner;
    } else {
        $notAWinner = $aGame->wrongAnswer();
        return $notAWinner;
    }
}

Это решение позволяет нам убрать объект aGame из цикла. Однако тут же появляется проблема другого рода. Увеличивается количество параметров. Теперь нам нужно отправить их в $dice. Огромное количество параметров может стать проблемой. В то же время нормальное число параметров, которого нам хотелось бы достичь - два.

Тогда нам не придется особенно беспокоиться о том, как эти параметры используются в самом методе. $dice используется, только когда в объекте aGame вызывается метод roll(). Хотя метод roll() имеет большое значение для класса Game, он не определяет победителя викторины.

Анализируя код Game, можно сделать вывод, что статус победителя определяется только через вызов wasCorrectlyAnswered(). Странно. Это подчеркивает, что существуют некоторые серьезные проблемы с назначением имен в классе Game, и их нам придется решать в следующей статье.

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

function getNotWinner($aGame) {
    if (isCurrentAnswerCorrect()) {
        $notAWinner = $aGame->wasCorrectlyAnswered();
        return $notAWinner;
    } else {
        $notAWinner = $aGame->wrongAnswer();
        return $notAWinner;
    }
}

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

function testItCanTellIfThereIsNoWinnerWhenACorrectAnswerIsProvided() {}

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

Но у нас есть проблема. Тестируемый метод нуждается в объекте. Мы должны запускать его так:

function testItCanTellIfThereIsNoWinnerWhenACorrectAnswerIsProvided() {
    $aGame = ???
    getNotWinner($aGame);
}

Нам нужен объект $aGame типа Game. Но мы производим модульное тестирование, мы не хотим использовать реальный, сложный и плохо понимаемый класс Game. Это подводит нас вплотную к новой главе в тестировании, о которой мы поговорим в другой статье: псевдо объекты, заглушки и фейки.

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

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

Внутри тестового файла мы можем создать класс, аналогичный классу Game, и определить в нем только те два метода, которые нам нужны. Это очень просто:

class RunnerFunctionsTest extends PHPUnit_Framework_TestCase {

    // ... //

    function testItCanTellIfThereIsNoWinnerWhenACorrectAnswerIsProvided() {
        $aGame = new FakeGame();
        getNotWinner($aGame);
    }

    // ... //

}

class FakeGame {

    function wasCorrectlyAnswered() {

    }

    function wrongAnswer() {

    }
}

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

Time: 43 ms, Memory: 3.00Mb

OK, but incomplete or skipped tests!
Tests: 5, Assertions: 10, Skipped: 2.

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

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

if (isCurrentAnswerCorrect())

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

Это позволит нам контролировать результат выражения в приведенном ранее операторе if, и тем самым мы сможем контролировать процесс исполнения нашего кода. Мы нуждаемся в этом для первого теста, чтобы войти в if и вызвать wasCorrectlyAnswered():

function run() {

    // ... //

    do {
        $dice = rand(0, 5) + 1;
        $aGame->roll($dice);

        $notAWinner = getNotWinner($aGame, isCurrentAnswerCorrect());

    } while ($notAWinner);
}

function getNotWinner($aGame, $isCurrentAnswerCorrect) {
    if ($isCurrentAnswerCorrect) {
        $notAWinner = $aGame->wasCorrectlyAnswered();
        return $notAWinner;
    } else {
        $notAWinner = $aGame->wrongAnswer();
        return $notAWinner;
    }
}

Теперь мы контролируем процесс, все зависимости разорваны. Настало время для тестирования:

function testItCanTellIfThereIsNoWinnerWhenACorrectAnswerIsProvided() {
    $aGame = new FakeGame();
    $isCurrentAnswerCorrect = true;
    $this->assertTrue(getNotWinner($aGame, $isCurrentAnswerCorrect));
}

Тест проходит, очень хорошо. Из нашего переопределенного метода мы получили true, конечно:

function wasCorrectlyAnswered() {
    return true;
}

Мы должны проверить и другое направление исполнения if():

function testItCanTellIfThereIsNoWinnerWhenAWrongAnswerIsProvided() {
    $aGame = new FakeGame();
    $isCurrentAnswerCorrect = false;
    $this->assertFalse(getNotWinner($aGame, $isCurrentAnswerCorrect));
}

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

class FakeGame {

    function wasCorrectlyAnswered() {
        return true;
    }

    function wrongAnswer() {
        return false;
    }
}

И наша FakeGame была изменена соответственно.

Финальная очистка

Рефакторинг методом извлечения

Мы почти закончили. Прошу прощения, что эта часть серии получилась такой длинной, надеюсь, что вам понравилось, и вы не уснули. Финальные изменения перед тем, как мы получим окончательную версию файла RunnerFunctions.php и протестируем его:

function getNotWinner($aGame, $isCurrentAnswerCorrect) {
    if ($isCurrentAnswerCorrect) {
        $notAWinner = $aGame->wasCorrectlyAnswered();
        return $notAWinner;
    } else {
        $notAWinner = $aGame->wrongAnswer();
        return $notAWinner;
    }
}

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

function getNotWinner($aGame, $isCurrentAnswerCorrect) {
    if ($isCurrentAnswerCorrect) {
        return $aGame->wasCorrectlyAnswered();
    } else {
        return $aGame->wrongAnswer();
    }
}

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

Тестирование рефакторинга (снова и снова)

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

function testItCanTellIfThereIsNoWinnerWhenACorrectAnswerIsProvided() {
    $aGame = new FakeGame();
    $isCurrentAnswerCorrect = true;
    $this->assertTrue(getNotWinner($aGame, $isCurrentAnswerCorrect));
}

function testItCanTellIfThereIsNoWinnerWhenAWrongAnswerIsProvided() {
    $aGame = new FakeGame();
    $isCurrentAnswerCorrect = false;
    $this->assertFalse(getNotWinner($aGame, $isCurrentAnswerCorrect));
}

У нас есть дублированный код вызова new FakeGame() в каждом методе. Пора извлечь метод:

function testItCanTellIfThereIsNoWinnerWhenACorrectAnswerIsProvided() {
    $aGame = $this->aFakeGame();
    $isCurrentAnswerCorrect = true;
    $this->assertTrue(getNotWinner($aGame, $isCurrentAnswerCorrect));
}

function testItCanTellIfThereIsNoWinnerWhenAWrongAnswerIsProvided() {
    $aGame = $this->aFakeGame();
    $isCurrentAnswerCorrect = false;
    $this->assertFalse(getNotWinner($aGame, $isCurrentAnswerCorrect));
}

Теперь переменная $aGame становится практически бесполезной. Здесь стоит применить рефакторинг встроенной переменной:

function testItCanTellIfThereIsNoWinnerWhenACorrectAnswerIsProvided() {
    $isCurrentAnswerCorrect = true;
    $this->assertTrue(getNotWinner($this->aFakeGame(), $isCurrentAnswerCorrect));
}

function testItCanTellIfThereIsNoWinnerWhenAWrongAnswerIsProvided() {
    $isCurrentAnswerCorrect = false;
    $this->assertFalse(getNotWinner($this->aFakeGame(), $isCurrentAnswerCorrect));
}

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

Что мне до сих пор не нравится, так это то, что мы используем ту же переменную и присваиваем ей значение true или false в зависимости от теста. Я думаю, должен существовать более показательный способ сделать это:

function testItCanTellIfThereIsNoWinnerWhenACorrectAnswerIsProvided() {
    $this->assertTrue(getNotWinner($this->aFakeGame(), $this->aCorrectAnswer()));
}

function testItCanTellIfThereIsNoWinnerWhenAWrongAnswerIsProvided() {
    $this->assertFalse(getNotWinner($this->aFakeGame(), $this->aWrongAnswer()));
}

Вау! Наши тесты стали однострочными, и они на самом деле отображают то, что мы тестируем. Все детали скрыты в частных методах в конце теста. В 99% случаев вы не будете заботиться об их осуществлении, а если вам они вдруг понадобятся, вы можете просто нажать CTRL + клик на названии метода, и IDE сама перейдет к реализации.

Возвращаемся к рабочему коду

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

function run() {
    $notAWinner;

    $aGame = new Game();

    $aGame->add("Chet");
    $aGame->add("Pat");
    $aGame->add("Sue");

    do {
        $dice = rand(0, 5) + 1;
        $aGame->roll($dice);

        $notAWinner = getNotWinner($aGame, isCurrentAnswerCorrect());

    } while ($notAWinner);
}

Этот код превращается в следующий:

function run() {

    $aGame = new Game();

    $aGame->add("Chet");
    $aGame->add("Pat");
    $aGame->add("Sue");

    do {
        $dice = rand(0, 5) + 1;
        $aGame->roll($dice);

    } while (getNotWinner($aGame, isCurrentAnswerCorrect()));
}

До свидания, переменная $notAWinner. Но имя нашего метода все еще ужасно. Мы знаем, что мы должны всегда отдавать предпочтение положительным именам и поведению, а отрицательные допустимы только в условиях. Как насчет вот такого имени?

do {
    $dice = rand(0, 5) + 1;
    $aGame->roll($dice);

} while (didSomebodyWin($aGame, isCurrentAnswerCorrect()));

Но изменив имя, мы должны изменить код в while(), а также его поведение. Начнем с изменения тестов:

class FakeGame {

    function wasCorrectlyAnswered() {
        return false;
    }

    function wrongAnswer() {
        return true;
    }
}

Вообще-то лучше менять только нашу фейковую викторину. Это обеспечит, чтобы с новыми именами методов тесты оставались читаемыми:

function testItCanTellIfThereIsNoWinnerWhenACorrectAnswerIsProvided() {
    $this->assertTrue(didSomebodyWin($this->aFakeGame(), $this->aCorrectAnswer()));
}

function testItCanTellIfThereIsNoWinnerWhenAWrongAnswerIsProvided() {
    $this->assertFalse(didSomebodyWin($this->aFakeGame(), $this->aWrongAnswer()));
}

Получаем тест для прохождения

Конечно, тесты сейчас не проходят. Мы должны изменить реализацию метода:

function didSomebodyWin($aGame, $isCurrentAnswerCorrect) {
    if ($isCurrentAnswerCorrect) {
        return ! $aGame->wasCorrectlyAnswered();
    } else {
        return ! $aGame->wrongAnswer();
    }
}

Исправляем золотой мастер

Модульные тесты проходят, но выполнение золотого мастера нарушено. Мы должны убрать логин в операторе while:

do {
    $dice = rand(0, 5) + 1;
    $aGame->roll($dice);

} while (!didSomebodyWin($aGame, isCurrentAnswerCorrect()));

Готово!

Теперь золотой мастер снова проходит, а наш цикл do-while читается, как хорошо написанная проза. На этом можно остановиться. Спасибо, что уделили мне внимание.

РедакцияПеревод статьи «Refactoring Legacy Code: Part 4 - Our First Unit Tests»