Начиная с бизнес-логики
Очистите типы
В Посте на прошлой неделе о дизайне, основанном на типах, я определил основные типы, необходимые для приложения для оценки дротиков. Благодаря @ marc.minnee я еще больше улучшил ситуацию. Он посоветовал мне немного упростить типы сообщений. Вместо этого чудовища,
type Msg = HitFifteen Magnitude PlayerId | HitSixteen Magnitude PlayerId | HitSeventeen Magnitude PlayerId | HitEighteen Magnitude PlayerId | HitNineteen Magnitude PlayerId | HitTwenty Magnitude PlayerId | HitBullseye BullseyeMagnitude PlayerId | Miss
Я мог бы использовать что-нибудь попроще.
type Msg = Hit PlayerId Target Magnitude | Miss PlayerId
На самом деле не было необходимости определять действие для каждого типа Target, когда я мог просто передать этот тип. Обратите внимание, что я также забыл передать идентификатор игрока в действие Miss. Я также изменил порядок параметров, чтобы действия читались более естественно. Это заставляет мои действия выглядеть так.
Hit Player1 Seventeen Double Hit Player2 Fifteen Triple Miss Player1
Эти действия очень ясны и явно раскрывают цель действия.
Ясно, что стоит вернуться назад, просмотреть свою работу и, по возможности, еще раз взглянуть на код. Когда глубоко погружаешься в код, легко упустить кажущиеся очевидными вещи. Я считаю, что код Elm идеально подходит для сочетания и экспертной оценки, поскольку типы высокого уровня упрощают понимание цели кода без необходимости разбираться во всех деталях.
Подготовка моего кода к тестированию
Прежде чем я смог написать эффективные тесты для elm-test, я сначала сделал свой код более модульным. Вместо одного модуля с именем Main я разбил его на три модуля. Один модуль с именем Модель содержал все определения типов. Второй под названием Update содержит функцию обновления, а также любые функции, связанные с обновлением. Все описанное ниже тестирование сосредоточено на функции обновления. Вот этот модуль после его разделения.
module Update exposing (..) import Model exposing (..) update : Msg -> Model -> Model update msg model = case msg of Miss player -> model Hit player target magnitude -> model
Это базовая функция обновления «без операции». Он импортирует типы, которые были разделены в модуль Model, и сопоставляет шаблон с типом сообщения, возвращая ту же модель, которую он получил.
Реализация первой функции
Как только типы становятся достаточно прочными, я перехожу от дизайна, основанного на типах, к более традиционному дизайну, основанному на тестировании. Как и в мире Rails, мне нравится тестировать извне изнутри. Я начинаю с высокоуровневого тестирования функций и создаю модульные тесты там, где это необходимо, для тестирования на более осязаемом уровне.
В мире Elm, когда я думаю о тестах самого высокого уровня, которые я могу выполнить без браузера, я склоняюсь к тестам функции обновления. Здесь я могу предоставить модель, выполнить функцию обновления с определенным типом сообщения и подтвердить, что возвращенная модель соответствует моим ожиданиям.
Вот пример первого функционального теста. Это проверяет, что когда мы обрабатываем сообщение типа Miss, текущий номер дротика увеличивается.
all : Test all = let initPlayer1 : Player initPlayer1 = { name = "Player 1" , status = [] , score = 0 } initPlayer2 : Player initPlayer2 = { name = "Player 2" , status = [] , score = 0 } in describe "A Test Suite" [ test "Miss message type increments the currentDart" <| \() -> let initModel = { player1 = initPlayer1 , player2 = initPlayer2 , currentTurn = Player1 , currentDart = 1 } newModel = Update.update (Model.Miss Player1) initModel in Expect.equal newModel.currentDart 2 ]
С текущей функцией обновления мы получаем ожидаемую ошибку elm-test.
↓ A Test Suite ✗ Miss message type increments the currentDart 2 ╷ │ Expect.equal ╵ 1
На этом этапе я обновил функцию обновления, чтобы она выглядела так.
update : Msg -> Model -> Model update msg model = case msg of Miss player -> { model | currentDart = model.currentDart + 1 } Hit player target magnitude -> model
Это делает тест пройденным. Затем мы можем добавить тест, чтобы проверить, что тип сообщения Hit также правильно увеличивает currentDart.
Это приводит к этому новому коду обновления.
update : Msg -> Model -> Model update msg model = case msg of Miss player -> { model | currentDart = model.currentDart + 1 } Hit player target magnitude -> { model | currentDart = model.currentDart + 1 }
Выполнив эти два теста, мы теперь хотим убедиться, что значение currentDart никогда не может увеличиваться больше трех. Вот тест на эту функциональность.
test "currentDart cannot increment above 3" <| \() -> let initModel = { player1 = initPlayer1 , player2 = initPlayer2 , currentTurn = Player1 , currentDart = 3 } newModel = Update.update (Model.Miss Player1) initModel in Expect.equal newModel.currentDart 1
Чтобы этот тест прошел успешно, я внесу следующие изменения в функцию обновления.
update : Msg -> Model -> Model update msg model = case msg of Miss player -> { model | currentDart = nextDart model.currentDart } Hit player target magnitude -> { model | currentDart = nextDart model.currentDart } nextDart : Int -> Int nextDart num = if (num >= 3) then 1 else num + 1
На этом этапе я мог бы также решить написать модульные тесты для новой функции. Вот пример модульных тестов для этой новой функции.
, test "Update.nextDart properly increments" <| \() -> Expect.equal (Update.nextDart 1) 2 -- -- , test "Update.nextDart properly wraps" <| \() -> Expect.equal (Update.nextDart 3) 1
Вторая особенность
Протестировав одну функцию, довольно просто добавить новые функции в режиме тестирования. Нашей следующей функцией будет добавление кода, который обновляет текущего игрока после броска третьего дротика. Я написал для этого два теста. Один для бросков, которые не меняют игрока, а другой для бросков, которые меняют игрока.
, test "Player doesn't change before dart 3" <| \() -> let initModel = { player1 = initPlayer1 , player2 = initPlayer2 , currentTurn = Player1 , currentDart = 2 } newModel = Update.update (Model.Miss Player1) initModel in Expect.equal newModel.currentTurn Player1 -- -- , test "Player changes after dart 3" <| \() -> let initModel = { player1 = initPlayer1 , player2 = initPlayer2 , currentTurn = Player1 , currentDart = 3 } newModel = Update.update (Model.Miss Player1) initModel in Expect.equal newModel.currentTurn Player2
Запуск этих новых тестов приводит к прохождению первого теста и провалу второго (как и следовало ожидать). Написать код для прохождения этих тестов довольно просто.
update : Msg -> Model -> Model update msg model = case msg of Miss player -> { model | currentDart = nextDart model.currentDart , currentTurn = nextPlayer model.currentDart model.currentTurn } Hit player target magnitude -> { model | currentDart = nextDart model.currentDart , currentTurn = nextPlayer model.currentDart model.currentTurn } nextPlayer : Int -> PlayerId -> PlayerId nextPlayer dartNum player = if dartNum == 3 then changePlayer player else player changePlayer : PlayerId -> PlayerId changePlayer player = case player of Player1 -> Player2 Player2 -> Player1
Обратите внимание, что я изменил функцию обновления, чтобы обновить элемент currentTurn, а также currentDart. Благодаря этому тесты проходят, но вы могли заметить, что наши тесты не завершены. Мы проверяем только правильность установки currentTurn для сообщения Miss. То же самое должно происходить и с типом сообщения Hit.
Хотя мы могли бы вернуться и добавить еще два теста на уровне обновления, кажется, что это не место для тестирования этой низкоуровневой функциональности. Чтобы этого избежать, давайте исключим повторение в функции обновления и воспользуемся модульными тестами для проверки этой новой функции.
update : Msg -> Model -> Model update msg model = case msg of Miss player -> model |> updateDartAndTurn Hit player target magnitude -> model |> updateDartAndTurn updateDartAndTurn : Model -> Model updateDartAndTurn model = { model | currentDart = nextDart model.currentDart , currentTurn = nextPlayer model.currentDart model.currentTurn }
Это было довольно простое изменение. Обратите внимание, что теперь я использую оператор конвейера в операторе обновления case. Это позволит нам структурировать пункты обновления так, чтобы конвейеры данных были легко читаемы по мере добавления дополнительных функций.
Имея эту новую функцию, относительно легко написать несколько модульных тестов для новой функции.
, test "updateDartandTurn before dart 3 - proper player" <| \() -> let initModel = { player1 = initPlayer1 , player2 = initPlayer2 , currentTurn = Player1 , currentDart = 2 } newModel = Update.updateDartAndTurn initModel in Expect.equal newModel.currentTurn Player1 -- -- , test "updateDartandTurn before dart 3 - proper dart" <| \() -> let initModel = { player1 = initPlayer1 , player2 = initPlayer2 , currentTurn = Player1 , currentDart = 2 } newModel = Update.updateDartAndTurn initModel in Expect.equal newModel.currentDart 3
В дополнение к этим двум тестам я также написал тесты, чтобы проверить, что все правильно переворачивается после дротика 3.
, test "updateDartandTurn dart 3 - proper player" <| \() -> let initModel = { player1 = initPlayer1 , player2 = initPlayer2 , currentTurn = Player1 , currentDart = 3 } newModel = Update.updateDartAndTurn initModel in Expect.equal newModel.currentTurn Player2 -- -- , test "updateDartandTurn dart 3 - proper dart" <| \() -> let initModel = { player1 = initPlayer1 , player2 = initPlayer2 , currentTurn = Player1 , currentDart = 3 } newModel = Update.updateDartAndTurn initModel in Expect.equal newModel.currentDart 1
На данный момент у меня осталась одна пробная дыра. Я не доказал, что когда игрок 2 является активным игроком, ход правильно переходит к игроку 1 после дротика 3. Лучше всего это проверить на нижнем уровне отряда следующим образом.
, test "changePlayer goes from Player1 to Player2" <| \() -> Expect.equal (Update.changePlayer Player1) Player2 -- -- , test "changePlayer goes from Player2 to Player1" <| \() -> Expect.equal (Update.changePlayer Player2) Player1
На этом этапе мы можем продолжить тот же процесс, что и добавление новых функций. Продолжая этот процесс, я, скорее всего, в следующий раз займусь этими функциями.
- Броски дротиков правильно обновляют состояние цели Неоткрыто и меняют его на Открыто после необходимого количества ударов.
- Дарт бросает правильно обновлять оценки попаданий по открытым целям
- Целевое значение устанавливается на Закрыто после того, как второй игрок получит соответствующее количество ударов по этой цели.
- Подсчет очков не может происходить по закрытым целям.
- Приложение определяет статус, указывающий на то, что игра окончена (все цели закрыты, или у последующего игрока больше нет открытых целей) и правильно отмечает окончание игры.
Уроки выучены
В этом посте я рассмотрел процесс, который использую после того, как хорошо разбираюсь в своих типах. Определив типы и сообщения, очень легко перейти непосредственно к написанию бизнес-логики, основанной на типах. Есть несколько правил, которым я стараюсь следовать, когда погружаюсь в этот процесс.
- Я начинаю с тестов на уровне функций, что означает, что я начинаю тестирование с функции обновления. Я углубляюсь в каждый тип сообщения и начинаю определять тесты, которые будут проверять некоторые аспекты логики, связанной с этим типом сообщения.
- Поскольку я вижу необходимость в новых функциях нижнего уровня, я исключаю их из логики обновления и пишу для них модульные тесты.
- Я ищу повторяющуюся логику и делю ее из функции обновления на функции, которые можно объединить в цепочку для преобразования модели.
- Я даже не рассматриваю логику представления, пока бизнес-логика в основном не завершена. На мой взгляд, святой Грааль дизайна, основанного на типах / тестах, - это когда я могу протестировать всю бизнес-логику, не создавая представления. После завершения бизнес-логики я создаю представление и привязываю события HTML к сообщениям, и я вполне уверен, что все сработает с некоторыми проблемами.
Я только начинаю создавать свои приложения Elm, используя этот подход, поэтому я уверен, что мне еще предстоит многому научиться. Подозреваю, что добавлю дополнительную логику в это приложение, и мне придется что-то делать. Как всегда, приветствуются любые конструктивные отзывы. Это приложение значительно улучшилось благодаря отзывам, которые я получил в последнем посте.