четверг, 15 ноября 2012 г.

Пример разработки под управлением тестов

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

Шаг 0. Настройка среды в PHPStorm

Начну работу. Прежде, чем начнется интересное, необходимо создать проект и сделать так, чтобы запускались тесты. Создам пустой класс EventLimiter.php и пустой набор тестов. Настрою конфигурацию для запуска PHPUnit.

Желтая полоска говорит нам что тесты выполнились, но один был 'Incomplete'. Конечно.

Шаг 1. Увидеть результат

Начну с конца. Попробую сразу увидеть результат моей работы, а именно то, как будет использоваться мой объект.

Вот каков будет интерфейс EventLimiter-а. На сообщение о том, что должно быть сделано, я получу ответ "можно" или "нельзя". Уже через минуту после начала работы я запускаю программу! Тесты сразу падают, но не составляет труда их починить.
Глядя на тест, можно убежденно сказать, что он описывает вот такую модель.
Прежде чем продолжить, я хочу посмотреть, что у меня вырисовывается. Выглядит так, что я буду декларировать свое намерение выполнить какое-то действие, и получать разрешение. Или не получать. Выглядит удобно. Попробую описать случай, когда второе действие подряд запрещено.

Шаг 2. Объявляю ограничения

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

Я задумал два параметра: количество действий (1) в период времени (10 секунд). А что, имею право. Судя по тесту, мой объект должен запретить отсылку второй СМС-ки подряд.
Запускаю тесты: второй падает. Конечно, я же ничего еще не напрограммировал. Дело поправимое - надо писать код. Сохраню параметры, передаваемые в конструктор, и начну регистрировать входящие события.

Появились два приватных метода: registerEvent и eventsDone. Имена говорят всё, что нужно об этих методах знать. У объекта кроме параметров появилось внутреннее состояние - массив произошедших событий. Ну что же, всё к тому шло, это не я такой, это жизнь такая.
После запуска тестов и исправления ошибок всё заработало. Да, я совершенно упускаю второй параметр - время. Это потому, что тесты не настаивали на этом. Посмотрим, куда они поведут меня дальше. Займусь-ка я на кодом тестов, что-то он мне не нравится.

Шаг 3. Рефакторинг тестов

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

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

Шаг 4. Постоянство

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

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

Способ простой, но неудачный. Пришлось сделать репозиторий публичным статическим массивом, и очищать его перед каждым тестом. События регистрируются в памяти и разделяются между всеми EventLimiter-ами.
Зато стало ясно, что наша задача требует наличия постоянного хранилища. Я разрабатываю объект для управления событиями, между которыми может пройти и минута, и час. И работать он будет где-то на веб-сервере. Это значит что хранилище в памяти скрипта меня совершенно не устраивает. Мне нужен какой-то постоянный контейнер. Key - value хранилище. С отметками времени. Файловая система.

Шаг 5. Файлы без файловой системы

Содержимое папки в файловой системе - отличное key - value хранилище. Ключ - это имя файла. Значение - это его содержимое. Файл имеет метку времени. То, что нужно.
Но писать тесты на файловую систему - неудобно. Нужно где-то создавать временную папку, потом её удалять. Если что-то пойдет не так, папка может остаться. Или не появиться. Файловая система - это глобальная зависимость, которая только мешает при юнит-тестировании. Благо, есть https://github.com/mikey179/vfsStream, библиотека, которая позволяет тестировать зависимость от файловой системы в памяти.
vfsStream использует обертку для потоков, stream wrapper. Для того, чтобы направить функции работы с файловой системой на свои обработчики, регистрирует свой протокол. Путь в файловой системе в тестах будет выглядеть так: 'vfs://path/somewhere/'.
Необходимо изменить тесты, добавив метод setUp и передав путь в файловой системе как зависимость в EventLimiter. Но вначале с помощью Composer-а установлю "mikey179/vfsStream".

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

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

Шаг 5. Путешествия во времени

Пора заняться зависимостью от времени. Логика работы EventLimiter-а толкает нас написать тест, в котором будет два запроса на выполнение действия, разделенных во времени на, например, 11 секунд. Правильный EventLimiter разрешит оба действия. Задерживать юнит тесты на 11 секунд? Никогда! Лучше отправим нашу модель в будущее.
Я собираюсь ввести явную зависимость от объекта "Часы", который будет показывать то время, которое будет нам нужно для тестирования. В продуктивном режиме часы просто будут сообщать текущее время.
Итак, пора написать новый тест.

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

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

Новый тест показал ошибку, которую удалось исправить без особого труда. На этом буду считать работу законченной. С готовым кодом можно познакомиться на гитхабе: https://github.com/Magomogo/EventLimiter

4 комментария:

  1. Прекрасный пост! Отдельный high five за использование авто-рефакторингов пхп-шторма. При чтении и просмотре у меня возникло много мыслей. Вот они.

    - Мне кажется, function goingTo() – не самое удачное имя. confirmAllowed() не лучше?

    - Не совсем точным преставляется имя теста test_second_event_isnt_allowed. По-моему, имеется в виду test_event_repetition_isnt_allowed

    - Очень уж много кода появляется для озеленения 2-го теста. Я б разбил вот так.

    - Заметь, что про $timeSpan речи пока не было, так как сломаных тестов для его внедрения мы ещё не писали.

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

    - Шаг 4 с введением – по сути – синглтона меня покоробил. Не вижу, почему регистрация событий независимо для каждого экземпляра – это категорически неудобно. Да, как-то надо будет разделять один объект/состояние между всеми заинтересованными агентами. Но это другой аспект, не связанный с логикой лимитирования поступающих событий. Пусть этим занимается Pimple или Zend_Registry. А если кто-то зависит от экземпляра EventLimiter-а, ему должны его предоставить. Иначе, ты фактически ввёл глобальную переменную "EventLimiter". Оно нам надо?

    - Так что вторую часть с vfsStream я бы повернул иначе, реализовав отдельный объект KeyValueFileStorage; и паралленльно завязав EventLimiter на какой-то key-value storage вместо его внутреннего массива. То есть, в реальном приложении в Pimple/Zend_Registry будет попадать EventLimiter завязанный на KeyValueFileStorage, даже не зная при этом, что его counts-ы лежат в файлухе.

    - Про часы – прекрасно! И я бы даже не гнушался переименования параметра конструктора из $testClock в $clock с удалением значения null по умолчанию. Время – важная зависимость, которую предпочитаю объявлять явно.

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

    - $timeSpan я бы назвал $eventsLifetimeSeconds. Да, длиннее; но зато понятно, что в секундах, и что это время жизни.

    Ну а вообще, я в восторге! Отличная работа!

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

    ОтветитьУдалить
    Ответы
    1. Например использовать Riak в качества key-value хранилища, Он делегирует клиенту разруливать коллизии, что в этой задаче сделать просто.

      Удалить
    2. Поэтому! Как я и написал, стоит отделить хранилище от EventLimiter-а! ;-)

      Удалить