Edited at

Elm TDDしながらチャットコメントを実装する


この記事はElm+Firebaseでチャットアプリを作るのエントリーです。

前記事 : Elmでビュー(チャット画面)を作る

次記事 : Elm TDDしながらチャットコメントの投稿を実装する



この記事で行うこと

本稿では、TEA (The Elm Architecture)とスナップショットテストをしながら実装を進めていく方法をお伝えします。


実装内容


TEAを利用したコメント反映

以下のような流れで実装をしていきます。TDDを交えながらステップバイステップで見ていきましょう。

フォームのInputイベント -> Msg発行 -> updateによるMsgのハンドリング, Modelの更新 -> Modelをviewに反映


コメントフォームにおけるInputイベントの実装

コメントフォームにおいて、Inputイベントが起きていることをテストします。まずは前回のviewからchatFormと言う名前でイベントを起こしたい箇所を抽出します。Elmでは、viewは関数と配列によって構成されているに過ぎないので関数として切り出すのはとっても簡単です。


Main.elm

-- テスト対象の関数をexportする

module Main exposing (Msg(..), chatForm)

...

type Msg
= UpdateContent String

view : Model -> Html Msg
view model =
div [ class "page" ]
[ section [ class "card" ]
[ div [ class "card-header" ]
[ text "Elm Chat"
]
, div [ class "card-body" ]
[ div [ class "media" ]
[ div [ class "media-left" ]
[ a [ href "#", class "icon-rounded" ] [ text "S" ]
]
, div [ class "media-body" ]
[ h4 [ class "media-heading" ] [ text "Suzuki Taro Date:2016/09/01" ]
, div [] [ text "この文章はダミーです。文字の大きさ、量、字間、行間等を確認するために入れています。この文章はダミーです。文字の大きさ、量、字間、行間等を確認するために入れています。" ]
]
]
, hr [] []
, div
[ class "media" ]
[ div [ class "media-body" ]
[ h4 [ class "media-heading" ] [ text "Tanaka Jiro Date:2016/09/01" ]
, div [] [ text "この文章はダミーです。文字の大きさ、量、字間、行間等を確認するために入れています。" ]
]
, div
[ class "media-right" ]
[ a [ href "#", class "icon-rounded" ] [ text "T" ]
]
]
]
]
, section []
-- ここを関数化
[ chatForm
]
]

-- Form部分を切り出し
chatForm : Html Msg
chatForm =
form [ class "chart-form pure-form" ]
[ div [ class "input-group" ]
[ input [ type_ "text", class "", placeholder "Comment" ] []
, button [ class "pure-button button-secondary" ] [ text "SNED" ]
]
]


期待をテストで表現します。イベントが起きたときには、後述するupdate関数でハンドリングするためにMsgを発行します。今回の場合、chatFormのviewから対象となるタグを辿っていってイベントをシミュレートし、UpdateContent "abc"というMsgが発行されることを期待します。


Tests.elm

module Tests exposing (suite)

import Expect exposing (Expectation)
import Main exposing (..)
import Test exposing (..)
import Test.Html.Event as Event
import Test.Html.Query as Query
import Test.Html.Selector as Selector

suite : Test
suite =
describe "The Main module"
[ describe "chatForm"
[ test "フォームに 'abc' と入力したら UpdateContent 'abc' の Msgが発行される" <|
\_ ->
chatForm
|> Query.fromHtml
|> Query.find [ Selector.tag "input" ]
|> Event.simulate (Event.input "abc")
|> Event.expect (UpdateContent "abc")
]
]


良い感じでREDとなりました。

スクリーンショット 2018-12-29 22.55.41.png

GREENになるようにonInputイベントを追記しましょう。


Main.elm

import Html.Events exposing (onInput)

...

chatForm : Html Msg
chatForm =
form [ class "chart-form pure-form" ]
[ div [ class "input-group" ]
[ input [ type_ "text", class "", placeholder "Comment", onInput UpdateContent ] []
, button [ class "pure-button button-secondary" ] [ text "SNED" ]
]
]


GREENですね。

スクリーンショット 2018-12-29 22.58.07.png


Modelの反映

Inputイベントがテストできたので、Modelの定義とupdate関数の定義、viewの更新をしていきましょう。


Main.elm

type alias Model =

{ content : String }

init : () -> ( Model, Cmd Msg )
init _ =
( { content = "" }, Cmd.none )

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
-- イベントで受け取った値でモデルを更新
UpdateContent c ->
( { model | content = c }, Cmd.none )

view : Model -> Html Msg
view model =
div [ class "page" ]
[ section [ class "card" ]
[ div [ class "card-header" ]
[ text "Elm Chat"
]
, div [ class "card-body" ]
[ div [ class "media" ]
[ div [ class "media-left" ]
[ a [ href "#", class "icon-rounded" ] [ text "S" ]
]
, div [ class "media-body" ]
[ h4 [ class "media-heading" ] [ text "Suzuki Taro Date:2016/09/01" ]
, div [] [ text "この文章はダミーです。文字の大きさ、量、字間、行間等を確認するために入れています。この文章はダミーです。文字の大きさ、量、字間、行間等を確認するために入れています。" ]
]
]
, hr [] []
, div
[ class "media" ]
[ div [ class "media-body" ]
[ h4 [ class "media-heading" ] [ text "Tanaka Jiro Date:2016/09/01" ]
-- ここが変更点
, div [] [ text model.content ]
]
, div
[ class "media-right" ]
[ a [ href "#", class "icon-rounded" ] [ text "T" ]
]
]
]
]
, section []
[ chatForm
]
]


毎回model.contentとするのは面倒な為、パターンマッチで中身だけを取り出しましょう。


Main.elm

view { content } =

...
, div [] [ text content ]



実行結果

これがTEAを利用したWEBアプリケーションの構築になります。最初は慣れるまで時間が掛かりますが、すべての工程がTEAに乗っかっていくので無駄な思考が要らなくなります。また、テストとコンパイラの強力さがあるため、その2つが通ってさえいれば間違いなく動き、実行時エラーが起きないことが保証されます。またElmのデバッガはとても便利で発行されたMsgとそのときのModelをトレースすることができます。

chat.gif


List.map によるレコードとHtmlの変換


CommentレコードとHtmlの変換

Modelのときと同様にCommentというレコードを定義します。mediaView関数は、Commentを受け取りチャットのコメントを表すviewを表現します。今回もTDDで実装するため中身は空にします。


Main.elm

module Main exposing (Comment, Msg(..), chatForm, mediaView)

type alias Comment =
{ name : String, content : String }

mediaView : Comment -> Html Msg
mediaView comment =
div [ class "media" ]
[ div [ class "media-left" ]
[ a [ href "#", class "icon-rounded" ] [ text "S" ]
]
, div [ class "media-body" ]
[ h4 [ class "media-heading" ] [ text " Date:2018/12/29" ]
, div [] [ text "" ]
]
]


テストを書きます。suzukiComment =

mediaView (Comment "Suzuki Taro" "コメントです。")
に対して、名前とコメントがHtml上に表示されていることを期待するテストを書きます。


Tests.elm

module Tests exposing (suite)

import Expect exposing (Expectation)
import Main exposing (..)
import Test exposing (..)
import Test.Html.Event as Event
import Test.Html.Query as Query
import Test.Html.Selector as Selector

suite : Test
suite =
describe "The Main module"
[ describe "chatForm"
[ test "フォームに 'abc' と入力したら UpdateContent 'abc' の Msgが発行される" <|
\_ ->
chatForm
|> Query.fromHtml
|> Query.find [ Selector.tag "input" ]
|> Event.simulate (Event.input "abc")
|> Event.expect (UpdateContent "abc")
]
, describe "mediaView" <|
let
suzukiComment =
mediaView (Comment "Suzuki Taro" "コメントです。")
in
[ test "コメントしたのは、「Suzuki Taro」だ。" <|
\_ ->
suzukiComment
|> Query.fromHtml
|> Query.find [ Selector.class "media-body" ]
|> Query.find [ Selector.tag "h4" ]
|> Query.has [ Selector.text "Suzuki Taro Date:2018/12/29" ]
, test "コメント内容は、「コメントです。」だ。" <|
\_ ->
suzukiComment
|> Query.fromHtml
|> Query.find [ Selector.class "media-body" ]
|> Query.find [ Selector.tag "div" ]
|> Query.has [ Selector.text "コメントです。" ]
]
]


期待通りテストが失敗しました。

スクリーンショット 2018-12-29 23.45.47.png

スクリーンショット 2018-12-29 23.45.56.png

テストが通った為、TEAに組み込んでいきます。Modelにcommentsを初期値として追加しましょう。viewではテストしたmediaViewに対して複数のCommentを渡していくので、List.mapを利用しましょう。型として見ると以下のように変換されているわけですね。List同士の連結には、(++)を利用します。

List Comment -> List (Html Msg)


Main.elm

type alias Model =

{ content : String, comments : List Comment }

init : () -> ( Model, Cmd Msg )
init _ =
( { content = ""
, comments =
[ Comment "Suzuki Taro" "1つ目のコメントです。"
, Comment "Suzuki Taro" "2つ目のコメントです。"
, Comment "Suzuki Taro" "3つ目のコメントです。"
]
}
, Cmd.none
)

view : Model -> Html Msg
view { content, comments } =
div [ class "page" ]
[ section [ class "card" ]
[ div [ class "card-header" ]
[ text "Elm Chat"
]
-- ここが変更点。
, div [ class "card-body" ] <|
List.map mediaView comments
++ [ hr [] []
, div
[ class "media" ]
[ div [ class "media-body" ]
[ h4 [ class "media-heading" ] [ text "Tanaka Jiro Date:2016/09/01" ]
, div [] [ text content ]
]
, div
[ class "media-right" ]
[ a [ href "#", class "icon-rounded" ] [ text "T" ]
]
]
]
]
, section []
[ chatForm
]
]


アプリケーションを起動すると、無事反映されていることがわかりました。

スクリーンショット 2018-12-29 23.58.06.png


ソースコード

この時点でのソースコード