🧑‍💻

#164 仕様保証と保守性の観点から考える、テストにおける定数の使い方

に公開

はじめに

最近、テストコードの書き方について、「定数の使用を許容するか否か」で意見が分かれる場面がありました。

expect(result).toBe(TYPE_NAME);
// vs
expect(result).toBe('yakitori');

一見ささいな違いに見えますが、保守性・可読性・仕様の保証といった複数の観点が絡むため、「何を目的としたテストなのか」を理解していないと、意図とは異なるテスト設計となってしまうおそれがある箇所でもあります。


自身の考え方についても見直す良い機会だと思いましたので、それぞれの立場の意見について整理し、備忘録としてまとめていきたいと思います。
本記事では「どちらが正しいか」ではなく、「目的や規模に応じた”判断軸”」について取り上げます。

テストで定数を使うか、ベタ書きするか

一見すると「保守性のために定数を使えばいいのでは?」という考え方がしっくりきそうです。


実際、定数を使えばDRYの原則に従えますし、仮にその値が変更されたとしても、テスト側は自動的に追従できます。
テストケースが多ければ多いほど、ベタ書きにしていた場合の修正漏れリスクや、意図しない値のまま残ってしまう可能性は高まります。


また、定数を使えば、1 といった数字を ADMIN_USER.ID のように意味づけることができ、読み手に意図が伝わりやすいテストになることもあります。


しかし一方で、「定数の中身が壊れていてもテストで気づけない」という問題もあります。


たとえば、「〇〇のエラー時にはステータスコード 404 をレスポンスで返す」と、外部と契約(公開)されているAPIがあるとします。
仮にそのAPIで使用している HTTP_STATUS_CODE.NOT_FOUND: 404 という定数の値を誤って 405 に変更してしまったとしても、テストは toBe(HTTP_STATUS_CODE.NOT_FOUND) で書かれていれば通ってしまいます。
その結果、実際には 404 を返すべき仕様が崩れているのに、気づけないままデプロイされるなどのリスクが高まります。

ベタ書きはテスト単体で見ると安全

テストは本来、「入力に対して、意図した“結果”が出ているか」を検証するものです。
そして「意図した結果」は、「そのテストが独立していても意味がわかること」が望ましい状態です。

expect(result).toBe(TYPE_NAME); // `TYPE_NAME`がどういう値か、テストだけではわからない
// → 何を意図しているのかが「外部依存」になる

expect(result).toBe('yakitori'); // 期待される仕様が、テストだけでわかる
// → 何を意図しているのかが、その場で読み取れる

ベタ書きであれば、TYPE_NAME の値に意図しない変更があった場合、テストが異常として失敗します。
つまり、「テストが壊れた = 仕様が壊れた」という関係が維持しやすくなります。


これが、「テストケース単体で見たときに、ベタ書きの方が意図が明確で安全」と言われる理由です。
特に「値そのものが仕様に直結している」場合、ベタ書きによってテストの失敗が仕様の壊れとして明確に現れるため、安全性が高いとも言えます。

テスト全体で見ると定数の価値は高い

テストが100件、200件とあるような状況では、

  • 定数の値を1つ変えたとき、ベタ書きだと「すべて修正対象になる」
  • 修正し忘れると、テストは「“正しいつもり”で壊れたままになる」
  • 特に、「数値や文字列が複雑なドメインコード」では読みやすさも低下する

といった懸念が生じます。


また、「ビジネスロジックに従った振る舞いをテストしたい場合」など、定数を使うことで可読性や保守性が高まる場面もあります。(もちろん、定数名が意図を明確に伝えていないような場合、かえって可読性が下がることもあるので注意が必要です)


観点 ベタ書き 定数使用
単体テストの意図明示 ◎ すぐ読める △ 定数の中身を確認する必要あり
定数が壊れたときの検知 ◎ テストが落ちる ✕ テストは通るまま
値が仕様に直結している ◎ 仕様として明示できる ✕ 定義が壊れても気づきにくい
全体の保守性 ✕ 一括修正が大変 ◎ 定数変更にテストが追従する
複雑な値の扱い △ 冗長でわかりづらい可能性もある ◎ 意味づけできる(定数名に意図を込められる)


このように、保守性・可読性・仕様保証など複数の要素が絡むため、「どちらが正しいか」ではなく「そのテストが何を目的としているのか」によって、適切な選択は変わってきます。


次の章では、それぞれの手法が適しているケースについて、より具体的な例を挙げて考えていきたいと思います。

適した手法を具体例から考える

ケース1:文字列種別(USER_ROLE.ADMIN など)

export const USER_ROLE = {
  ADMIN: 'admin',
  GUEST: 'guest',
} as const;
function hasAdminAccess(role: string) {
  return role === USER_ROLE.ADMIN;
}
test
expect(hasAdminAccess(USER_ROLE.ADMIN)).toBe(true);
expect(hasAdminAccess(USER_ROLE.GUEST)).toBe(false);

こちらは「定数の方が適している」と考えます。(もちろん、ベタ書きでも問題ありませんが)
理由としては、以下のような点が挙げられます。

  • 'admin''guest' の文字列自体は「内部的な表現」であって、外部仕様ではない
  • 仮に値が 'adminUser' に変わっても、それに応じてアプリとテストが一緒に動けば問題ない
  • 定数によって語彙を明示できて、コード全体が読みやすくなる

このような定数を使うことで、実装とテストの間で「語彙のズレが起きづらくなる」のも利点です。
たとえば 'admin''administrator' に変更した場合も、定義元の定数さえ直せば、テストや他のロジックがすべて追従できます。

ケース2:HTTPステータスコード(404、500など)

export const HTTP_STATUS_CODE = {
  OK: 200,
  NOT_FOUND: 404,
  INTERNAL_SERVER_ERROR: 500,
} as const;
function getStatusById(id: number): number {
  return id === 1 ? HTTP_STATUS_CODE.OK : HTTP_STATUS_CODE.NOT_FOUND;
}
test
expect(getStatusById(1)).toBe(200);
expect(getStatusById(999)).toBe(404);

こちらは「ベタ書きの方が適している」と考えます。
理由としては、以下のような点が挙げられます。

  • 404200 といったステータスコードは、外部仕様(HTTPプロトコル)で定義された値であり、「仕様そのもの」をテストする意図が強い
  • 万が一 HTTP_STATUS_CODE.NOT_FOUND405 に変更されても、ベタ書き(toBe(404))であればテストが失敗して異常に気づける
  • ステータスコードの意味は業界的に広く共有されており、ベタ書きでも読み手の理解を妨げにくい

もちろん、HTTP_STATUS_CODE.NOT_FOUND を使うことでコード全体の一貫性や意味づけが明示される、というメリットもあります。
しかし、値そのものが仕様に直結しており「変更=バグ」であるような場面では、テストでのベタ書きによる明示的な検証の方が、安全性が高いと言えるでしょう。

判断軸の整理とまとめ

ここまで見てきたように、「定数を使うか、ベタ書きするか」はどちらが正しいという話ではなく、テストの目的や値の性質によって判断が分かれるテーマです。


以下に、判断の際に参考となる視点について、一覧表にまとめてみました。


観点 ベタ書きが適している 定数の方が適している 補足・注釈
値が仕様に直結しているか ◎ HTTPステータスコードなど、値そのものが仕様の一部である場合 △ アプリ内部でのみ使われる識別子やフラグなど この値自体が外部と契約しているかどうか(例:APIで 404 を返す仕様など)
値が将来的に変更されうるか △ 変更時に複数のテストに手を入れる必要があるものの、検索・置換や範囲が限定されていれば現実的 ◎ 定数変更によりテストも一括で追従できる 例:MAX_COUNT のような運用レベルの調整が起こりうる設定値
テスト単体の可読性 ◎ その場で期待値が読めてわかりやすい △ 定数名から想像できないと、定義を見に行く手間がある テストコードを読む第三者の立場を想定。読みやすさと想像のしやすさが基準
値の明示性 △ 値だけでは意味が読み取れないこともある ◎ 定数名に意図が込められていれば理解しやすい 'admin' よりも USER_ROLE.ADMIN の方が「どういう概念なのか」が伝わりやすい、といった場合も
定義が壊れたときの検知性 ◎ 値のずれでテストが落ちる ✕ 実装とテストが同じ壊れ方をすると、気づかず通ってしまう toBe(SOME_CONST) では、定数と実装がどちらも壊れていてもパスしてしまう可能性がある
テストの目的の違い ◎ 値の正しさや仕様への一致を確認したいとき ◎ 振る舞いだけを検証したいとき 仕様そのものを保証するのか、振る舞いを保証するのかで適した書き方が異なる


判断時のポイント:


「なんとなく定数を使う/ベタ書きにする」ではなく、次のような問いを立てることで、より納得感のある選択ができるようになるかと思います。

  • この値は、仕様そのものとして保証したいものか?
  • このテストが壊れていたら、どんなバグを見逃すことになるか?
  • この値は将来変わる可能性があるか?
  • 読み手に対して、値の意味が伝わる必要があるか?

判断が分かれる例:MAX_RETRIES をどうテストするか?

ここまで、テストにおける「ベタ書き」と「定数使用」の違いや選び方の軸を整理してきました。
しかし、実際には どちらの選択も合理的に見えるグレーなケースも存在します。


たとえば、以下のような「最大試行回数」の定数です。

export const MAX_RETRIES = 3;

export function shouldRetry(count: number): boolean {
  return count < MAX_RETRIES;
}

このとき、テストはどう書くべきでしょうか?

// パターン① 定数を使う
expect(shouldRetry(MAX_RETRIES - 1)).toBe(true); // 試行回数がMAX_RETRIES未満なので再試行
expect(shouldRetry(MAX_RETRIES)).toBe(false); // 試行回数がMAX_RETRIESに達しているので終了

// パターン② ベタ書きで意図を明示
expect(shouldRetry(2)).toBe(true); // 試行回数が3未満なので再試行
expect(shouldRetry(3)).toBe(false); // 試行回数が3に達しているので終了

あるいは、以下のように 閾値(3)としてそれだけを明示的に使用する書き方もあるでしょう。

expect(shouldRetry(3)).toBe(false); // 3回目はリトライしないことを保証

このケースがややこしいのは、以下のような観点でどちらにも合理性があるためです。

  • 定数を使う立場
    • MAX_RETRIES は将来的に「4回に増やしたい」などの変更がありうる
    • 定数にしておけば、実装とテストが自然に追従する
    • ベタ書きだと修正漏れの可能性がある
  • ベタ書きをする立場
    • 「3回までしか再試行しない」仕様そのものを保証したい
    • MAX_RETRIES が壊れてもテストが通ってしまうのはリスク
    • テストから読み取れる 意図や閾値が明確になる

最終的には、「何をテストしたいのか?」によって書き方は変わります。


テストの目的 適した書き方
ロジックが仕様通りに動いていることを確認したい MAX_RETRIES を使って振る舞いを検証する
「最大3回である」という仕様自体が正しいかを保証したい toBe(3) のようにベタ書きで明示する


もちろん、どちらか一方に絞るのではなく、「目的ごとにテストケースを分けて記載する」という選択肢もあります。
しかし、上記のような「仕様の保証」と「振る舞いの確認」を行うテストをそれぞれ記載していくのは、些か冗長にも感じてしまいます。

補強案:定数の保証テストを別で用意する

ベタ書きと定数の使い分けには、それぞれの利点があります。
ただ、両者を同じ粒度で併用しようとすると、かえって定数を使うメリットが見えづらくなることもあります。
(「どうせ修正範囲が広がるなら、全部ベタ書きに統一してもいいのでは?」となりがち)


こうした状況に対して、「定数そのものの値を別にテストで保証する」というアプローチをご紹介します。
あくまでひとつの解決策ですが、考え方の参考になれば幸いです。

定数の値そのものを検証する

定数の中身が壊れても業務テストが素通りしてしまう課題を解決するために、定数定義に対してベタ書きで明示的にテストを行い、その値を保証します。

test('MAX_RETRIES should be 3', () => {
  expect(MAX_RETRIES).toBe(3);
});

業務テストでは定数を使う

上記のような保証テストを別で設けることで、実際の業務テストでは定数のみのテストでもカバーできる範囲が広がります。

expect(shouldRetry(MAX_RETRIES - 1)).toBe(true);
expect(shouldRetry(MAX_RETRIES)).toBe(false);

// 別でMAX_RETRIESの値を保証しているため、実質以下のテストでもある
// expect(shouldRetry(2)).toBe(true);
// expect(shouldRetry(3)).toBe(false);

これにより、業務テストでは保守性・可読性のメリットを活かしつつ、定数の壊れを検知できる構成になります。
一貫性や責務の分離がしやすくなるので、テストケースが充実しているような規模の大きいプロジェクトでは、特に扱いやすくなるのではないでしょうか。

おわりに

いかがでしたでしょうか。
今回は、テストコードの書き方における「ベタ書きと定数を利用する方法」についてメリットを比較し、それぞれの使い所について整理してみました。


また、両者の利点を活かしたアプローチ方法として、「定数の保証テストを別で用意する」方法も紹介させていただきました。
もちろん、すべてのプロジェクトや定数に必ずしも適用するべき、というものではありません。
それでも、「仕様の値として保証しつつ、定数も活用したい」という場面では、選択肢として持っておくと有効かなと思います。


ベタ書きでも定数利用でも、重要なのは「そのテストで何を保証したいか」を把握していることです。
プロジェクトのルールとも相談しながら、目的に応じて選び分けられるように今後とも意識していきたいと思います。


以上です。最後まで閲覧いただきありがとうございます。

Discussion