🦁

(自分の) JavaScript のユニットテストの書き方

2022/03/22に公開約4,500字2件のコメント

(社内用ドキュメントの公開版)

テストのポリシー

前提として、ユニットテストを導入するコストを、限界まで低くすることを目指す。テストが根付いていない言語環境や文化では、放っておくとテストが書かれないまま実装が進行し、結果としてテスト不可能な巨大な雪だるまが完成する。こうなるとメンテコストが高いE2Eを大量に書かないといけなくなり、テストの実行時間が膨れ上がっていく。

そうなる前に、ユニットテストを書きやすい環境を維持し、ユニットテストとして問題を切り分けられるような環境を維持する。とにかく書きやすさを重視し、一つのユニットテストを書くオーバーヘッドを限界まで下げる。

最初の一つを早い段階で書く

自分の経験的には、ユニットとテストの最初の一つを書いたらあとは自然とその周辺で増えていく。サンプルがあったら人はコピペする。逆にいうと最初の一つを書かない限り一切書かれない。まず一つ用意するのが大事。

一つ、といっても結局実装する対象によってパターンがあり、パターンごとに雛形を用意する必要がある。

自分のやるフロントエンド領域だと、こういうパターンが多い。

  • プラットフォームに依存しない純粋なテスト
  • サーバーに依存するテスト(beforeAll/afterAll でモックサーバを差し込む)
  • ブラウザAPIに依存するテストのうち、jsdom を許容できるもの (jsdom + react 等)
    • navigator や location は信頼するのが難しい
  • Date モックに依存するテスト(@sinonjs/fake-timersvi.useFakeTimers())

もちろん作る対象によるので、都度作る。

E2E とユニットテストについて

ユニットテストと比較して E2E が重要ではない、というわけではないが、プログラマにとってはユニットテストの方が「安く、速く、うまい」。

E2E テストはブラックボックステストであり、実行において内部実装を知る必要はなく、開発者以外に向いた最終的な振る舞いを表現する。対して、ユニットテストはプログラマがそのプロジェクトを進行させるための「心理的安全性を担保する」ために必要なものと考えている。内部仕様を表現されていれば、機能変更やリファクタを安全に行うことが可能になる。

ユニットテストは E2E テストと独立しているが、間接的に E2E を通すために下支えとなる。責務を分割できない実装すると、自然に E2E が増えてしまう。つまり、理論的にはモジュールごとに正しい責務を分割できていれば、本来の目的である E2E テストは最小限で済むはずである。

その際の指標としてテストカバレッジがあるが、対象によるが 80~90% あたりにカバレッジをあげていくペイラインがあると思う。それを下回らないようにする。ブラウザ用のコードでも 60%切ってると危険。


実践

今回は vitest を使う。mocha, ava, jest と今まで使った中で一番体験がいい。 API が jest 互換なので、移行しやすい。今の jest は commonjs/ESM の混乱が直撃してしまっており、正直設定が面倒になってしまっている。vitest はある程度丸投げできる。

Vitest - A blazing fast unit test framework powered by Vite | Vitest

$ npm install -D vitest c8 # c8 はカバレッジを取るのに必要

移行しやすいといっても、グローバル変数が露出しておらず import { test, expect } from "vitest"; のように import が必要になる。自分はグローバルに突然生えるよりこっちのほうが何由来かわかって好き。

余談だが、次の node(18?) には組み込みテストランナーが入るらしい。Deno の影響を強く受けてる模様。

最初は雑に作る

最初は言語やフレームワークの便利機能等をあえて忘れて、あえて頭を悪くして愚直に書く。頭がいいアサーションも、頭がいい context も避ける。

テストは動作保証でありつつ、ついでにアプリケーション内で「もっとも信用できるコピペ元」にならなければならない、と自分は思っている。実装をラップした賢いアサーションも、特にカバレッジを上げるような場合は必要だが、それとAPIの使い方を表明するようなテストは別に書く。

今回は、説明用に足し算の関数 function add(a: number, b: number): number を実装するとする。

最初は実装用のファイルではなく、テストケースを書いた add.test.ts を作って、そのテストケース内部で仕様を満たすように実装してしまう。

add.test.ts
import { test, expect } from "vitest";

test('1+1=2', () => {
  expect(1 + 1).toBe(2)
});

npx vitest add.test.ts で実行。

このとき、 __tests__test/** ではなく、実装したいディレクトリで実装してしまってよい。

異論はあると思うが、テストが見えないディレクトリに隠蔽されていることで、見た目上きれいになっても、維持する対象という意識が薄れて、テスト意識が低い人に放置される害のが大きいと感じている。テストは消極的な存在ではなく、積極的に混ぜていったほうが逆にいいと考えている。

このとき、describecontext を積極的には使わない。ファイル名自体がテストのためのスコープを持つので、基本的にはそれで十分とする。 describe があるときは、専用の beforeEach 等があることを期待する。

関数化と実装の抽出

このうち、自分がほしい対象だけを関数に切り出す。

add.test.ts
import { test, expect } from "vitest";
import { add } from "./add";

function add(a: number, b: number): number {
  return a + b;
}

test('1+1=2', () => {
  expect(add(1 + 1)).toBe(2)
});

ここまで来て初めて実装用のファイルに切り出す。

add.ts
export function add(a: number, b: number): number {
  return a + b;
}
add.test.ts
import { test, expect } from "vitest";
import { add } from "./add";

test('1+1=2', () => {
  expect(add(1, 1)).toBe(2)
});

ここで意識することとして、expect() のアサーションも .toBe().equal() のような単純なものしか使わないようにしている。複雑なアサーションを使うと、実装者は気持ちいいかもしれないが、第三者には読めない。JSのテストフレームワークは常に流行が変動してるので、アサーションを覚えたところで持ち越せるノウハウが少ない。

昔、自分は標準の assert しか使わないようにしてたが、流石にフレームワーク組み込みのものを使わないと違和感が大きいと感じるようになった。例えば標準 assert にない .toSnapshot() 等を使いたくなると一貫性がなくなってしまう。

あと assert はESM対応できてないので、昔ミスってビルド後のランタイムに assert polyfill を巻き込んでしまったことがあった。

不安な箇所を潰す

今回の add 関数は単純すぎてあまり頑張る余地がないのだが、無限から無限引いたらどうなる? ってのをテストに足してみる。

import { test, expect } from "vitest";
import { add } from "./add";

//...

test('-∞ + ∞ = NaN', () => {
  expect(
    add(Number.Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY),
    'ref. https://tc39.es/ecma262/multipage/ecmascript-data-types-and-values.html#sec-ecmascript-language-types-number-type'
  ).toBe(NaN)
});

add が複雑になるに合わせて不安な箇所を潰す。

本来ならここでカバレッジが上がることを確認していく。

vitest --run --coverage

既にあるコードをテストに抽出する

今やった作業の逆をやる。本番コードにテストを書いてしまうのも、リファクタ中ならあり。

add.ts
export function add(a: number, b: number): number {
  return a + b;
}

// *** Test ***
import {test} from "vitest";
if (process.env.NODE_ENV === 'test') {
  test('1+1=2', () => {
    //...
  });
}

jest/vitest は NODE_ENV に test が入っているので、ランタイムで実行されないように分岐を入れてテストを書く。コード内にプライベートな関数やモジュールが多いとき、一旦こう書いてしまう。

フロントエンドでは、vitest の依存がビルドができないので、おそらく fail するが、 この書き方のように ESM treeshake + Terser がちゃんと効く保証をすれば、そのままビルドするのもできなくはない。現状だとあまり推奨しないが。

おわり

フロントエンド特有の事情もあったり、異論はあると思うが、少なくとも自分はユニットテストは自分自身のために書いていて、それが結果として全体最適になると思っている。

Discussion

移行しやすいといっても、グローバル変数が露出しておらず import { test, expect } from "vitest"; のように import が必要になる。

設定でグローバル変数として扱うこともできます!

https://vitest.dev/config/#globals
ログインするとコメントできます