0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

テストのためのexportが許されるか

0
Posted at

テスト実装のレビューをしている時、謎の定数がテストファイル内で定義されているのを発見しました。何かと思ったら、テスト対象ファイルで定義されている定数を使いたかったが、これがexportされていなかったため、仕方なくテストファイル内で再定義したとのことでした。

テスト対象ファイルの方で定数値が変更された時、テストファイルの方も変更しないといけないのはちょっとだるいな、と思いましたが、でもテストファイルで使いたいという理由でexportするのものな・・・、とも思いました。

この疑問を解決すべく

  • そもそもテストのためのexportは許されるのか
  • どのような理由で許されないのか
  • 許される場合はあるのか

について考えをまとめることにしました。

そもそもテストのためのexportは許されるのか

結論、基本的には許されません。

exportすると、その値や関数は少なくとも同一コードベース内の他モジュールから参照可能になります。テスト都合だけで公開範囲を広げると、モジュールのカプセル化を弱めることになります。

具体的なコードを見てみましょう。

// userService.ts
const normalizeName = (name: string) => {
  return name.trim().toLowerCase();
};

export const createUser = (name: string) => {
  const normalizedName = normalizeName(name);

  return {
    name: normalizedName,
  };
};

この場合、基本は normalizeName を直接テストせず、createUser 経由で振る舞いを確認します。

describe("createUser", () => {
  it("名前を正規化する", () => {
    expect(createUser("  Taro  ")).toEqual({
      name: "taro",
    });
  });
});

これには大きなメリットがあります。内部実装を

const normalizeName = ...

から

const normalizeUserInput = ...

のように変更したり、関数自体をなくしたりしても、createUserの外部仕様が変わっていなければテストを修正する必要がありません。

テスト対象を「観察可能な振る舞い」のみにする、ということを聞いたことがあるかもしれません。これは今回の例では「外部公開(export)されているものは外部から観察できるが、createUserの中で呼び出されているnormalizeNameは外部から観察できない。つまりnormalizeNameは「観察可能な振る舞い」ではないのでテストしない、ということです。

そもそも、「観察可能な振る舞い」であるcreateUserについて、inputに対するoutputのテストがしっかりできていれば、実質normalizeNameのテストもできたことになります。

createUserが仕様通りに機能するには、normalizeNameが仕様通りに動作しなければなりません。そのため、createUserが正常ならnormalizeNameも正常だろうということが推測できます。

内部実装が複雑でテストしたい時は・・・?

先程例に挙げたnormalizeNameはそこまで複雑な処理を行なっていませんでした。ですが、システム上かなり重要なロジックが内部実装に存在し、これのテストをどうしても実装したくなることがあります。

解決(?)方法として以下の3点があるかと思います。

  • 独立したモジュールとして切り出す
  • 内部実装のテストを諦める
  • Vitest / Jestの特殊な仕組みを使う

独立したモジュールとして切り出す

テストのために内部実装をexportするのではなく、独立したモジュールとして切り出します。

先ほどの例で言うと、以下のような状況に修正になります。

// normalizeName.ts
export const normalizeName = (name: string) => {
  // とても複雑で重要なため、テストしたいロジック
};
// userService.ts
import { normalizeName } from "./normalizeName";

export const createUser = (name: string) => {
  const normalizedName = normalizeName(name);

  return {
    name: normalizedName,
  };
};

この場合の export は「テストのため」ではなく、責務を独立したモジュールとして切り出した結果なので自然です。

ただ、normalizeNameがcreateUserでしか使われていない場合、どうしてわざわざ切り出されているのかわからなくなる可能性があります。やっていることがuserService.tsでnormalizeNameをexportしたときとあまり変わらない気もします。

ここで重要なのが、normalizeNameが独立したモジュールとして切り出すに値する責務を持っているかどうかです。

そもそもnormalizeNameをテストしたくなったのは、normalizeNameが複雑かつ重要なロジックを持っているからでした。そのようなロジックは、userService のライフサイクルや責務から離れた独自の責務を持つことが多いです。

  • 複数箇所から使われる可能性がある
  • 独自の仕様・境界値・分岐を持つ
  • userService のライフサイクルや責務と切り離して考えられる

などが当てはまるのなら、ユーザーについての責務(userService)ではなく、名前正規化という独自の責務を持っているように見えます。

例に挙げたコードではそこまで推し量ることはできませんが、開発時に内部実装をテストしたくなった時、別の責務のモジュールとして切り出すことを検討するのは価値があると思います。

内部実装のテストを諦める

別モジュールとして切り出すほどのものではないと判断したのなら、その内部実装はわざわざ単独のテストを必要としていないと割り切るのもありかと思います。

前述の通り、公開されているAPIのテストをしっかり行うことで、内部実装もテストされます。

あなたの心配は、杞憂かもしれません。

Vitest / Jestの特殊な仕組みを使う

テストツールで、内部実装をテストする方法が用意されていることもあります。今回はVitestのIn- Source Testingについて簡単に紹介します。

公式:https://vitest.dev/guide/in-source.html

VitestのIn-Source Testingでは、本番コードと同じファイル内にテストを書くことで、内部実装のテストを行います。

// userService.ts

const normalizeName = (name: string) => {
  return name.trim().toLowerCase();
};

export const createUser = (name: string) => {
  return {
    name: normalizeName(name),
  };
};

if (import.meta.vitest) {
  const { describe, it, expect } = import.meta.vitest;

  describe("normalizeName", () => {
    it("前後の空白を削除して小文字にする", () => {
      expect(normalizeName("  Taro  ")).toBe("taro");
    });
  });
}

テストコードと normalizeName は同じスコープにいるので、export する必要がありません。

ただ、これだとテストコードがビルド対象になってしまいます。

vite.config.tsを以下のようにすることで、テストコードは本番ビルドから除外できるので、In-Source Testingを採用する場合は、忘れずに設定しておきましょう。

// vite.config.ts
import { defineConfig } from "vite";

export default defineConfig({
  define: {
    "import.meta.vitest": "undefined",
  },
});

この方法により、内部実装のテストができるようになりました。ただ、前述の通り、そもそも内部実装のテストはしない方がいいです。内部実装をテストすると、テストのクオリティが劣化し、結果として

  • 振る舞いが変わっていないのに、リファクタリングでテストが壊れる
  • 本番コードが壊れているのに、実装詳細だけを確認するテストは通ってしまうことがある

という、テストが信用できない状況になってしまいます(参考)。

Vitest公式も、In-Source Testingの用途として、

  • 小さなスコープの関数・utility
  • プロトタイピング
  • inline assertion

などを挙げています。

本題: テストのためのexportが許される場面は?

理想論っぽいものを紹介してきましたが、現実が理想とはかけ離れた状態になることはしばしばあります。

そのため、テストのためのexportは絶対に禁止というより、デメリットを理解した上で、他の選択肢よりも現実的である場合に採用される妥協策と考えるのが良いかなと思いました。

個人的には、以下のような場合であれば検討の余地があると思います。

  • レガシーコードをリファクタリングする前にテストを追加したい
  • 公開API経由でのテストが極端に重い
  • テスト専用であることが明示できる

レガシーコードをリファクタリングする前にテストを追加したい

既存コードに対して後からテストを追加する場合です。

例えば、以下のような巨大な関数が存在するとします。

// legacyService.ts

const calculateSomething = (...) => {
  // 複雑な処理
  // 大量の条件分岐
  // 外部APIやDBにも依存している
};

export const execute = (...) => {
  // 大量の処理

  const result = calculateSomething(...);

  // さらに大量の処理
};

本来であれば、calculateSomethingを別モジュールへ切り出すなど、責務を整理したいところです。

しかし、既存コードをいきなりリファクタリングすると、そもそも現在の動作を壊していないことをどうやって確認するのかという問題が発生します。

そこで、

  1. 一時的にcalculateSomethingをexportする
  2. 現在の振る舞いをテストで固定する
  3. テストを安全網としてリファクタリングする
  4. 最終的に不要になったexportを削除する

という進め方でやることもあるかと思います。

// 一時的に公開
export const calculateSomething = (...) => {
  ...
};

この場合、exportすること自体が問題ないのではなく、安全に改善するための一時的な手段として採用しています。

最終形ではなく、移行途中のコードであるということです。

公開API経由でのテストが極端に重い

内部実装をテストするために、公開API経由では非常に多くの準備が必要になるケースもあります。

例えば、確認したいのは単純な計算ロジックなのに、

  • APIを起動
  • DBのデータを準備
  • 外部APIをモック
  • 複雑なリクエストを生成
  • ようやく対象ロジックが実行される

という状況になっているとします。

そのような場合、内部関数を直接テストするために一時的・限定的にexportすることは、現実的な選択肢になり得ます。

ただし、内部実装に強く依存したテストが増えていってしまわないために、「なぜ直接テストする必要があるのか」は明確にしておいた方が良さそうです。

テスト専用であることを明示する

どうしてもテストから直接アクセスする必要がある場合、テスト用途であることを明示する方法もあります。(参考)

例えば、

const normalizeName = (name: string) => {
  ...
};

const calculateSomething = (...) => {
  ...
};

export const __test__ = {
  normalizeName,
  calculateSomething,
};

テスト側では、

import { __test__ } from "./userService";

describe("normalizeName", () => {
  it("...", () => {
    expect(__test__.normalizeName(...)).toBe(...);
  });
});

とします。

これなら少なくとも、

export const normalizeName = ...

と普通に公開するよりは、この関数は通常利用を想定したものではないという意図をコード上に残せます。

もちろん、技術的には他のモジュールから利用できることには変わりありません。そのため、これも問題を完全に解決する方法というより、テスト用exportを採用する場合に意図を明確にする方法です。

まとめ

いろいろ調べたり、検討してみましたが、個人的には「内部実装を単独でテストしようとしない」を結論として持ちたいと思います。冒頭の定数についても、テストのためのexportは行わず、テストファイル側で再度定義する実装を維持しました(実装の期待値を意図せず変更してしまった時に検出できるのもある)。

もし迷った際は、以下のように検討しようと思いました。

公開API経由でテストできないか
↓
できない / 非常に困難
↓
独立した責務として切り出せないか
↓
切り出すほどではない / 今は変更できない
↓
Vitestなどの仕組みでexportせずテストできないか
↓
それでも難しい
↓
テスト用exportを検討する

本記事はAIとやりとりしながら考えをまとめたものです。ご意見、ご指摘等ございましたら優しくお願いいたします。

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?