😊

実行環境依存のコードに対してテストを書く考え方

2022/03/04に公開1

社内用の啓発記事ですが、閉じる理由がないのでここに投げます。

ブラウザにべったりなコードを書いてると、ブラウザや node.js 固有の環境をインラインで記述してしまうことが多々あると思います。

あえてダメダメなブラウザ向けのエントリポイントの例を書きます。

// main.ts
let id = localStorage.get('id');

if (!id) {
  id = `${navigator.userAgent}-${Math.random()}`;
  localStorage.set('id', id);

  fetch('/auth', {
    method: 'POST',
    credentials: 'include',
    body: JSON.stringify({ id, at: Date.now(),  }),
    headers: {'Content-Type': 'application/json'},
  })
  .then(res => res.json())
  .then(token => {
    localStorage.set('id', token.id);
    id = token.id;
    // ... ここから通常処理
  })
  .catch(() => {
    // request が fail temp id のまま続行
  })
  ;
} else {
  // ... 既にログインしてる場合
}

一旦 anonymous な ID を振って、ログインを試行してから処理を分岐するコードです。

ちょっと簡略化しすぎたり、今後の説明をするためのわざとらしいコードなんですが、主にテストを実施する視点でいくつか問題があります。

  • スクリプトを評価した段階で必ず実行されてしまうので、実行タイミングの制御を外部からコントロールできない
  • navigator などブラウザ固有の機能を参照してるので、ユニットテストを書く際に特殊なモック処理を必要とする
  • Date.now() が現在時刻に依存しているので、実行してみるまで値が確定しない
  • id を UA-乱数としているが、これはサーバー的に意味があるフォーマットなのか自明ではない。
  • localStorage という環境固有なストレージを直接触っている

じゃあどうするか、それを順を追って説明します。


注意: 概念的なコードで、最初の数ステップはサーバーが立ってることを前提にしますが、最終的にサーバーに頼らない実装になります。

1. 起動プロセスを外部から制御可能にする

とりあえず async 関数の中に突っ込みます。

// main.ts
export async function main () {
  let id = localStorage.get('id');
  if (!id) {
    id = `${navigator.userAgent}-${Math.random()}`;
    localStorage.set('id', id);
    try {
      const token = await fetch('/auth', {
        method: 'POST',
        credentials: 'include',
        body: JSON.stringify({ id, at: Date.now(),  }),
        headers: {'Content-Type': 'application/json'},
      }).then(res => res.json())
      // ... ここから通常処理

      localStorage.set('id', token.id);
      id = token.id;
    } catch (err) {
      // ... 認証できない場合
    }
  } else {
    // ... 既にログインしてる場合
  }
}

// 開発環境でいずれか

// vite や webpack 等で定数展開と一緒に使う場合
if (process.env.NODE_ENV !== "test") {
  main();
}

// バンドラに頼らないとき、 node 環境で jsdom 等なしでテストするとわかっている場合
if (typeof window !== "undefiend") {
  main();
}

async 関数で書き直して、 await しながら try catch で制御フローを書き直しました。そして関数の起動条件を分離しています。

少なくともこの段階でロード=評価ではなく、外部から実行を制御する余地が生まれます。

main.test.ts
// jest 等では NODE_ENV=test なので、読み込んだ時点では実行されない
import { main } from "./main";
import assert from "assert";

beforeEach(() => {
  // 説明用: この main を node 環境で動かすだけの雑なモック。真似しないように。
  globalThis.navigator = { userAgent: "test-ua" };
  globalThis.localStorage = new Map();
  globalThis.fetch = async () => () => ({ id: 'dummy' })
});

test("run main", async () => {
  await main();
  const id = localStorage.get("id");
  assert(id !== undefined);
});

これで、読み込みが終わったら id が空でない、というテストが書けるようになりました。
また、明示的に main 関数を try catch で処理することで、サーバーが立ってる、たってない状態に対応した異常系のテストも書けます。

副作用を分離する

この時点で参照透過性がない(=実行時に環境に依存する)部分は次の部分です。

  • localStorage
  • Date.now()
  • Math.random()
  • navigator.userAgent
  • fetch

これらを外から受け取れるようにしていきます。

とはいえ大人の事情でインターフェースを激しく変えてしまうと文句が出やすいです。なので最初はおとなしくデフォルト引数を使うことにします。

(fetch 以外を抽象します。fetch 抽象は別途やります)

// main.ts
export type Storage = {
  get(key: string): string | undefined,
  set(key: string, val: string): void
}

export async function main(
  storage: Storage,
  rand: number = Math.random(),
  ua: string = navigator.userAgent,
  now: number = Date.now()
) {
  let id = storage.get('id');
  if (!id) {
    id = `${ua}-${rand}`;
    storage.set('id', id);
    try {
      const token = await fetch('/auth', {
        method: 'POST',
        credentials: 'include',
        body: JSON.stringify({ id, at: now}),
        headers: {'Content-Type': 'application/json'},
      }).then(res => res.json())
      // ... ここから通常処理
      storage.set('id', token.id);
      id = token.id;
    } catch (err) {
      // ... 認証できない場合
    }
  } else {
    // ... 既にログインしてる場合
  }
}

//... 起動条件は略

localStorage と Map が似ているので、 お互いを満たすサブセットの Storage 型を定義して、テスト時は Map をキャストして使います。

main.test.ts
import assert from "assert";
import type { Storage } from "./main";
import { main } from "./main";

beforeEach(() => {
  globalThis.fetch = async () => Promise.reject();
});

test("id is ua-rand on fail", async () => {
  const storage = new Map() as Storage;
  await main(storage, 0.5, 'test', 0).catch(err => {});
  const id = storage.get("id");
  assert(id === `test-0`);
});

test("reuse id", async () => {
  const storage = new Map() as Storage;
  storage.set('id', 'predefined');
  await main(storage, 0.5, 'test', 0);
  const id = storage.get("id");
  assert(id === `predefiend`);
});

localStorage の代わりに Map を読み書きさせて、その値をテスト対象にします。
サーバーサイドの実装をせずとも、クライアントがレスポンスに応じて id を更新することが確認できます。

先程のテストと違い、 fail 時には設定された temp id が取れることがテストできるようになっています。
また、事前に id がセットされていれば、それを再利用する、というテストも書けるようになりました。

fetch を抽象する

fetch は環境依存です。実は最新の node v17 には fetch が実装されているのですが… そのことはともかく、ここを環境に応じて差し替えられるようにしておきます。

// main.ts
export type Storage = {
  get(key: string): string | undefined,
  set(key: string, val: string): void
}

export type Request = (method: 'GET' | 'POST', url: string, body: any) => Promise<any>;

const defaultRequest: Request = async (method, url, body) => {
  const res = await fetch(url, {
    method: method,
    credentials: 'include',
    body: JSON.stringify(body),
    headers: {'Content-Type': 'application/json'},
  });
  return = await res.json();
}

export async function main(
  storage: Storage = localStorage,
  request: Request = defaultRequest
  rand: number = Math.random(),
  ua: string = navigator.userAgent,
  now: number = Date.now()
) {
  let id = storage.get('id');
  if (!id) {
    id = `${ua}-${rand}`;
    storage.set('id', id);
    try {
      const token = await request('POST', '/auth', { id, at: now });
      // ... ここから通常処理
      storage.set('id', token.id);
      id = token.id;
    } catch (err) {
      // ... 認証できない場合
    }
  } else {
    // ... 既にログインしてる場合
  }
}
//... 起動条件は略

Request という型による契約を用意し、それを満たしたものをデフォルト引数で受け取るようにしています。

ここを差し替えることで fetch 以外の実装、例えば node http による実装や WebSocket の実装に差し替えることができるようになりました。

テストで便利なのは言わずもがな、実際にランタイムで必要な処理を切り替えることができます。例えば突然「ReactNative 化したい。その際の通信は Android の HTTP Client に処理を回してくれ」と言われても対応できるわけですね。

また fetch を JSON で送信する際の固有な処理、 body の JSON.stringify や Content-Type を、この defaultRequest の中に隠蔽できて、使いやすくなっています。

このテストコードを書いてみましょう。

main.test.ts
import type { Storage, Request } from "./main";
import { main } from "./main";

// ...
test("replace server side id", async () => {
  const storage = new Map() as Storage;
  const mockRequest = async () => ({id: 'xxx'});
  await main(storage, mockRequest, 0.5, 'test', 0);
  const id = storage.get("id");
  assert(id === `xxx`);
});

サーバーに依存せずに、レスポンスを使っていることがわかるようになりました。


ここからはやりすぎるアンチパターンを解説します。

アンチパターン: 過剰な自己完結

クライアントで完結するので、クライアント視点の開発はしやすくなりましたが、サーバーと協調できていないと無意味なインターフェースを実装してることになりかねません。

ここで必要になるのが、「契約による実装」という概念で、クライアントとサーバーのインターフェースを、実装ではない場所で定義して共有しておくことです。

契約プログラミング - Wikipedia

クライアント・サーバーともに typescript だったら型を再利用するだけでもいいかもしれませんが、他の場合だと GraphQL だったり、 Open API だったりするわけですね。

アンチパターン: 間違った DRY

Request インターフェースを満たした defaultRequest の実装でラップすることで、たしかに request 関数は見た目上使いやすくなりました。

ただ、開発が進むにつれて、頻出するアンチパターンがあります。

あえてよくないコードを例示します。

const defaultStorage = localStorage as Storage;
const defaultRequest: Request = async (method, url, body) => {
  const id = defaultStorage.get('id');
  const res = await fetch(url, {
    method: method,
    credentials: 'include',
    body: JSON.stringify(body),
    headers: {
      'Content-Type': 'application/json',
      'X-CUSTOM-DATA': defaultStorage.get('custom-data'),
      'Authorization': `Bearer ${id}`
    },
  });
  const data = await res.json();
  return data;
}

共通処理として、このスコープにない defaultStorage への参照と、ストレージから読みだした値として X-CUSTOM-DATA が追加されています。
これにより、外部の手続きとして custom-data が変わる可能性や、そもそもこのヘッダがいらないときに捨てることができなくなります。

ここからのさらに破綻するパターンは2つあり、大量の条件分岐の追加と、コピーからのフォークです。

大量の追加条件

// 条件が追加されて破綻するケース
const defaultRequest: Request = async (method, url, body, includeCustomData = true) => {
  const id = defaultStorage.get('id');
  const res = await fetch(url, {
    method: method,
    credentials: 'include',
    body: JSON.stringify(body),
    headers: {
      'Content-Type': 'application/json',
      'X-CUSTOM-DATA': includeCustomData ? defaultStorage.get('custom-data') : undefined,
      'Authorization': `Bearer ${id}`
    },
  });
  const data = await res.json();
  return data;
}

これは引数を一つ追加しただけですが、大量に追加されて、お互いの設定が印象するような時、とても扱いが辛い共有コードになります。

大量の重複 fork

元のコードをコピーして分岐するものが大量に作られます。

const defaultRequest: Request = async (method, url, body) => {
  const id = defaultStorage.get('id');
  const res = await fetch(url, {
    method: method,
    credentials: 'include',
    body: JSON.stringify(body),
    headers: {
      'Content-Type': 'application/json',
      'X-CUSTOM-DATA': defaultStorage.get('custom-data'),
      'Authorization': `Bearer ${id}`
    },
  });
  const data = await res.json();
  return data;
}

const defaultRequest2: Request = async (method, url, body) => {
  const id = defaultStorage.get('id');
  const res = await fetch(url, {
    method: method,
    credentials: 'include',
    body: JSON.stringify(body),
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${id}`
    },
  });
  const data = await res.json();
  return data;
}

利用者のドメイン理解が浅かったり、またはそもそも難しすぎる場合に発生します。

どうすればよかったのか

いずれも初期にどれだけ意識高く設計したとしても、それはチームとして共有しないと意味ない例です。

今回は fetch の抽象で説明しましたが、 MVC フレームワークのコントローラで共通の認証処理を書く際にもこの問題は発生しやすいです。

これに対応する方法は、実際ケースバイケースなのですが、「環境(ここではブラウザ)のための抽象」と「ドメインのための実装」を同時に行わない、というポリシーが有効だと自分は思っています。

自分ならこのようにします。

export type Request = (
  method: 'GET' | 'POST',
  url: string, body: any,
  body: any,
  headers?: any,
  init?: Omit<RequestInit, 'headers' | 'method' | 'body'>
) => Promise<any>;

const request: Request = async (method, url, body, headers, init = {}) => {
  const res = await fetch(url, {
    method,
    // default は omit
    credentials: 'omit',
    body: JSON.stringify({...headers, 'Content-Type': 'application/json' }),
    ...init
  });
  return await res.json();
}

const requestWithAuth = async (method, url, body, headers, init, storage = localStorage) => {
  const id = storage.get('id');
  return request(
    method,
    url,
    body,
    {
      'Authorization': `Bearer ${id}`
    },
    {
      credentials: 'include',
    }
  );
}

// const defaultRequest = requestWithAuth;

そのままの実装では headers を設定する余地がなかったので、それをオブジェクトとして渡すようにします。
(脱出ハッチとして、その他の RequestInit のパラメータも引き取るようにしたのですが、ここが環境固有の部分を生んでしまってます。どこまでコントロール可能にするかはトレードオフです)

その上で、Request のインターフェースを満たした requestWithAuth が追加のヘッダを設定して request のユーザーになります。

リクエストが認証機能を使うかどうかは、セキュリティ要件なので、明示的に requestWithAuth を要求しないと使えないことにします。

ただしそれを手間だと感じる人が多いようだったら、既定の defaultRquest を requestWithAuth に設定します。(セキュリティ要件次第です)

大事なのは、 request という環境を抽象したコア部分を再利用することと、アプリケーションのドメイン固有を別に行うことです。DDD だとインフラストラクチャ層だといったりしますね。とにかくここにビジネスロジックを書かないのが大事。

破綻した原因は、「fetch というブラウザ実装を扱いやすくする」という環境に対する目的と、「認証処理を共通化する」というドメインに対する目的が合体して、それ以外のユースケースに対応できなかったからです。

共通処理をまとめる DRY (Don't Repeat Yourself) の思想は、環境の軸と、アプリケーションドメインの軸で一緒に適応できない、というのは覚えておくと良いでしょう。

まとめ

  • 即時評価は制御できないので起動条件を外部からコントロールできるようにする
  • 最初はデフォルト引数でインターフェースを変えずに差し替えていく
  • 環境とドメインを分けて考える

こんな感じに整理できると思います。


後はいくつかのデフォルト引数化の例

現在時刻に関するコード

// BAD
export function getDayStart(): number {
  return new Date().setHours(0,0,0,0);
}
// GOOD
function getDayStart(at: number = Date.now()) {
  return new Date(at).setHours(0,0,0,0);
}

userAgent

navigator

// BAD
export function isSafari() {
  const userAgent = navigator.userAgent;
  return userAgent.match(/Safari/)
}
// GOOD
export function isSafari(ua = navigator.userAgent) {
  return ua.test(/Safari/);
}

(実装自体は簡略化してるのでマジなものではないです)

Discussion

woo-noowoo-noo

書きやすいテストの考え方、とても参考になりました。
body: any, が2つあったので、お手隙でご確認お願いします。(誤字・脱字を伝える導線が他にあったらすみません🙇‍♂️)

export type Request = (
  method: 'GET' | 'POST',
  url: string, body: any,
  body: any,
  headers?: any,
  init?: Omit<RequestInit, 'headers' | 'method' | 'body'>
) => Promise<any>;