📼

形骸化したVCR運用を立て直し、実データでも安全なテストを実現する

に公開

この記事は CAMPFIRE Advent Calendar 2025 の14日目の記事です。

はじめに

こんにちは、バックエンドエンジニアのkabeです。

今年はどんな年だろう〜と振り返ってみたのですが、年始から春にかけてはRailsアップデートをほぼ1人で対応を進めたり、夏辺りからはサポートサービスの1つであるCAMPFIRE広告などの業務改善を担当していました。
今回は、広告業務で事業部が利用している外部SaaSとCAMPFIREサービスとの連携の運用改善に取り組む中で、連携基盤を安定させるために

  • 形骸化していたVCR運用を立て直し
  • 実データしかない場合のテスト作成にもフィルタ機能を使って対応した

という話を書きます🙋‍♂️

1. 背景と課題:外部SaaS連携と「形骸化した VCR」

CAMPFIREでは外部SaaSとの連携を実装する際、テスト用のHTTPリクエスト/レスポンスを記録・再生するVCR gemを導入しています。しかし、今回自分が対応した連携では「VCRが動いているようで実は動いていない」状態になっていました。

VCRとは

VCR(Video Cassette Recorder)は、外部APIとの通信を記録・再生するためのRubyライブラリです。テスト実行時に実際のAPIリクエストを送信する代わりに、事前に記録したレスポンス(カセット)を再生することで、テストの高速化や外部APIへの依存を排除できます。

vcr/vcr

1.1 形骸化していたVCR運用

今回対応した箇所では既にVCRが利用されていましたが、いくつか問題がありました。

現状の問題点:

  • 現在設定されている接続先: どこにも繋がらない全く関係のないダミー環境
    • 例: https://api.example-saas.com/tenant-dummy
  • 本当に接続したい先: 外部SaaSの実環境(開発用DB または 本番DB)
    • 例: https://api.example-saas.com/tenant-campfire

実環境の構成:

連携先の外部SaaSには以下の2つのDBが存在し、リクエスト時のパラメータで接続先を切り替えることができます。

  • 開発用DB
  • 本番DB

しかし、既存の環境変数には全く関係のないダミー環境が設定されており、実環境に接続ができませんでした。そのため、外部SaaS側との連携に更新があった場合でも、カセットを再生成することができず、既存のカセットを手動で編集して対応していました。

例えば、下記のようなカセットが存在している場合:

# spec/fixtures/vcr_cassettes/external_saas/csv_export/csv_data.yml
http_interactions:
- request:
    method: post
    uri: https://test-api.example-saas.com/tenant-dummy/api/csvexport
    body:
      encoding: UTF-8
      string: ""
  response:
    status:
      code: 200
      message: OK
    headers:
      Content-Type:
      - text/csv; charset=utf-8
    body:
      encoding: UTF-8
      string: |
        ID,会社名,名前,メール
        1,テスト株式会社1,テスト太郎1,test1@example.com
        2,テスト株式会社2,テスト太郎2,test2@example.com

連携処理の更新内容に合わせて、既存カセット内のレスポンス(body.string)を以下のような形で手動で編集していました。

body:string: |
    ID,会社名,名前,メール,追加カラム ←ここを手動で追加
    1,テスト株式会社1,テスト太郎1,test1@example.com,追加カラム1 ←ここを手動で追加
    2,テスト株式会社2,テスト太郎2,test2@example.com,追加カラム2 ←ここを手動で追加

同じ行にカラムを追加する程度であれば内容を推測して修正ができます。しかし、このやり方を続けていくと実環境との乖離が発生します。
その結果、表面上は対応が完了していたはずでも連携処理の一部に漏れが発生することがありました。

1.2 問題の表面化

先で述べた接続先の問題を解決するため、環境変数を実環境のURLに変更したところ、この変更により別の問題が表面化しました。

環境変数を変更した結果、既存のカセットは環境変数の値が異なるため使うことができず、テストが失敗するようになったのです。
既存カセットを再生成すれば解決はできますが、テストごとにカセットが存在し、また再生成には外部SaaS側のデータ整備が必要なケースも多くあります。今回対応したい箇所以外のカセット全てを再生成するのは工数が高く現実的ではありませんでした。

2. カスタムマッチャーで過去カセットを救済する

2.1 解決策:カスタムマッチャーの導入

環境変数の差異を解決するために、カスタムマッチャーを導入しました。
外部SaaSのAPIでは、URLのパスにテナントIDが含まれています(例: /tenant-dummy/api/csvexport)。API仕様として重要なのは「どのテナントか」ではなく「どのエンドポイントか」という前提を置き、テナントIDを無視してマッチングするマッチャーを実装しました。

カスタムマッチャーの実装

# spec/spec_helper.rb

VCR.configure do |config|
  config.cassette_library_dir = 'spec/fixtures/vcr_cassettes'
  config.hook_into :webmock
  config.allow_http_connections_when_no_cassette = true

  # カスタムマッチャーの登録
  config.register_request_matcher :external_api_path do |request_1, request_2|
    uri_1 = URI.parse(request_1.uri)
    uri_2 = URI.parse(request_2.uri)

    # テナントIDを除外したパスを取得(最初のパスセグメントを削除)
    # /tenant-dummy/api/csvexport → /api/csvexport
    path_1 = uri_1.path.sub(%r{^/[^/]+/}, '/')
    path_2 = uri_2.path.sub(%r{^/[^/]+/}, '/')

    request_1.method == request_2.method && path_1 == path_2
  end
end

このマッチャーにより、以下の2つのリクエストがマッチするようになります。

  • 旧カセットのリクエスト: POST https://api.example-saas.com/tenant-dummy/api/csvexport
  • 再生成カセットのリクエスト: POST https://api.example-saas.com/tenant-campfire/api/csvexport

どちらも /api/csvexport というエンドポイントに対するPOSTリクエストとして認識されます。

2.2 カスタムマッチャーの使用方法

テストファイルでは、以下のようにカスタムマッチャーを指定してカセットを使用します。

# spec/clients/external_saas/csv_client_factory_spec.rb

around do |example|
  VCR.use_cassette 'external_saas/csv_export/csv_data', 
                   match_requests_on: [:external_api_path] do
    example.run
  end
end

2.3 移行用の足場としてのカスタムマッチャー

このカスタムマッチャーは、ダミー環境向けカセットをすべて再生成し終えるまでの一時的な措置です。
カスタムマッチャーの実装意図と「いつ消す予定か」を明確にするため、運用ドキュメントを一緒に作成しました。
このドキュメントには以下の内容が含まれています:

  • カスタムマッチャーの動作説明
  • カスタムマッチャーを使用している背景と理由
  • VCRカセット再生成の手順
  • カセット一覧
  • 注意事項

これにより、他エンジニアでも安心してテストを更新できる環境が整いました。

2.4 解決したと思いきや、別のテストで新たな課題が発覚

ここまでの対応で一安心!と考えていたのですが、他連携バッチの修正対応中に別の問題が発覚します。

  • 連携バッチのテスト自体が存在せず、一部の連携処理に漏れが発生
  • テストを追加しようとしたところ、実環境の開発用DBが整備されておらず、本番DBの値を取得せざるを得ない
    • そのため、テストを作成すると本番DBの個人情報がカセットに記録されてしまう

この課題を解決するため、VCRのフィルタ機能を使って個人情報を自動的にマスキングするサニタイザーを実装することにしました。

3. サニタイザーで個人情報を守る

3.1 解決策:VCRのフィルタ機能を使ったサニタイズ

VCRには、カセットに記録する前にデータを加工するためのフィルタ機能が用意されています。今回は以下の2つを利用します。

filter_sensitive_data: シンプルな文字列置換

filter_sensitive_dataは、カセットに記録される前に特定の文字列をマスクする機能です。APIトークンやパスワードなどの機密情報を隠すのに適しています。

VCR.configure do |config|
  config.filter_sensitive_data('<API_TOKEN>') { ENV['API_TOKEN'] }
  config.filter_sensitive_data('<PASSWORD>') { ENV['PASSWORD'] }
end

Filter sensitive data

before_record: カスタム処理によるフィルタリング

before_recordフックは、カセットに記録する直前にカスタム処理を実行できる機能です。レスポンスボディを解析して、より複雑なサニタイズ処理を行うことができます。

VCR.configure do |config|
  config.before_record do |interaction|
    # interaction.response.body を加工
    # 例: CSVをパースして個人情報をダミー値に置換
    if interaction.response.headers['Content-Type']&.first&.include?('text/csv')
      # CSVサニタイズ処理
    end
  end
end

before_record hook

この2つの機能を組み合わせることで、様々な機密情報を安全にカセットに記録できます。

今回のケースでは、VCRのbefore_recordフックを使って、カセットに書き込む前に機密データをダミー値に差し替える方針を採用しました。

実装の要点

サニタイザーでは、CSVレスポンスをパースして各セルをサニタイズし、再びCSV形式に戻します。

before_recordフックへの登録

def configure(config)
  config.before_record do |interaction, cassette|
    # カセット名とレスポンス形式を確認
    return unless cassette.name.to_s.include?(CASSETTE_NAME)
    return unless csv_response?(interaction)

    # CSVをサニタイズ
    sanitize_csv_body(interaction)
  end
end

CSVのパースとサニタイズ

def sanitize_csv_body(interaction)
  require 'csv'
  rows = CSV.parse(interaction.response.body.dup.force_encoding('UTF-8'))
  return if rows.empty?

  header = rows.first
  # ヘッダー以外の各行をサニタイズ
  sanitized_rows = [header] + sanitize_data_rows(header, rows[1..])
  interaction.response.body = sanitized_rows.map { |row| CSV.generate_line(row) }.join
rescue CSV::MalformedCSVError, Encoding::UndefinedConversionError => e
  Rails.logger.warn("VCR: Failed to sanitize CSV response: #{e.message}") if defined?(Rails)
  interaction.response.body = ''
end

サニタイズの3つの方法

以下の3つの方法を組み合わせて、優先順位に従って実行します:カラムインデックス → カラム名 → パターン

  1. カラムインデックスベース: カラムの位置(インデックス)で判定
  2. カラム名ベース: ヘッダー名(正規表現)で判定
  3. パターンベース: 値のパターン(メールアドレス、URLなど)で判定
# サニタイズ対象のカラムインデックス(0始まり)
COLUMN_SANITIZERS = {
  1 => :company,       # 会社名
  2 => :person_name,   # 名前
  3 => :email,         # メールアドレス
}.freeze

def sanitize_cell(cell, col_idx, col_name, row_number)
  return cell if cell.blank?

  # 1. カラムインデックスベース
  if COLUMN_SANITIZERS.key?(col_idx)
    return sanitize_by_type(cell, COLUMN_SANITIZERS[col_idx], row_number)
  end

  # 2. カラム名ベース(ヘッダー名で判定)
  sanitize_by_column_name(cell, col_name, row_number)
end

def sanitize_by_type(cell, type, row_number)
  case type
    when :email
      "test#{row_number}@example.com"
    when :person_name
      "テスト太郎#{row_number}"
    when :company
      "テスト株式会社#{row_number}"
    else
      cell
  end
end

def sanitize_by_column_name(cell, col_name, row_number)
  case col_name
    when /メール|email/i
      "test#{row_number}@example.com"
    when /名前|氏名/
      "テスト太郎#{row_number}"
    when /会社|法人/
      "テスト株式会社#{row_number}"
    else
      # 3. パターンベース(値のパターンで判定)
      sanitize_patterns(cell, row_number)
  end
end

def sanitize_patterns(cell, row_number)
  result = cell.dup
  # メールアドレスパターンをマスク
  result.gsub!(/[\w+\-.]+@[a-z\d\-.]+\.[a-z]+/i, "masked#{row_number}@example.com")
  # URLパターンをマスク
  result.gsub!(%r{https://[^\s/]+/[^\s/]+/\d+}, "https://example.com/resource/00000#{row_number}")
  result
end

VCR設定の全体像

サニタイザーの実装と同時に、先のセクションではspec_helper.rbに直接記載していたカスタムマッチャーも含めて、vcr_external_saas.rbという別ファイルにまとめました。これにより、外部SaaS関連のVCR設定(カスタムマッチャー、サニタイザー、APIトークンマスク)を一元管理できるようになりました。

spec_helper.rbでVCRを設定し、外部SaaS用の設定を読み込みます。

# spec/spec_helper.rb

require_relative 'support/vcr_external_saas'

VCR.configure do |config|
  config.cassette_library_dir = 'spec/fixtures/vcr_cassettes'
  config.hook_into :webmock
  config.allow_http_connections_when_no_cassette = true

  # 外部SaaS用設定(APIトークンマスク、CSVサニタイザー、カスタムマッチャー)
  VcrExternalSaaS.configure(config)
end

vcr_external_saas.rbでは、以下の3つの機能を統合しています:

  1. APIトークンのマスク: 全カセット共通でAPIトークンをマスク
  2. CSVサニタイザー: 特定のカセットのCSVレスポンス内の機密データをサニタイズ
  3. カスタムマッチャー: テナントIDを無視したリクエストマッチング
# spec/support/vcr_external_saas.rb

module VcrExternalSaaS
  class << self
    def configure(config)
      filter_api_token(config)        # APIトークンのマスク
      CsvSanitizer.configure(config)   # CSVサニタイザーの登録
      RequestMatcher.configure(config) # カスタムマッチャーの登録
    end

    private

      # 外部SaaS APIトークンをマスク(全カセット共通)
      def filter_api_token(config)
        config.filter_sensitive_data('<EXTERNAL_SAAS_API_TOKEN>') do
          Settings.dig(:external_saas, :api_token) if defined?(Settings)
        end
      end
  end

  module CsvSanitizer
    # ... サニタイザーの実装 ...
  end

  module RequestMatcher
    # ... カスタムマッチャーの実装 ...
  end
end

サニタイズ前後のVCRカセットの比較

サニタイザーを適用することで、VCRカセットに保存されるデータがどのように変わるか。

サニタイズ前(個人情報が含まれた状態):

http_interactions:
- request:
    method: post
    uri: https://api.example-saas.com/tenant-campfire/api/csvexport
    body:
      encoding: UTF-8
      string: ""
  response:
    status:
      code: 200
      message: OK
    headers:
      Content-Type:
      - text/csv; charset=utf-8
    body:
      encoding: UTF-8
      string: |
        ID,会社名,名前,メール,電話番号
        1,株式会社サンプル,山田太郎,yamada@example.com,090-1234-5678
        2,テスト商事,佐藤花子,sato@example.com,080-9876-5432

サニタイズ後(個人情報がダミー値に置換された状態):

http_interactions:
- request:
    method: post
    uri: https://api.example-saas.com/tenant-campfire/api/csvexport
    body:
      encoding: UTF-8
      string: ""
  response:
    status:
      code: 200
      message: OK
    headers:
      Content-Type:
      - text/csv; charset=utf-8
    body:
      encoding: UTF-8
      string: |
        ID,会社名,名前,メール,電話番号
        1,テスト株式会社1,テスト太郎1,test1@example.com,000-0000-0000
        2,テスト株式会社2,テスト太郎2,test2@example.com,000-0000-0000

このように、サニタイザーにより個人情報がダミー値に置換され、安全にカセットを保存・共有できるようになりました。

4. まとめと今後

本記事では、形骸化していたVCR運用を立て直すために、以下の対応を行いました。

  1. カスタムマッチャーの導入: 環境変数の変更により使えなくなった過去のカセットを救済するため、テナントIDを無視してマッチングするカスタムマッチャーを実装しました。
  2. サニタイザーの実装: 実環境の本番DBの実データしか取得できない状況でも、VCRのフィルタ機能を使って個人情報を自動的にマスキングするサニタイザーを実装しました。
  3. 運用ドキュメントの作成: カスタムマッチャーの意図と「いつ消す予定か」を明確にするため、運用ドキュメントを作成し、他エンジニアでも安心してテストを更新できる環境を整えました。

これらの対応により、これまで属人的だった連携処理の対応が改善され、不安なテストのままデプロイすることが減りました。

今後は以下の対応を進めていきます。

  • カセットの再生成: すべてのカセットを実環境向けに再生成し、カスタムマッチャーを削除する
  • サニタイズの見直し: 新しいカラムが追加された場合に備えて、サニタイズルールを見直す
  • テストの拡充: より多くのエッジケースをカバーするテストを追加する

VCRは外部APIとの通信を扱うため、初めての場合は戸惑いや不安を感じることもあるかもしれません。
自分自身も初めてVCRを見かけたときは使い方を理解するまで時間がかかりました。
誤った運用を避け、サニタイズやフィルタ機能を適切に活用すれば、テストの安全性を高めつつ運用コストを抑えることができます。
本記事で紹介した個人情報の自動マスキングなどを通じて、安全で効率的なカセット運用も可能になります。

VCRはテスト自動化や保守性向上に大きく貢献できるツールです。まだ使ったことがない方はぜひ導入し、その利便性を実感してみてください。

Discussion