Elmアプリに対するテストをDOM Testing Libraryを使って書く

趣味で書いているnekobitoというElm製のアプリケーションに、テストコードをちゃんと書こうと思いまして、どういった方針で書き進めていくか考えました。また、DOM Testing Libraryを使うことにしたので、そのセットアップなどについて書きました。

今回書いたテストコードのスクリーンショット

何をテストしたいか

関数単位のユニットテストと、インテグレーションテストの両方を書きたいと思いました。そして、どちらかというとインテグレーションテストを厚めに書きたいです。nekobitoはElmコードから触れない領域(localStorageやFilesystem Access API)を扱っているので、portsが多用されています。portsでのやり取りも含めて、全体がうまく動いていることを確認するテストが必要です。

ユニットテスト

まずはユニットテストです。これにはelm-explorations/testを使います。たとえばnekobitoでは、portを介してJavaScriptの世界から受け取った値をElmの型に変換する必要があります。そこの処理に対して、以下のようなテストコードを書きました。

suite : Test suite = describe "decode" [ test "decode JSON value into note" <| \_ -> let value = Encode.object [ ( "name", Encode.string "note name" ) , ( "lastModified", Encode.int 1677303425173 ) , ( "text", Encode.string "note content" ) ] in case Note.decode value of Result.Ok v -> Expect.equal v { name = "note name", lastModified = Just 1677303425173, text = "note content" } Result.Err _ -> Expect.fail "failed to decode JSON value into note" ]

elm-testではインテグレーションテストは書けない

次に、インテグレーションテストです。画面のテストという意味ではelm-testのQueryというモジュールを使ってテストを書けますが、

test "The list has both the classes 'items' and 'active'" <| \() -> div [] [ ul [ class "items active" ] [ li [] [ text "first item" ] , li [] [ text "second item" ] , li [] [ text "third item" ] ] ] |> Query.fromHtml |> Query.find [ tag "ul" ] |> Query.has [ classes [ "items", "active" ] ]

見た感じ、これは単体テストです。マークアップやクラス名を指定して確認しているため、実装の詳細部分を意識してテストコードを書くことになりそうです。

DOM Testing Libraryを使う

さて、ここでこの記事のタイトルであるDOM Testing Libraryです。実は業務でReact Testing Libraryを使っていて、それをElmアプリケーションに対して使えないかなと思ってみたところ、フレームワークに限らず使えそうな名前のパッケージがあったため、これを使えるのでは、と思いました。ちなみにVue Testing Library、Reason Testing Library、Angular Testing Libraryなどはあったのですが、Elm Testing Libaryはありませんでした。

Elm Testing Libraryを作る?

ElmでもTesting Libraryを使いたい人はいるかもしれないのでこれができると最高なのですが、ゴールデンウィークは限られています。いったん考えないことにしました。

テストランナーJestのセットアップ

JavaScriptのテストランナーが必要ですので、Jestのセットアップを行います。jestをインストールして、jest --initjest.config.jsを出力しました。また、モジュール周りをESM形式にしたいのでBabelも利用します。

parcelのビルドをBabelに邪魔させない

nekobitoではParcelを使ってビルドを行っていますが、babel.config.jsがあると、意図しない挙動になってしまうようです。なので、回避策としてこちらのリンクの通りに.parcelrcを設定。

{ "extends": "@parcel/config-default", "transformers": { "*.js": [ "@parcel/transformer-js" ] } }

Elmアプリケーションのテストコード内での実行方法

ビルド時はimport { Elm } from "../elm/Main.elm";のように書いてParcelにうまいことしてもらってるところですが、テストコードでこれをうまいこと解決する方法が思いつきませんでした。ただ、ElmはコンパイルしてJavaScriptを吐き出します。少し乱暴かもしれませんが、このJavaScriptをテストコード内で読み込んで、evalすれば良いと考えました。つまり、こういうことです。

async function runElmApp() { // 予めビルドしておいたHTMLとJSをファイルから文字列として読み込む const htmlString = await fs.readFile("build/index.html", 'utf-8') const jsString = await fs.readFile("build/index.45c1912d.js", 'utf-8') // HTMLは文字列からDOMにパースしてdocument.bodyにappend const dom = new DOMParser().parseFromString(htmlString, 'text/html') const root = dom.querySelector('#root') document.body.appendChild(root) // JSはそのままevalで実行 eval(jsString) return root }

テストコード内でElmアプリケーションを実行できるようになりました。ただ、一つ問題があります。

const jsString = await fs.readFile("build/index.45c1912d.js", 'utf-8')

このJSファイル名を、動的に決める必要があります。なので、

const JS_FILENAME = execSync("find build/*.js | head -1").toString().trim() // "build/index.xxx.js" const jsString = await fs.readFile("build/index.45c1912d.js", 'utf-8')

こちらもちょっと乱暴ですが、上記のようにbuildディレクトリの中身からファイル名を検索して取得すれば良いです。

インテグレーションテストを書く

上記のrunElmAppで吐き出したDOMに対して、以下のようにテストコードを書いていけるようになりました。たとえば、以下はテキストエリアが表示されてplaceholderが入っていることを確認するテストです。

test('Show textarea', async () => { const container = await runElmApp() const textarea = await findByRole(container, 'textbox') expect(textarea).toHaveAttribute("placeholder", "# Markdown text here") })

これでめでたく、DOM Testing Libraryを使ったテストを実行することができました。

PASS src/js/index.test.js ✓ Show textarea (41 ms) Test Suites: 1 passed, 1 total Tests: 1 passed, 1 total Snapshots: 0 total Time: 0.433 s, estimated 1 s Ran all test suites.

今回のセットアップはこちらのコミットで見ることができます。