logo
Published on

テストを書くときに意識していること

Authors

はじめに

テストコードを書く目的は「動くことを確認する」だけでなく、仕様書の変わりになったり、将来の変更に対する安全網となると思います。そのため、テストコードを読めば「このコードは何を担保しているのか」「どんな条件で何が起きるのか」がわかることが良いテストの条件だと考えています。

ソフトウェアテスト設計の基本的な考え方

テスト技法の観点

同値分割法

入力値をグループ(同値クラス)に分け、各グループから代表的な値を1つ選んでテストする手法です。例えば「1〜100の数値を受け取る」という仕様があれば、有効な値(1〜100のどれか1つ)、無効な値(0以下、101以上、文字列など)でグループ分けし、それぞれの代表値でテストします。全ての値をテストするのは現実的ではないため、効率的にテストケースを絞り込む技法です。

境界値分析

同値クラスの境界にある値を重点的にテストする手法です。バグは境界付近で発生しやすいという経験則に基づいている。「1〜100」なら、0、1、100、101といった境界の値をテストします。

状態遷移テスト

システムの状態と、イベントによる状態遷移をテストする手法です。各状態からどのイベントで次の状態に移るか、無効な遷移が拒否されるかをテストします。

デシジョンテーブル

複数の条件の組み合わせと、その結果の動作を表にまとめてテストする手法です。条件が複雑に絡み合う場合に、網羅性を確保するために使います。

テストレベルからの観点

テストピラミッド

テストは「ユニットテスト(単体テスト)」「インテグレーションテスト(結合テスト)」「E2Eテスト(エンドツーエンドテスト」の3層で構成され、下層ほど多く、上層ほど少なくするのが理想とされています。

ユニットテストは高速で安定しているため多く下記、E2Eテストは重くて不安定になりやすいため重要なシナリオに絞るという考え方です。

何を守るためにテストを書くのか

  • ビジネス上の重要な振る舞いを担保
    • 仕様通りに機能が動作することを確認
    • 例外的なケースでも適切に動作することを確認
  • セキュリティ上の境界
    • 認証・認可のルールが正しく適用されていることを確認
    • 不正アクセスやデータ漏洩を防止するためのチェック

なぜその構造にするのか

テストコードにも設計思想が必要です。構造に意図がないと、テストが増えるにつれて可読性が低下し、保守が困難になります。以下のポイントを意識しています。

  • 階層構造で条件分岐を表現すること
    • RSpecのdescribe/context/itを使い分けることで、テスト対象と条件と期待結果を明確に分離します。これにより、テストを読むだけで「どんな条件の時に何が起こるのか」が理解しやすくなります。
  • 正常系と異常系を明確に分離すること
    • 期待通りに動くケースとエラーが発生するケースを意識的に分けることで、テストの網羅性を高めます。異常系のテストが漏れると、本番で予期しないエラーが発生した時に対応が難しくなるためです。
  • 外部依存をモックで分離すること
    • 外部APIやデータベース以外のストレージに依存するテストは、実行速度が遅くなり、外部のサービスの状態によって結果が変わるため、信頼性が低下します。

読みやすく壊れにくいテストを書く

読みやすさとは、テストを読んだだけで「何を検証しているか」が理解できることです。

壊れにくさとは、プロダクトコードの小さな変更でテストが大量に失敗しないことです。実装の詳細ではなく、振る舞いをテストすること・テストデータをFactory Botなどで柔軟に生成すること・共通パターンをshared_examplesで再利用することで、壊れにくさが向上します。

テストの構造設計

  • describe : 「何をするか」を宣言します。通常はメソッド名やアクション名を記述します。
  • context : 「どんな条件の時か」を表現します。「〜の場合」という形式で、状態や前提条件を記述します。例えば、「管理者としてログインしている場合」「有効なパラメータの場合」などです。
  • it : 「何が起きるか」を記述します。「〜すること」という形式で、1つの検証項目を表現します。itを読めば、そのテストが何を確認しているかがわかります。

テストデータの管理

Factory Botでテストデータの生成を一元管理しています。モデルのデフォルト値をFactoryで定義し、テストごとに必要な属性だけを上書きします。これにより、仕様変更があってもFactoryを修正するだけで済みます。

letは遅延評価の仕組みを提供します。テスト内で参照された時に初めて評価されるため、不要なデータ生成を避けられます。一方、let!は即時評価で、テスト実行前にデータを作成します。テスト実行前にデータが存在してる必要がある場合に使用します。

traitは、同じモデルで異なる状態を表現するために使います。例えば「完了済みの試験」「未完了の試験」のように、状態によって振る舞いが異なる場合にtraitを定義します。

正常系と異常系の網羅的なテスト設計

  • 正常系 : ユーザーが正しい操作をした時に期待通りに動作することを確認します。これは基本的なテストで、機能が正しく実装されていることを保証します。
  • 異常系 : エラーや例外が発生するケースを確認します。異常系のテストを網羅するために、いくつかのパターンを意識しています。
    • 入力値の不正 : 必須パラメータが欠けている場合、形式が不正な場合、値が範囲外の場合など
    • 認証・認可のエラー : ログインしていない場合、トークンが無効または期限切れの場合、他人のリソースにアクセスした場合など
    • リソース不在 : 指定したIDのレコードが存在しない場合、削除済みのリソースにアクセスした場合など
    • 外部サービスのエラー : タイムアウト、接続失敗、サーバーエラーなど

外部依存の分離:モックとスタブの戦略

AWS Cognitoのテスト

AWS Cognitoを使用したSSOを実装した際に、RSpecでテストを書く機会があり、以下の点を考慮してテストを設計しました。

各レイヤーごとに独立してテスト可能にする

  • Client層 : Faradayを使ったHTTPクライアント部分
  • ApiClient層 : CognitoのAPIを呼び出す部分
  • Service層 : ビジネスロジックを実装する部分

このようなレイヤー分けを行い、各レイヤーで独立したテストが可能になるようにしました。

HTTPリクエストのモック

Client層のテストでは、Faradayのconnectionをdoubleでモックし、レスポンスをシミュレートしています。リクエストヘッダーやボディが正しく設定されているかも検証しています。

double
RSpecのテストダブル(モックオブジェクト)を作成するメソッド。実際のオブジェクトの代わりに使用することで、外部依存を排除し、テストの独立性を保つことができる。

mock_connection = double("Faraday::Connection")
allow(client).to receive(:connection).and_return(mock_connection)

mock_response = double("Faraday::Response",
  success?: true,
  status: 200,
  body: { "result" => "success" }
)
expect(mock_connection).to receive(:post).and_return(mock_response)

ApiClient層のスタブ

Service層のテストでは、ApiClientをinstance_doubleでモックしています。instance_doubleを使うことで、実際のクラスに存在しないメソッドをスタブしようとするとエラーになるので、実装とテストの整合性を保つことができます。

instance_double
検証付きテストダブルを作成するメソッドです。doubleとは違い、実際のクラスのインターフェースを検証します。

api_client = instance_double(Cognito::ApiClient)
allow(service).to receive(:api_client).and_return(api_client)
allow(api_client).to receive(:verify).and_return(Result.success(...))

テストケースの網羅性

  • 正常系 : トークン検証成功、リフレッシュ成功
  • 異常系 : 無効なトークン、期限切れトークン
  • 通信エラー : タイムアウト、接続失敗、サーバーエラー
  • 境界値 : refresh_tokenがnil/空文字の場合

設計上の工夫

Resultパターンを採用しています。

APIの戻り値をResult.success(data)やResult.failure(message, status_code)というオブジェクトで返すことで、成功・失敗を明示的に扱うことができます。
これによりテストでもexpect(result.success?).to be trueのように意図が明確になりますし、呼び出し側での例外ハンドリングも不要になります。

なぜこの設計にしたのか

  • テストの高速化 : 実際のCognitoに接続すると遅くなる
  • テストの安全性 : 外部サービスの状態に依存しない
  • 異常系のテストが容易になる : 実際のCognitoでタイムアウトやサーバーエラーを再現するのは難しいが、モックなら簡単にシミュレートできる

やらなかったこと

  • 実際のCognitoとの結合テスト

まとめ

「何をテストすべきか」と「どのレベルでテストすべきか」の2つの観点からテスト設計を考えます。

「何をテストすべきか」については、テスト技法(同値分割法、境界値分析、状態遷移テスト、デシジョンテーブル)を意識し、「どのレベルでテストすべきか」についてはテストピラミッドの考え方に基づいて、ユニットテストを中心に設計しました。

同値分割法の適用

本プロジェクトではJWTを使用した認証テストがあります。ここでは入力を「有効なグループ」「無効なグループ」に分けて考えました。

  • 有効な同値クラス
    • 正しい署名
    • 有効期限内のJWT
  • 無効な同値クラス
    • JWTが含まれていない
    • 署名が不正なJWT
    • 有効期限切れのJWT
    • 必須クレームが欠落しているJWT

これにより、各同値クラスから代表的な値を選んでテストケースを作成し、効率的に網羅性を確保しました。

境界値分析の適用

トークンの有効期限についても境界値を意識しています。「1時間前に発行されたトークン」と「1時間後に期限切れのトークン」のように、時間の境界でテストしています。

let(:token_expires_at) { 1.hour.from_now }  # 境界値:有効
let(:token_expires_at) { 1.hour.ago }       # 境界値:無効(期限切れ)

状態遷移テストの適用

例えば、試験の受験状態が「未完了」「完了」の2つの状態を持つ場合、各状態からの遷移をテストしています。

  • 状態 : 未完了→完了
  • テストした遷移
    • 採点処理が実行→状態が完了に変わる
    • 採点処理が実行されない→状態が未完了のまま

デシジョンテーブルの適用

Cognito認証では、複数の条件の組み合わせをテストしました。

トークン期限リフレッシュ結果検証結果期待する動作
有効-成功成功(リフレッシュなし)
有効-失敗失敗
期限切れ成功成功成功(新トークン付き)
期限切れ成功失敗失敗
期限切れ失敗-失敗

テストピラミッドの考え方

ユニットテスト

  • モデルのバリデーション、関連付け
  • サービスクラスのロジック
  • 外部API連携はモックを使って高速にテスト

インテグレーションテスト

  • Request Specで、コントローラー→サービス→モデルの連携をテスト
  • 認証・認可の総合的な動作確認

E2Eテスト

  • 重要なユーザーシナリオに絞る