WIP某所で喋るための草稿。
当たり前のことを書く。当たり前のことが、当たり前にできない人へ。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 が複数回呼べる
※なんとなく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.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.js
とsrc/foo.test.js
と併置する方がいい。なぜならNodeは相対パス解決が他の言語より大変なので。だるいとテストが書かれなくなっていく。