2021-09-17

いちばんシンプルにStimulus ControllerをMochaでテストする方法

Stimulus: A modest JavaScript framework for the HTML you already have.

Stimulusはテストしにくい

Stimulus は DOM の影響範囲を閉じ込めつつ、reactive system を強制せず、バックエンドとフロントエンドに無用な断絶を生まない、実に reasonable な選択肢として使い勝手がよいが、一方で生 DOM を使ったり DOM と Controller の bind がマジカルに解決されてしまい、ユニットテストを書きにくいという課題を抱えている。

StimulusのControllerを実際にテストする際に必要なもの

2021年9月現在、ライトな JavaScript のテスト環境としては Node.js を利用するのがよくあるパターンと思われる。しかし Stimulus は Virtual DOM を使わないので Node.js 上でテストするには以下の準備が必要になる。

※ なお、今回はテスティングフレームワークとして Mocha を利用する。これは Jest を使うよりどんな準備が必要かを分かりやすくするためである。

今回用意したバージョンは以下の通り。

  • Mocha 9.1.1
  • JSDOM v17
  • jsdom-global 3.0.2

生DOM部分のテストではjsdom-globalが便利だけど注意も必要

コードがシンプルになるのは以下のように実行して global に window, document オブジェクトを利用できるようにする方法になる。

mocha -r jsdom-global/register

こうすると

テストケースのコードの中ではどこでも documentwindow へアクセスすることができる

逆に言うと、

letvar で初期化してはいけない

ことになる。せっかく inject しておいた document や window が壊れてしまう。

jsdom-globalを利用することについて

賛否はあるとは思うが、DOM に依存するコードを書く際に、完全に scope を閉じ込めようとするとかえって都合が悪いこともある。

これは JavaScript を書くすべての人に通用するノウハウではないかもしれないが、3rd party のコードを inject しながらその機能を利用したコードを書くことはよくある。このような場合、現実世界では DOM は global で巨大な singleton であり、いくつも生成できるものではないし、3rd party のコードがそもそもその global な singleton であることに依存していたり、うっかり複数回初期化すると動作が壊れる可能性もある。(例えば Google の global site tag によって定義される gtag() をうっかり複数回呼ぶと動作が意図と食い違う可能性がある。)

ということで、この本物の DOM の特徴に合わせておくと DOM にアクセスする function を新たに定義してテストコードから呼びたいという場合にも特別なことをする必要がなくなる。

ただし、当たり前だけどこういうコードは並列実行できない。Facebook が Virtual DOM を作り Jest を用意してるのはこういう理由なんだね。

具体的なテストコード

jsdom-globalを利用しつつテスト対象のDOMを組み立てる

Mocha で BDD-style で書いている場合は以下のように document の中身を書き換えてしまうとよい。

beforeEach(() => {
  document.body.innerHTML = `
<div data-controller="some">
  <button data-action="some#upCount">up !</button>
</div>
`
})

Stimulusのアプリケーションをテストする方法

必要な準備は以下の二つ

  1. MutationObserver を Node.js の文脈で global access できるように
  2. DOM 書き換えと controller の register からの bind の queue をちゃんと待つ

1 については以下のコードで実現できる。

before(() => {
  global.MutationObserver = window.MutationObserver
})

これは Stimulus が DOM の MutationObserver に無条件に依存したコードになっているので、Node.js で動かす場合に global にぶら下げ直す必要があるからである。

2 については setTimeout(() => {}, 0) が必要ということ。具体的には以下のようなコードになる。

it('', () => {
  const app = Application.start()
  app.register('some', SomeController)

  setTimeout(() => {
    document.querySelector('button').click()
  }, 0)
})

まとめ

今回は Stimulus を利用したコードのテスト方法として

  • Mocha
  • jsdom-global

を利用したシンプルな方法を紹介した。DOM が gloal な singleton であることを前提にした方法だが、一方で並列実行には向いていないので、JavaScript が本当に大規模になったらこの方法は合わないかもしれない。

もっとも並列実行に向いていないのは DOM を直接書き換えている部分だけなので、それ以外を並列実行するように設定するという方法でもよいかもしれない。(Controller の中で I/O などに直接タッチしてるとダメだけど、これはちゃんと DI してテストの時に差し替えるとか、作り方を注意すればいいだけ。)

About

例によって個人のなんちゃらです