Начиная с бизнес-логики

Очистите типы

В Посте на прошлой неделе о дизайне, основанном на типах, я определил основные типы, необходимые для приложения для оценки дротиков. Благодаря @ 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

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

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

Уроки выучены

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

  1. Я начинаю с тестов на уровне функций, что означает, что я начинаю тестирование с функции обновления. Я углубляюсь в каждый тип сообщения и начинаю определять тесты, которые будут проверять некоторые аспекты логики, связанной с этим типом сообщения.
  2. Поскольку я вижу необходимость в новых функциях нижнего уровня, я исключаю их из логики обновления и пишу для них модульные тесты.
  3. Я ищу повторяющуюся логику и делю ее из функции обновления на функции, которые можно объединить в цепочку для преобразования модели.
  4. Я даже не рассматриваю логику представления, пока бизнес-логика в основном не завершена. На мой взгляд, святой Грааль дизайна, основанного на типах / тестах, - это когда я могу протестировать всю бизнес-логику, не создавая представления. После завершения бизнес-логики я создаю представление и привязываю события HTML к сообщениям, и я вполне уверен, что все сработает с некоторыми проблемами.

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