153
161

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Elm2(完全版)Advent Calendar 2018

Day 22

僕の考えた最強のWEBアプリ設計・開発 in Elm

Last updated at Posted at 2018-12-22

ものづくりでこんな経験ありませんか?

個人開発でもプロダクト開発でも構いません。以下のような経験はありませんか・・・? 僕は・・・すべてあります・・・。この記事ではなるべくユーザの気持ちに立ち返り、結果としてすばやく・安定した開発をするための一つの案を**Elm**を中心として説明したいと思います。

  • 新しい技術を試している間に作ろうとしたものを見失う
  • コーディング以前に消耗してしまった
    • 以下のような技術を学んでいる途中でものづくりとはなにか?と哲学な気持ちに
      • UML(モデリング)
      • OOP(オブジェクト指向原則)
      • リーダブルコード(人によって違う可読性という謎の概念)
      • デザインパターン
      • 抽象設計
      • DDD
      • クリーンアーキテクチャ
  • 久々に触ったら何をやっているか、わからないし クソコードだし 新しく作り直した
  • 気合を入れて抽象化を頑張ったら結果的に無駄だった(これは良い設計だ、と心の中で思うことにした)
  • ひたすら動かしてコンソール(デバッガ)デバッグを繰り返した
  • 本当に作りたかったものってなんだったっけ・・・?となった

僕の開発フロー

僕の考えたと言いつつ何も考えていません。先人たちの素晴らしい考えを取捨選択してフローを構築しているに過ぎません。実際に開発フローがイメージできるようにお伝え出来たらなと思います。技術としては以下の成分が含まれます。

  • Elm
    • フロントエンド
    • 関数型プログラミング
  • アジャイル・スクラム開発
  • Acceptance Test Driven Development(ATDD)
  • Test Driven Development(TDD)
名称未設定 24 P1.png

要求を考えてみよう!

フローを考えるためにお題を決めましょう。誰でも絶対わかるという事でTodoリストを今回は採用しました。TodoMVCのようなものを想定しています。Todoリストとは何か?と言われたらなんと答えるでしょうか?これが最初のフローです!

「あー・・・うん・・・ タスクを管理するものだよ」

これではわかりませんね? タスクとはなんでしょう?管理とはどういう意味でしょう?TodoMVCの仕様をざっとまとめてみました。意外とできることが多いです・・・。

スクリーンショット 2018-12-22 18.01.25.png
  • タスク
    • (ユーザが)しなくてはならない事柄(作業)を文章にしたもの
    • タスクには2つの状態がある
      • Active(作業を終えていない状態)
      • Complete(作業を終えた状態)
  • 管理する
    • すべてのタスクがTodoリスト(一覧)で確認できること
      • タスクが並んでいることがわかる
      • タスクの状態がわかる(Active, Complete)
    • Todoリストのタスクを仕分けできること
      • Activeなタスクだけが並んでいることがわかる
      • Completeなタスクだけが並んでいることがわかる
    • タスクが作れること
      • Todoリストの先頭にActiveなタスクを追加できる
    • タスクを消せること
      • ひとつのタスクを消せること
      • Completeなタスクを一括で消せること
    • タスクの状態を一括で変えれること
      • すべてのタスクをActiveにする
      • すべてのタスクをCompleteにする
    • Activeなタスクが集計(カウント)できること

ここではやりたいことをなるべく明確にすることが重要です。しかし、すべてを出し尽くさなくても問題はありません。開発をしていく中で徐々に育てていきましょう。

要求をシナリオに落とす

さらに、要求をシナリオに落とします。シナリオに落とす際には、使われる様子をありありと目に浮かぶ状態でなければなりません。この段階の実装で見た目を完全に整える必要はありませんが、整えるほどユーザの視点に近づくのは間違いないでしょう。自分の場合は、要求を考える段階で絵をたくさん描いたりして、なるべく想像を膨らませています(iPadとAppleペンシルがおすすめです!)。

  • Todoリストの先頭にActiveなタスクを追加できる

todo-mvc.gif

それではイメージが整った段階で要求をシナリオとして書き下します。以下は、Gherkinと呼ばれるシンタックスでアジャイル開発などにおいてプロダクトを管理する人(Product Owner)やステークホルダー(開発関係者・顧客等)に受け入れてもらうための仕様(シナリオ)になります。これはプロダクトの道標そのものになります。自分が何を作っていたかを振り返ることができ開発中に「作りたかったものはなんだったっけ?」とはなりにくくなります。

  • Given: 前提
  • When: もし
  • Then: ならば
Feature: Todoアプリ

    Scenario: タスクの入力フォームは、Submit時にクリアされる。
        Given Todoリストアプリを開く
        When オートフォーカスされている入力フォームに、"new todo"と入力し、
        And エンターキーを押す
        Then 入力フォームから入力内容は消える

    Scenario: タスクの入力フォームに入力された内容が、Submit時にTodoリストの最後に追加される。
        Given Todoリストアプリを開く
        When オートフォーカスされている入力フォームに、"new todo"と入力し、
        And エンターキーを押す
        Then Todoリストの最後に、内容が"new todo"のActiveなタスクが追加される

上のようなフォーマットが用意されていることは喜ばしいのですが、大抵の場合、時間と共に内容は変更され陳腐化しがちです。そこでGherkinシンタックスを採用しているBDDツールを利用します。今回はおそらく一番メジャーであるCucumber(Java)とSeleniumを利用します。Gherkinシンタックスで書かれたfeatureファイルを書いた状態でCucumberを実行すると、メソッドのテンプレートが自動生成されます。あとは、以下の要領でテストを追加していきます。

  • Chromeでアプリを開く
  • イベントを起こす
  • assertで期待する結果になっているか確認する
public class BasicStepDefs {

    WebDriver driver = null;

    @Given("^Todoアプリを開く$")
    public void TODO_アプリを開き() throws Throwable {
        System.setProperty("webdriver.chrome.driver", "Driver/chromedriver");
        driver = new ChromeDriver();
        driver.get("http://localhost:8080/");
    }

    @When("^オートフォーカスされている入力フォームに、\"([^\"]*)\"と入力し、$")
    public void オートフォーカスされている入力フォームに_と入力し(String newTodoContent) throws Throwable {
        final WebElement newTodoInput = driver.switchTo().activeElement();

        newTodoInput.sendKeys(newTodoContent);
    }

    @When("^エンターキーを押し$")
    public void エンターキーを押すと() throws Throwable {
        final WebElement newTodoInput = driver.switchTo().activeElement();

        Thread.sleep(1000);

        newTodoInput.sendKeys(Keys.ENTER);
    }

    @Then("^入力フォームから入力内容は消える$")
    public void 入力フォームから入力内容は消える() throws Throwable {
        final WebElement newTodoInput = driver.switchTo().activeElement();

        Thread.sleep(1000);

        assertEquals("", newTodoInput.getAttribute("value"));
        driver.quit();
    }

    @Then("^Todoリストの最後に、内容が\"([^\"]*)\"のActiveなタスクが追加される$")
    public void Todoリストの最後に_内容が_のActiveなタスクが追加される(String newTodoContent) throws Throwable {
        final List<WebElement> todoList = driver.findElements(By.cssSelector("ul.todo-list > li"));
        final WebElement lastListItem = todoList.get(todoList.size() - 1);
        final WebElement itemCheckbox = lastListItem.findElement(By.tagName("input"));
        final WebElement itemLabel = lastListItem.findElement(By.tagName("label"));

        final String expected = "li-class=\"\", input-checked=null, label-text=\"" + newTodoContent + "\"";
        final String actual =
                "li-class=\"" + lastListItem.getAttribute("class") + "\", " +
                        "input-checked=" + itemCheckbox.getAttribute("checked") + ", " +
                        "label-text=\"" + itemLabel.getText() + "\"";

        Thread.sleep(1000);

        assertEquals(expected, actual);
        driver.quit();
    }
}

実際にはいきなりシナリオを追加するわけではなく、ひとつずつシナリオを追加し、それを実装していくベイビーステップが好ましいです。シナリオ(cucumber)を実行すると無事失敗(RED)します。これを通す(GREEN)ことで着実にものづくりが進んでると言う実感を得れるわけです。

次のステップでは実装を追加していくことで、シナリオを満たしていくことになります。まずはじめはどうしましょう? 設計をはじめることでしょうか? サーバサイドを実装することでしょうか? Firebaseなどの技術を選定することでしょうか? 思い出してください。既にユーザがそうなってほしいと言う姿を定義しました。ユーザに一番近いフロントエンドを実装することが一番の近道です。ここでユーザから距離が遠い箇所から始めてしまった場合、何を作っていたのか道を見失いやすかったり、必要のない実装をしてしまいがちです。あくまで今フォーカスしているシナリオを達成するためにしなければならないことを全力で駆け抜けることが大事です。それを満たしていればプロダクトが完成していることになります。

HtmlテストによるTDDサイクル

やっとElmのターンです! シナリオからイベントを発火する部分を抽出してきました。この部分を実装するためのテストを書きましょう。

  • When オートフォーカスされている入力フォームに、"new todo"と入力し、
  • And エンターキーを押す

Elmは、Htmlに対してを簡単にテストすることが可能です。上記2つのイベントに関して以下のようにテストすることができます。Elmはイベントを発火する場合にはMsgという物を発行することができます。Msgは自由に名前を付けることができるのでより良く抽象化されたコードになります。

テストコード

describe "The Main module"
        [ describe "todoHeaderView"
            [ test "タスク内容の入力時に、UpdateNewTask Msgが発行される" <|
                \_ ->
                    todoHeaderView ""
                        |> Query.fromHtml
                        |> Query.find [ Selector.tag "input" ]
                        |> Event.simulate (Event.input "new todo")
                        |> Event.expect (UpdateNewTask "new todo")
              , test "タスク内容の入力時に、エンターキー(KeyDownイベント)を押したとき、KeyDownNewTask Msgが発行される" <|
                \_ ->
                    let
                        simulatedKeyDownEventObject : Int -> Value
                        simulatedKeyDownEventObject key =
                            Encode.object
                                [ ( "keyCode"
                                  , Encode.int key
                                  )
                                ]
                    in
                    todoHeaderView ""
                        |> Query.fromHtml
                        |> Query.find [ Selector.tag "input" ]
                        |> Event.simulate (Event.custom "keydown" <| simulatedKeyDownEventObject enterKeyCode)
                        |> Event.expect (KeyDownNewTask enterKeyCode)
...

まずはテストを動かすための実装をしましょう。

最低限コンパイルが通るための実装コード

type alias KeyCode =
    Int

type Msg
    = UpdateNewTask String
    | KeyDownNewTask KeyCode

enterKeyCode : Int
enterKeyCode =
    13

todoHeaderView : String -> Html Msg
todoHeaderView newTask =
    header [ class "header" ]
        [ h1 [] [ text "todos" ]
        , input
            [ class "new-todo"
            , placeholder "What needs to be done?"
            , value newTask
            , autofocus True
            ]
            []
        ]

テストを走らせると無事失敗します。これもATDDと同様に小さな道標ですね!

それでは、以下のような変更を加えてテストを通しましょう!

+ onKeyDown : (Int -> msg) -> Attribute msg
+ onKeyDown tagger =
+     on "keydown" (Decode.map tagger keyCode)

todoHeaderView : String -> Html Msg
todoHeaderView newTodoContent =
    header [ class "header" ]
        [ h1 [] [ text "todos" ]
        , input
            [ class "new-todo"
            , placeholder "What needs to be done?"
            , value newTodoContent
            , autofocus True
            , onInput ChangeNewTodoItem
            , onKeyDown KeyDownNewTodo
+           , onInput ChangeNewTask
+           , onKeyDown KeyDownNewTask
            ]
            []
        ]

単体テストによるTDDサイクル

次は、イベントが発火したときに呼ばれる関数の実装をおこないます。もちろん、こちらのステップもTDDを利用しておこないます。アプリケーションの状態を表すModelにTodoリストを持たせて、エンターキーが押されたときの挙動をテストしています。ここで重要なのは、バリデーションや細かい異常系に関する部分についてのテストもしています。このような部分はシナリオで担保することができますが、アプリケーションの仕様として残しておくことに大きな価値が無いという点とシナリオテストの作成・維持コストや実行速度を加味すると、単体テストで保証するほうが良いことがわかるでしょう。一般にATDDによるテストの数よりTDDのテストの数の方が多くなるでしょう。

updateKeyDownNewTaskTest : TestCase -> KeyCode -> Model -> Model -> Test
updateKeyDownNewTaskTest testCase keyCode model newModel =
    test testCase <|
        \_ ->
            let
                actual =
                    updateKeyDownNewTask keyCode model

                expected =
                    newModel
            in
            Expect.equal actual expected
...
, describe "updateKeyDownNewTask"
            [ updateKeyDownNewTaskTest
                "押されたキーがエンターキーだったとき、Taskの内容は空になり、Activeなタスクとしてリストの先頭に追加される"
                enterKeyCode
                (Model "abc" [ Complete "def" ])
                (Model "" [ Active "abc", Complete "def" ])
            , updateKeyDownNewTodoTest
                "押されたキーがエンターキーではなかったとき、Taskの内容はそのまま"
                12
                (Model "abc" [])
                (Model "abc" [])
            , updateKeyDownNewTaskTest
                "Taskの内容が空かつ、押されたキーがエンターキーだったとき、タスクリストは変わらない"
                enterKeyCode
                (Model "" [])
                (Model "" [])
            , updateKeyDownNewTaskTest
                "Taskの内容が空白かつ、押されたキーがエンターキーだったとき、タスクリストは変わらない"
                enterKeyCode
                (Model " " [])
                (Model " " [])
            ]
...

Htmlテストと同様です。コンパイルが通る最低限の実装をしましょう。

type Task
    = Active String
    | Complete String

type alias Model =
    { newTask : String, todoList : List Task }

updateKeyDownNewTask : KeyCode -> Model -> Model
updateKeyDownNewTask keyCode { newTask, todoList } =
    { newTask = "", todoList = [] }

テストを通すためのコードを実装します。テストがあるので迷走することがありませんね!

updateKeyDownNewTask : KeyCode -> Model -> Model
updateKeyDownNewTask keyCode { newTask, todoList } =
    let
        trimmedTask =
            String.trim newTask
    in
    if keyCode == enterKeyCode && not (String.isEmpty trimmedTask) then
        { newTask = "", todoList = Active trimmedTask :: todoList }

    else
        { newTask = newTask, todoList = todoList }

実装の組み込み・シナリオを完成させよう!

今まで実装した関数を組み込んでいきます。

init : () -> ( Model, Cmd Msg )
init _ =
    ( { newTask = ""
      , todoList = []
      }
    , Cmd.none
    )

update : Msg -> Model -> ( Model, Cmd Msg )
update msg ({ newTask } as model) =
    case msg of
        UpdateNewTask task ->
            ( { model | newTask = task }, Cmd.none )

        KeyDownNewTask keyCode ->
            ( updateKeyDownNewTask keyCode model, Cmd.none )

-- viewの実装は省略

実装を組み込んだら、cucumberを実行するだけです! TDDをしっかり積み重ねてきたので、組み込み方さえ間違っていなかれば、受け入れテストで落ちる気がしませんね!

$ ./gradlew cucumber

開発のフローの感覚は掴めたでしょうか? あとは、このサイクルを繰り返してクルクル続けていくだけです。開発の目的を見失うこと無く、着実に一歩一歩進めることでしょう!

おまけ(コミットログ)

このフローを進めていく場合、TDDのサイクル毎にコミットを重ねて行くことをオススメします。そうすることで新規でプロダクト参加する人や数カ月後に開発を再開する自分に向けて、どのようにプロダクトが成長していったかを見るための最高の履歴となります!

まとめ

普段の開発方法と比べてどうだったでしょうか? 今まで冒頭に述べたような開発の苦しみが重くのしかかっていましたが、今回のフローを採用し始めてから開発が非常に楽しくなりました。プロダクトで成し遂げたかったことが、すべてテストによって保証されているため、あとから抽象化や設計を見直し放題なのです。また、シナリオで成し遂げたかったことがElmを利用すると、その本質だけを実装できる理想のツールであることがわかります。そのため敢えてElmの説明を深くはしませんでした。まだまだ開発における課題は多いですが、より良い手法を採用して本当のものづくりをしていきたいですね!

153
161
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
153
161

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?