Create a gist now

Instantly share code, notes, and snippets.

What would you like to do?

WIP某所で喋るための草稿。

Testable JavaScript

当たり前のことを書く。当たり前のことが、当たり前にできない人へ。JavaScriptだから、当たり前のことをしなくていいと思っている人達へ。

基本方針

  • それぞれのファイルは、可能な限り参照透過な関数を提供する
  • それぞれのファイルは、読み込んだだけでは副作用を起こさない
  • ユニットテストの対象と、クロスブラウザテストの対象を区別する

最初の状態

main.js
components/
   App.js

main.js

import React from "react";
import ReactDOM from "react-dom";
import App from "./components/App"

ReactDOM.render(<App/>, doucment.querySelector(".main"));

この際、Appはどうでもいい。非常に素朴な状態。

テスト可能、コントロール可能な構造へ

「scriptタグが読み込まれた状態は、まだDOMの読み込みが完了してない。」ということが明らかになった。 つまりは window.addEventListener("load", ...) が必要になる。

以下のように変更していく

main.js
lib/
  mount-app.js
components/
  App.js

lib/mount-app.js

import ReactDOM from "react-dom"
import App from "../components/App"
export default (el) => {
  ReactDOM.render(App, el);
}

main.js

import mountApp from "./lib/mount-app"

window.addEventListener("load", () => {
  const el = document.querySelector(".main")
  mountApp(el);
});
  • main.js が このアプリが React であることすら気にしなくなった
  • mountApp を呼ぶタイミングをコントロールできるようになった
  • mountApp のユニットテストを書きやすい
  • mountApp が複数回呼べる

mountApp のテストを書く

※なんとなくmochaを使ってる相当。

lib/mount-app.test.js

import assert from "assert"
import mountApp from "./mount-app"
it ("mount App to element", async done => {
  const el = document.createElement("div");
  await mountApp(el);
  assert.ok(el.innerHTML !== "");
});

mountApp は与えられたエレメントにAppを展開するということを保証したいので、こういうテストになるだろう。この際、どのようなComponentをマウントするかは、Appのテストの責務なので、ここでは気にしない。

しかし、mountAppをawaitしているが、実際はマウントするタイミングを外に教えていないので、次のように書き換える必要がある。

lib/mount-app.js

import ReactDOM from "react-dom"
import App from "../components/App"
export default (el) => {
  return new Promise(done => {
    ReactDOM.render(App, el, done);
  });
}
  • 空div に向けてrenderしたので, document.body に副作用を与えることなくテストが書けた
  • テストを書きながら、render が終わったことを観測するための機能が欠けてることに気づいた

もっと初期化構造を作る

App.js の初期化に、サーバー上の初期データがほしい、となった。 fetch-inital-state.js を追加する。

こんなファイルだとしよう。今回は何かしらの実装書くのが面倒なので、Flowの型を付与しながら記述する。

lib/fetch-initial-state.js

type AppState = {...};
export default async function fetchInitialState(): Promise<AppState> {
  const req = await fetch("/api/app-state");
  return req.json();
}

※RESTとか考えてない。適当。fetchの使い方もちょっとうろ覚え…

GET api/app-state がAppStateを満たすかは、サーバー側の責務だとして、ここでテストを書くのは、今のところ不適当。サーバーがNodeなら型を共有して、サバクラ両方で型を共有しながら書くことになる。

複数のエンドポイントを叩いて合成する場合、fetch を何らかの方法でモックして、テストを書く意味が出て来る。

main.jsはこうなるだろう。

import mountApp from "./lib/mount-app"
import fetchInitialState from "./lib/fetch-initial-state"

export async function main() {
  await document.ready;
  const data = await fetchInitialState();
  await mountApp(el, data);
  console.log("Done");
}

main();

※ mountApp が Appに data を渡す部分は省略

GET /api/app-state さえモック出来ればテストを書くことはできるだろうが、やるにしてもE2Eかブラウザテストの範疇にあり、個別の責務の分解にだけ気を使う。export してるのはテスト用バックドア。

main.js のブラウザテスト

main.test.js

import {main} from "./main"
it("kick off app", () => {
  stubFetch(); // fetchをスタブする何かの操作
  await main();
})

これは、ユニットテストと同じテストランナーである必要がない。クロスブラウザ用で、ここだけ karma で記述する、などの選択肢がある。

なぜやるか

  • 初期化が終わることを確認したい
  • とりあえずカバレッジを増したい
  • 任意の初期状態を用意し、そこからどのように振る舞うか確認したい
  • クロスブラウザで、構文エラーなどで止まったりしないか、非互換APIを叩いていないか確認したい

自分の考え方

  • E2E/クロスブラウザテストはコストが高い。可能な限り、UniversalなJavaScriptとして、Node/V8でユニットテストする
  • EcmaScriptとしてのクロスブラウザ対応は、Babelの範疇とする
  • 設計変更の為の担保は、テストよりFlow/TypeScriptの型によって担保する
  • GUIの副作用、イベントの発生方法は、事前にそのコードを予測するのが困難で、TDDは機能しない。テストアフターで書く。
  • 初期化フローを分解することで観測点、副作用点を明らかにしていく
  • DOMに強く依存するコードは、クロスブラウザテストに含める
  • React(の仮想DOM)は、ブラウザのDOMに非依存としてブラウザテストしない
  • ReactはEnzymeでテストする
  • src/*test/* で対応するディレクトリに置くより、src/foo.jssrc/foo.test.js と併置する方がいい。なぜならNodeは相対パス解決が他の言語より大変なので。だるいとテストが書かれなくなっていく。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment