🤖

RailsでAIファーストのテスト設計 - レコードの差分全部を期待値に書くようにしてみた

に公開

RailsでAIファーストのテスト設計 - レコードの差分全部を期待値に書くようにしてみた

TL;DR

「AIが書いたコードに漏れや意図しない変更は本当に無いか?」を包括的にチェックする新しいテスト手法をトライしてみました。データベースの全変更を自動監視し、期待値もAIが自動生成することで、従来の「重要な変更のみ期待値に記述するテスト」から「全変更を漏れなく監視」へのパラダイムシフトで、AI時代の開発効率と品質を両立させる試行錯誤の記事です。

またレコードの全変更に加え、レスポンス全文にレコードの各属性値が含まれるかもチェックすることで、意図しない情報の漏れを防ぎます。

きっかけ

もともと私たちのサービスではデータ監査のためにPaperTrailを導入していました。ユーザーのアクション履歴やデータ変更の追跡が目的でしたが、「このPaperTrailの仕組み、テストでも活用できないだろうか?」と思ったのがきっかけです。

PaperTrailはすべてのモデル変更を自動的に記録するため、「PaperTrailからテスト実行前後の差分を見れば、そのテストがどんなデータ変更を起こしたかが完全に分かる」ことに気づきました。そして、今までこの取り組みを手動で書こうものなら大変な工数となりますが、AI時代では現実的に可能なものとなりました。

課題として、AIが書いた実装は表示されるプロパティが急に変更されるなど、指示以外の想定しない変更が入りやすいリスクがあったり、 記述も既存の実装に対して差分としては読みにくく、レビューがしにくいなどの問題がありました。 そこで今回のような仕組みを使って、機械的にセキュリティ的な変更やレコード上の変更が無いことを担保してからレビューに入ることで、レビューをできるだけ効率的に行おうと思いました。

なぜAIファーストのテストが必要なのか

従来のテストの課題

  • 部分的・限定的な期待値の記述:レコード数や特定属性のbefore/afterのみをテストする
  • 意図しない副作用に気づきにくい:関連する変更を見落としがち、レスポンスの変化、レコードの変化すべてに対してチェックができない
  • 手動メンテナンス:期待値の更新が開発者の負担

AI時代の新しい可能性

  • AIによるコケたテストの修正:大量の差分でも的確な自動でAIが埋めてくれて、実装がもたらした変更はレビューで目視確認するだけで済む。
  • 包括的監視:全てのデータ変更を網羅的にチェックする。レスポンスの変化、レコードの変化すべてに対してチェックができる

開発環境・前提条件

  • Rails
  • RSpec
  • PaperTrail
  • AIアシスタント(Claude Code等)

実装コード全体

このシステムは3つのファイルとspec_helper.rbへの追記で構成されています。すべてのファイルが必要です。

ファイル1: spec/support/papertrail_change_tracker.rb - メインロジック

テスト実行前後のPaperTrail差分を検出し、レスポンス属性を自動分類する共有コンテキスト

# frozen_string_literal: true

RSpec.shared_context "papertrailでデータ変更内容を全てチェック", shared_context: :metadata do
  around(:each) do |example|
    prev_ids = PaperTrail::Version.pluck(:id)
    example.run
    newer_versions = PaperTrail::Version.where.not(id: prev_ids).sort_by(&:created_at)

    @new_versions = newer_versions.map do |version|
      # responseがnilまたはbodyが存在しない場合の安全な処理
      response_body = begin
        if response && response.respond_to?(:body) && response.body.respond_to?(:include?)
          # バイナリファイルの場合はエンコーディングエラーを避ける
          response.body.force_encoding("UTF-8") if response.body.encoding == Encoding::ASCII_8BIT
          response.body
        else
          ""
        end
      rescue Encoding::CompatibilityError, Encoding::UndefinedConversionError
        ""
      end

      {
        item_type: version.item_type,
        event: version.event,
        changeset: version.changeset,
        displayed_attributes: version.changeset.filter { |k, v| v[1].is_a?(String) && response_body.include?(v[1]) }.keys,
        hidden_attributes: version.changeset.filter { |k, v| !v[1].is_a?(String) || !response_body.include?(v[1]) }.keys
      }
    end

    if defined?(expected_behaviors) && !expected_behaviors.nil?
      # 定義されていてnil以外なら必ずテストをする
      expect(@new_versions).to match_papertrail_structure(expected_behaviors) # コメントアウトして無効化するなど、本末転倒なので絶対禁止です
    elsif defined?(expected_behaviors) && expected_behaviors.nil?
      # nilと明示的な場合はスキップ
      next
    else
      expect(@new_versions).to match_papertrail_structure([]) # 定義されていない場合は正解を伝えるエラーメッセージがでるようにmatch_papertrail_structureを叩く。とりあえず空配列を渡す
    end
  end
end

ファイル2: spec/support/papertrail_matcher.rb - マッチャーとヘルパー

期待値比較、自動生成JSONファイル出力、JSONファイル読み込み機能を提供

# frozen_string_literal: true

require "fileutils"

# JSONファイルからexpected_behaviorsを読み込むヘルパーメソッド
def load_expected_behaviors(filename)
  file_path = Rails.root.join("spec", "fixtures", "expected_behaviors", filename)
  # puts "load_expected_behaviors: #{file_path}"
  raw_data = JSON.parse(File.read(file_path)).map(&:deep_symbolize_keys)

  # 再帰的にa_kind_of変換を行う
  convert_a_kind_of(raw_data)
end

private
def convert_a_kind_of(obj)
  case obj
  when Array
    obj.map { |item| convert_a_kind_of(item) }
  when Hash
    obj.transform_values { |value| convert_a_kind_of(value) }
  when "a_kind_of(String)"
    a_kind_of(String)
  when "a_kind_of(Integer)"
    a_kind_of(Integer)
  when "a_kind_of(Float)"
    a_kind_of(Float)
  when "a_kind_of(TrueClass)"
    a_kind_of(TrueClass)
  when "a_kind_of(FalseClass)"
    a_kind_of(FalseClass)
  when "a_kind_of(Hash)"
    a_kind_of(Hash)
  when "a_kind_of(Array)"
    a_kind_of(Array)
  else
    obj
  end
end

def reverse_convert_a_kind_of(obj)
  case obj
  when String
    "a_kind_of(String)"
  when Integer
    "a_kind_of(Integer)"
  when Float
    "a_kind_of(Float)"
  when TrueClass
    "a_kind_of(TrueClass)"
  when FalseClass
    "a_kind_of(FalseClass)"
  when Hash
    "a_kind_of(Hash)"
  when Array
    "a_kind_of(Array)"
  when NilClass
    nil
  else
    raise "Unknown object: #{obj.class}"
  end
end

RSpec::Matchers.define :match_papertrail_structure do |expected|
  match do |actual|
    return false unless actual.length == expected.length

    actual.zip(expected).all? do |actual_version, expected_version|
      # 基本構造の確認
      actual_version[:item_type] == expected_version[:item_type] &&
      actual_version[:event] == expected_version[:event] &&
      # changesetの構造確認
      changeset_matches?(actual_version[:changeset], expected_version[:changeset]) &&
      # displayed_attributesとhidden_attributesの確認
      attributes_match?(actual_version[:displayed_attributes], expected_version[:displayed_attributes]) &&
      attributes_match?(actual_version[:hidden_attributes], expected_version[:hidden_attributes])
    end
  end

  failure_message do |actual|
    # index番号も使って比較内容を明示する
    all_expected_answer = ""
    actual.zip(expected).each_with_index.map do |(actual_version, expected_version), idx|
      # 基本構造の確認
      expected_answer = ""
      if expected_version
        expected_answer += "Got actual item_type: #{actual_version[:item_type]}, Expected: #{expected_version[:item_type].presence || "not defined"}\n" if actual_version[:item_type] != expected_version[:item_type]
        expected_answer += "Got actual event: #{actual_version[:event]}, Expected: #{expected_version[:event].presence || "not defined"}\n" if actual_version[:event] != expected_version[:event]
        expected_answer += "Got actual changeset: #{JSON.pretty_generate(actual_version[:changeset])}\n" unless changeset_matches?(actual_version[:changeset], expected_version[:changeset])
        expected_answer += "Got actual displayed_attributes: #{array_to_string(actual_version[:displayed_attributes])}\n" unless attributes_match?(actual_version[:displayed_attributes], expected_version[:displayed_attributes])
        expected_answer += "Got actual hidden_attributes: #{array_to_string(actual_version[:hidden_attributes])}\n" unless attributes_match?(actual_version[:hidden_attributes], expected_version[:hidden_attributes])
        all_expected_answer += "##{idx} is not match.\n" + expected_answer if expected_answer.present?
      else
        expected_answer += "Got actual item_type: #{actual_version[:item_type]}\n"
        expected_answer += "Got actual event: #{actual_version[:event]}\n"
        expected_answer += "Got actual changeset: #{JSON.pretty_generate(actual_version[:changeset])}\n"
        expected_answer += "Got actual displayed_attributes: #{array_to_string(actual_version[:displayed_attributes])}\n"
        expected_answer += "Got actual hidden_attributes: #{array_to_string(actual_version[:hidden_attributes])}\n"
        all_expected_answer += "##{idx} is not match. Expected is not defined.\n" + expected_answer if expected_answer.present?
      end
    end
    if expected.length == 0 && actual.length > 0
      all_expected_answer += "No expected_behaviors defined, Start with empty array. 'let!(:expected_behaviors) { [] }' and create actual JSON file after you recognize the changes.\n"
    elsif expected.length > actual.length
      all_expected_answer += "Your expected_behaviors array has #{expected.length} items, but the test only created #{actual.length} database changes. DELETE #{expected.length - actual.length} items from expected_behaviors.\n"
    elsif expected.length < actual.length
      all_expected_answer += "Your expected_behaviors array has #{expected.length} items, but the test created #{actual.length} database changes. ADD #{actual.length - expected.length} items to expected_behaviors.\n"
    end
    file_path = RSpec.current_example.metadata[:file_path]
    line_number = RSpec.current_example.metadata[:line_number]
    auto_generated_actual = actual.map do |version|
      changeset = version[:changeset].to_h do |key, value|
        before_value = value[0]
        after_value = value[1]
        [key, [reverse_convert_a_kind_of(before_value), reverse_convert_a_kind_of(after_value)]]
      end
      version.dup.tap do |v|
        v[:changeset] = changeset
      end
    end
    auto_generated_actual = JSON.pretty_generate(auto_generated_actual)
    tmp_rnd_auto_generated_actual_path = Rails.root.join("tmp", "papertrail_auto_generated", "#{file_path.tr("/", "_")}_#{line_number}.json")
    FileUtils.mkdir_p(tmp_rnd_auto_generated_actual_path.dirname)
    File.write(tmp_rnd_auto_generated_actual_path, auto_generated_actual)
    all_expected_answer += "Auto generated actual is saved to #{tmp_rnd_auto_generated_actual_path}. Rename and move this to spec/fixtures/expected_behaviors/.\n"
    "expected_behaviors is incorrect at #{file_path}:#{line_number}.\nRead carefully docs/papertrail-change-tracker.md before fixing.\n" + all_expected_answer
  end

  private
    def array_to_string(array)
      "[" + array.sort.join(",") + "]"
    end

    def change_matches?(actual_change, expected_change)
      return false unless actual_change.is_a?(Array) && actual_change.length == 2
      return false unless expected_change.is_a?(Array) && expected_change.length == 2

      # before値の比較
      before_matches = if expected_change[0].respond_to?(:matches?)
        expected_change[0].matches?(actual_change[0])
      else
        actual_change[0] == expected_change[0]
      end

      # after値の比較
      after_matches = if expected_change[1].respond_to?(:matches?)
        expected_change[1].matches?(actual_change[1])
      else
        actual_change[1] == expected_change[1]
      end

      before_matches && after_matches
    end

    def changeset_matches?(actual_changeset, expected_changeset)
      # キーを文字列に統一して比較
      actual_keys = actual_changeset.keys.map(&:to_s).sort
      expected_keys = expected_changeset.keys.map(&:to_s).sort
      return false unless actual_keys == expected_keys

      expected_changeset.all? do |key, expected_change|
        # 実際のキーは文字列、期待値のキーはシンボルの可能性があるので両方試す
        actual_change = actual_changeset[key] || actual_changeset[key.to_s]
        change_matches?(actual_change, expected_change)
      end
    end

    def attributes_match?(actual_attributes, expected_attributes)
      return true if expected_attributes.nil? && actual_attributes.nil?
      return false if expected_attributes.nil? || actual_attributes.nil?

      if expected_attributes.respond_to?(:matches?)
        # RSpecマッチャー(match_array等)を使用
        expected_attributes.matches?(actual_attributes)
      elsif expected_attributes.is_a?(Array) && actual_attributes.is_a?(Array)
        # 配列の場合は常に順序を無視した比較(自動でmatch_arrayの動作)
        actual_attributes.sort == expected_attributes.sort
      else
        # その他の場合は厳密比較
        actual_attributes == expected_attributes
      end
    end
end

ファイル3: spec/rails_helper.rb - 自動適用設定

全Request Specで自動的にPaperTrail監視を有効化

# この行を追加(他のRSpec設定と一緒に)
config.include_context "papertrailでデータ変更内容を全てチェック", type: :request

ファイル4: docs/papertrail-change-tracker.md - 詳細ドキュメント

**このドキュメントは必須です。**AIによる自動修正が正しく動作するために、使い方とトラブルシューティングの完全なガイドが必要です。

# PaperTrail変更監視システム

## 1. 概要

Request Specにおいて、エンドポイントが意図した通りのデータベース変更を行っているかを自動的に検証するシステムです。PaperTrailを使用してデータ変更を追跡し、期待される変更パターンと実際の変更を比較します。

## 2. 基本的な使用方法

### 2.1 基本的な定義

Request Specで `expected_behaviors`**必ず外部JSONファイルから読み込み** します:

```ruby
RSpec.describe "UsersController", type: :request do
  describe "POST /users" do
    let(:expected_behaviors) { load_expected_behaviors("user_creation.json") }

    it "ユーザーを作成する" do
      post "/users", params: { user: { email: "test@example.com", name: "John Doe" } }
      expect(response).to have_http_status(:created)
    end
  end
end
```

### 2.2 データ変更なしの場合

```ruby
context "取得処理" do
  let(:expected_behaviors) { [] }

  it "ユーザー一覧を取得する" do
    get "/users"
    expect(response).to have_http_status(:ok)
  end
end
```

### 2.3 コンテキスト別の定義

```ruby
context "新規作成の場合" do
  let(:expected_behaviors) { load_expected_behaviors("user_creation.json") }
end

context "更新の場合" do
  let(:expected_behaviors) { load_expected_behaviors("user_update.json") }
end
```

### 2.4 JSONファイルの構造

すべての期待値は外部JSONファイルで管理します。

```json
# spec/fixtures/expected_behaviors/user_creation.json
[
  {
    "item_type": "User",
    "event": "create",
    "changeset": {
      "id": [null, "a_kind_of(String)"],
      "email": [null, "a_kind_of(String)"],
      "created_at": [null, "a_kind_of(String)"]
    },
    "displayed_attributes": ["email"],
    "hidden_attributes": ["id", "created_at"]
  }
]
```

テストファイルでは以下のように読み込みます:

```ruby
let(:expected_behaviors) { load_expected_behaviors('user_creation.json') }
```

#### 2.4.1 JSONでのRSpecマッチャー指定
- `"a_kind_of(String)"``a_kind_of(String)`
- `"a_kind_of(Integer)"``a_kind_of(Integer)`
- `"a_kind_of(Float)"``a_kind_of(Float)`
- `"a_kind_of(TrueClass)"``a_kind_of(TrueClass)`
- `"a_kind_of(FalseClass)"``a_kind_of(FalseClass)`

**注意**: 現在は値の型しか見れてないので、同じ文字列同士でビフォーアフターを厳密に区別したい場合は直接JSONファイルを編集するように明示的に指示する必要があります。

## 3. データ構造

```ruby
{
  item_type: "User",                    # 変更されたモデル名
  event: "create",                      # イベント種類(create, update, destroy)
  changeset: {                          # 変更内容
    "属性名" => [変更前の値, 変更後の値]
  },
  displayed_attributes: [...],          # レスポンスに含まれる属性
  hidden_attributes: [...]              # レスポンスに含まれない属性
}
```

## 4. JSONファイルでの値の表現

JSONファイル内では動的な値や正規表現を文字列として表現します:

```json
{
  "changeset": {
    "id": [null, "a_kind_of(String)"],
    "email": [null, "test@example.com"],
    "status": [null, "active"]
  }
}
```

## 5. AI向け修正ガイド

### 5.1 基本的な修正手順

1. **--fail-fastでテスト実行**
2. **エラーメッセージから誤りの差分と、自動生成されたJSONファイルのパスを確認する**
3. **小さな差異でコケていたら差分をjqで修正。大きく異なっていたら自動生成JSONをspec/fixtures/expected_behaviors/にコピーして`expected_behaviors`をload_expected_behaviorsを使い定義**
4. **再テスト**
5. **JSONファイルを参照するよう修正**
```ruby
let(:expected_behaviors) { load_expected_behaviors("user_creation.json") }
```

### 5.2 効率的な修正方法:auto-generatedファイルの活用

**推奨**: テスト失敗時に自動生成される完全なJSONファイルを直接コピーする方法

1. **テスト実行してauto-generatedファイルを生成**
```bash
docker compose exec web bundle exec rspec spec/requests/your_spec.rb:123 --fail-fast
```

2. **エラーメッセージからauto-generatedファイルのパスを取得**
```
Auto generated actual is saved to /myapp/tmp/papertrail_auto_generated/._spec_requests_your_spec.rb_123.json.
```

3. **auto-generatedファイルを正しい場所にコピー**

⚠️ **重要**: テストファイルで既に`load_expected_behaviors("existing_name.json")`を使用している場合は、**必ずその既存のファイル名に上書き**すること。新しいファイル名を作成してはいけない。

**理由(WHY)**:
- プルリクエストの差分が明確になり、どこが変更されたか分かりやすくなる

```bash
# 【重要】既存のJSONファイルがある場合は必ず上書きすること(別名ファイル作成禁止)
# 理由: 差分が分かりにくくなり、メンテナンスが困難になる
docker compose exec web cp /myapp/tmp/papertrail_auto_generated/._spec_requests_your_spec.rb_123.json /myapp/spec/fixtures/expected_behaviors/user_creation.json

# 新規の場合のみ新しいファイル名を使用
docker compose exec web cp /myapp/tmp/papertrail_auto_generated/._spec_requests_your_spec.rb_123.json /myapp/spec/fixtures/expected_behaviors/your_new_feature.json
```

**📝 命名ルール**:
- 既存ファイル: **必ず上書き** - 差分追跡とメンテナンス性を重視
- 新規ファイル: 機能名を明確に表すファイル名を使用

4. **テストを再実行して確認**
```bash
docker compose exec web bundle exec rspec spec/requests/your_spec.rb:123 --fail-fast
```

### 5.3 RSpecスコープの問題と解決策

**症状**: `expected_behaviors`が正しく定義されているのに別のJSONファイルが読み込まれる

**原因**: RSpecの`let`は後に定義されたものが優先されるため、同じスコープ内で複数の`expected_behaviors`が定義されると衝突する

**問題のあるコード例**:
```ruby
describe "複数のテストケース" do
  let(:expected_behaviors) { load_expected_behaviors("test1.json") }  # ①

  it "テスト1" do
    # このテストで①が使われることを期待
  end

  let(:expected_behaviors) { load_expected_behaviors("test2.json") }  # ②

  it "テスト2" do
    # このテストで②が使われることを期待
    # 実際は②が両方のテストで使われる(RSpecの仕様)
  end
end
```

**解決策**: 個別のcontextでスコープを分離する
```ruby
describe "複数のテストケース" do
  context "テスト1のケース" do
    let(:expected_behaviors) { load_expected_behaviors("test1.json") }

    it "テスト1" do
      # 正しく①のJSONが使われる
    end
  end

  context "テスト2のケース" do
    let(:expected_behaviors) { load_expected_behaviors("test2.json") }

    it "テスト2" do
      # 正しく②のJSONが使われる
    end
  end
end
```

**重要**: 各テストで異なる`expected_behaviors`を使用する場合は、必ず個別のcontextで囲むこと

## 6. トラブルシューティング

### 6.1 特殊文字によるフレーキーテスト

**症状**: 同じ属性でも時々`displayed_attributes`に含まれたり`hidden_attributes`に含まれたりする

**原因**: FactoryBotで生成される文字列にHTMLエスケープ対象の特殊文字(`&`, `,`など)が含まれている場合:

```ruby
# FactoryBotの例
receipiant_company_name { Faker::Company.name }          # "Apple Inc" - 特殊文字なし → 安定
receipiant_department_name { Faker::Commerce.department } # "Home & Garden" - & を含む → フレーキー
```

**HTMLレスポンス内での変換**:
- 元の文字列: `"Home & Garden"`
- HTMLエスケープ後: `"Home &amp; Garden"`
- PaperTrailの判定: `response_body.include?("Home & Garden")`**false**(不一致)

**解決策**:
1. FactoryBotで特殊文字を避ける:
```ruby
receipiant_department_name { "部署#{SecureRandom.hex(3)}" }
```
2. または判定ロジックでHTMLエスケープを考慮する

### 6.2 jqを使った一括修正方法

複数のexpected_behaviorsを一度に修正する場合:
```bash
# 複数項目を一括で修正
jq '.[5].displayed_attributes = ["back_image_thumbnail_url", "front_image_thumbnail_url", "owner_id"] |
.[12].hidden_attributes = ["back_image_url", "created_at", "exported_count_for_print"] |
.[17].item_type = "User" |
.[18].item_type = "Card"' file.json > fixed.json && cp fixed.json file.json

# フレーキー属性の一括置換(displayed ⇔ hidden間の移動)
jq '.[].displayed_attributes |= (. - ["receipiant_department_name"]) |
.[].hidden_attributes |= (. + ["receipiant_department_name"])' file.json > fixed.json
```

## 7. 技術的詳細

### 7.1 自動適用
`spec/rails_helper.rb`で全Request Specに自動適用:
```ruby
config.include_context "papertrailでデータ変更内容を全てチェック", type: :request
```

### 7.2 変更検出メカニズム
1. テスト実行前に既存のPaperTrail::VersionのIDを記録
2. テスト実行
3. 新しく作成されたVersionレコードを取得
4. `expected_behaviors`と比較

### 7.3 レスポンス属性の自動判定
```ruby
displayed_attributes: changeset.filter { |k, v|
  v[1].is_a?(String) && response.body.include?(v[1])
}.keys
```

## 8. 実装ファイル

- **spec/support/papertrail_change_tracker.rb**: メインの共有コンテキスト
- **spec/support/papertrail_matcher.rb**: カスタムマッチャー実装
- **spec/rails_helper.rb**: Request Specへの自動適用設定

使用例: spec/requests/users_spec.rb - 実際のテスト

RSpec.describe "Users", type: :request do
  describe "POST /users" do
    let(:expected_behaviors) { load_expected_behaviors("user_creation.json") }

    it "ユーザーを作成する" do
      post "/users", params: { user: { email: "test@example.com", name: "John" } }
      expect(response).to have_http_status(:created)
      # PaperTrail監視は自動実行される
    end
  end
end

期待値ファイル: spec/fixtures/expected_behaviors/user_creation.json

[
  {
    "item_type": "User",
    "event": "create",
    "changeset": {
      "id": [null, "a_kind_of(String)"],
      "email": [null, "test@example.com"],
      "name": [null, "John"],
      "created_at": [null, "a_kind_of(String)"],
      "updated_at": [null, "a_kind_of(String)"]
    },
    "displayed_attributes": ["email", "name"],
    "hidden_attributes": ["id", "created_at", "updated_at"]
  }
]

システムの動作原理

基本的なアイデア:テスト実行前後のPaperTrail差分を見て、データ変更とレスポンス属性を自動検出する

3つのステップ

  1. テスト前prev_ids = PaperTrail::Version.pluck(:id) で既存レコードID記録
  2. テスト実行:実際のAPIテストが動く
  3. 差分検出:新しいVersionレコードから変更内容を抽出、レスポンスボディをスキャンして属性をdisplayed/hiddenに自動分類

自動修正支援

テスト失敗時に実際の変更をJSONファイルで自動生成 → AIが差分修正を支援

シードデータも含む包括的な監視

テスト実行前のシードやファクトリで生成されたレコードも全て記録されます。JSONファイルが長くなりますが、displayed_attributesのデータ漏れをチェックするために必要な処理です。

各ファイルの役割

papertrail_change_tracker.rb - 自動監視

  • around(:each)でテスト前後の処理を自動挿入
  • PaperTrail差分検出とレスポンス属性分類

papertrail_matcher.rb - 比較・生成

  • カスタムマッチャーで期待値と実際の結果を比較
  • 失敗時の詳細エラーメッセージと自動JSONファイル生成
  • load_expected_behaviorsでJSON読み込み

rails_helper.rb - 全体適用

  • 1行の設定で全Request Specに自動適用

AIファーストテストの実践的なワークフロー - 実際の開発手順

実際の開発者の作業フローを詳しく解説

従来のテスト作成では、開発者が手動で期待値を書く必要がありました。しかしAIファーストなテストでは、AIが自動修正できるように設計されているため、作業フローが次のように変わります。

🚀 フロー1: 新しくテストを作成

# 1. まずは最小限のテストを書く
RSpec.describe "Users", type: :request do
  describe "POST /users" do
    let(:expected_behaviors) { [] }  # 👈 空配列から開始!

    it "ユーザーを作成する" do
      post "/users", params: { user: { email: "test@example.com", name: "John" } }
      expect(response).to have_http_status(:created)
    end
  end
end
# 2. テストを実行(当然失敗する)
$ rspec spec/requests/users_spec.rb:123

# 3. 自動生成されたJSONファイルのパスが表示される
Auto generated actual is saved to /tmp/papertrail_auto_generated/_spec_requests_users_spec.rb_123.json
# 4. 生成されたJSONファイルを正しい場所にコピー。AIにまかせてもOK。「コケたテストのexpected_behaviorsの修正を開始して」と依頼する。
$ cp /tmp/papertrail_auto_generated/_spec_requests_users_spec.rb_123.json \
     spec/fixtures/expected_behaviors/user_creation.json

# 5. テストファイルを更新。AIにまかせてもOK。「コケたテストのexpected_behaviorsの修正を開始して」と依頼する。
let(:expected_behaviors) { load_expected_behaviors("user_creation.json") }

この時点でテストは完璧に動作します! 手動で期待値を書く必要は一切ありません。気になる人は生成されたJSONファイルを見て、確認することもできます。

🔧 フロー2: APIの変更に伴うテスト修正

例えば、APIレスポンスに新しい属性profile_image_urlを追加したとします。

# 1. テストを実行(期待値と実際の結果が一致しなくなる)
$ rspec spec/requests/users_spec.rb:123

# 2. 新しいJSONファイルが自動生成される
Auto generated actual is saved to /tmp/papertrail_auto_generated/_spec_requests_users_spec.rb_123.json

従来なら手動で期待値を修正する必要がありましたが、AIファーストテストでは:

# 3. AIに指示して差分修正を依頼
$ claude "rspec spec/requests/users_spec.rb:123を実行して、コケたテストを修正して"

# Claudeが最終的に実行するコード
cp /tmp/papertrail_auto_generated/_spec_requests_users_spec.rb_123.json \
     spec/fixtures/expected_behaviors/user_creation.json

AIが自動的に差分を理解して、適切にJSONファイルを更新してくれます。

**既存のJSONファイルを同じパスに上書きすることでテストはパスし、かつGitのDiffで変更内容が厳密に分かり意図しない変更にも気づくことができます。

🤖 AIファーストの真価:差分に集中できる開発

従来の開発フロー

  1. 仕様変更
  2. 手動でテスト修正(時間がかかる)
  3. テスト実行
  4. エラーがあれば再度手動修正
  5. 無限ループ...

AIファーストのフロー

  1. 仕様変更
  2. テスト実行(自動でJSONファイル生成)
  3. AIに「この差分を修正して」と依頼
  4. 完了!

開発者は「この変更は意図したものか?」の判断にのみ集中でき、手動でのテスト修正作業から完全に解放されます。

従来のテストとの比較

項目 従来のテスト AIファーストテスト
監視範囲 特定の属性のみ 全データ変更を包括的に監視
メンテナンス 手動で期待値更新 AI + 自動生成で効率化
副作用検出 見落としリスク高 意図しない変更を即座に検出
API仕様保証 部分的 レスポンス属性まで完全検証

このシステムのメリット

🔍 包括的な品質保証

  • データベースの全変更を漏れなく監視
  • API仕様とデータ変更の完全な一致を保証

🤖 AI時代に最適化

  • 大量の差分もAIが自動修正
  • 開発者は発生した差分にのみ注目でき手動対応を最小限に抑えられる

🛡️ セキュリティ強化

displayed_attributes: ["email"]           # 意図的にレスポンスに含める
hidden_attributes: ["password_digest"]    # 機密データの漏洩を防止

開発効率の向上

  • リファクタリング時の副作用を早期発見
  • テスト作成・保守コストの大幅削減

注意点・今後の改善点

現在の制限事項

  • Request Spec限定:レスポンス内容から表示プロパティの変更チェックが目的のため、現在はRequest Specのみ対応。モデルテストや他のテストでも使えるはず
  • HTMLエスケープ問題:特殊文字でフレーキーテストが発生する可能性
  • 日付フォーマット問題:日付などがフォーマットされている場合、displayed_attributesで厳密に一致せずマッチできない場合がある

まとめ:AIファーストテストの未来

AIが実装を書く時代だからこそ、テスト側でもAIを活用し、AIの特性に合わせて包括的に問題を検出する仕組みが重要になります。このPaperTrail変更監視システムは:

  • ✅ 意図しない副作用の早期発見
  • ✅ AIによる自動修正での効率化が可能
  • ✅ 開発者の負担軽減と品質向上の両立

まだ途中経過ですが、AI時代のテスト設計のスタンダードを模索する取り組みとして進めています。

Discussion