🙌

【RSpec入門】PATCHリクエストのテストを完全理解!記事更新機能で学ぶデータ更新処理のテスト

に公開

PATCHリクエストって何?

前回はPOST(作成)について学びましたが、今回は**PATCH(更新)**について学びます。

身近な例で理解しよう

Twitter(X)の編集機能を想像してください:

  1. 📝 すでに投稿したツイートの「編集」ボタンをクリック
  2. ✏️ 内容を修正(例:誤字を直す)
  3. 📤 「更新」ボタンをクリック(PATCHリクエスト)
  4. ✅ ツイートが更新される

これがPATCHリクエストの流れです!

POSTとPATCHの違い

操作 HTTPメソッド 何をする?
POST POST 新しいデータを作る 新しい記事を投稿
PATCH PATCH 既存のデータを更新 記事の内容を修正

コントローラーの実装を理解しよう

# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  def update
    # 1. 更新したい記事をIDで検索
    @article = Article.find(params[:id])
    
    # 2. 送られてきたデータで更新を試みる
    if @article.update(article_params)
      # 成功:更新された記事データを返す
      render json: @article
    else
      # 失敗:エラー内容を返す
      render json: @article.errors, status: :unprocessable_entity
    end
  end

  private

  def article_params
    # Strong Parameters:安全な項目だけを許可
    params.require(:article).permit(:title, :content)
  end
end

コードを1行ずつ解説

1. 更新対象の記事を検索

@article = Article.find(params[:id])
  • params[:id]:URLの:id部分(例:/articles/123123
  • Article.find:指定されたIDの記事をデータベースから検索
  • 見つからない場合はActiveRecord::RecordNotFoundエラーが発生

具体例

PATCH /articles/5  # → params[:id] = "5"
Article.find("5")  # → ID=5の記事を検索

2. データの更新処理

if @article.update(article_params)
  • @article.update:既存の記事データを更新
  • article_params:Strong Parametersでフィルタリングされた安全なデータ
  • 更新成功ならtrue、失敗ならfalseを返す

3. 成功時の処理

render json: @article
  • render json::更新された記事データをJSONで返す
  • ステータスコードは指定なしの場合、200(OK)が自動設定される

4. 失敗時の処理

render json: @article.errors, status: :unprocessable_entity
  • @article.errors:バリデーションエラーの詳細情報
  • status: :unprocessable_entity:HTTPステータス422(データに問題あり)

テストの実装:正常系

# spec/requests/articles_spec.rb
RSpec.describe "Articles", type: :request do
  describe "PATCH /articles/:id" do
    # テスト用の記事を事前に作成
    let(:article) { Article.create!(title: "古い記事", content: "これは古い記事の内容です。") }
    
    context '有効なパラメータの場合' do
      it '記事を更新できる' do
        # PATCHリクエストを送信
        patch "/articles/#{article.id}", params: {
          article: {
            title: "更新された記事",
            content: "これは更新された記事の内容です。"
          }
        }
        
        # レスポンスのステータスをチェック
        expect(response).to have_http_status(:ok)  # 200
        
        # レスポンスの内容をチェック
        json = JSON.parse(response.body)
        expect(json['title']).to eq('更新された記事')
        expect(json['content']).to eq('これは更新された記事の内容です。')
        
        # データベースの実際のデータもチェック
        article.reload
        expect(article.title).to eq('更新された記事')
        expect(article.content).to eq('これは更新された記事の内容です。')
      end
    end
  end
end

テストコードを1行ずつ解説

1. テスト用データの準備

let(:article) { Article.create!(title: "古い記事", content: "これは古い記事の内容です。") }
  • let:テストで使用する変数を定義(遅延評価)
  • Article.create!:記事をデータベースに実際に作成
  • !がついている理由:作成に失敗した場合にエラーを発生させる

なぜletを使う?

# ❌ beforeだと、使わないテストでも必ず実行される
before { @article = Article.create!(...) }

# ✅ letだと、使用するテストでのみ実行される
let(:article) { Article.create!(...) }

2. PATCHリクエストの送信

patch "/articles/#{article.id}", params: { article: { ... } }
  • patch:PATCHリクエストを送信するメソッド
  • "/articles/#{article.id}":URLにIDを埋め込み(例:/articles/5
  • params::更新用のデータ

実際のHTTPリクエストに換算すると

PATCH /articles/5
Content-Type: application/json

{
  "article": {
    "title": "更新された記事",
    "content": "これは更新された記事の内容です。"
  }
}

3. レスポンスの検証

expect(response).to have_http_status(:ok)
  • 更新成功時は200(OK)が返される
  • 作成時の201(Created)とは異なるので注意

4. 最重要!データベースの確認

article.reload
expect(article.title).to eq('更新された記事')
  • article.reloadデータベースから最新の状態を再読み込み
  • これを忘れると、メモリ上の古いデータでテストしてしまう!

なぜreloadが必要?

# テスト開始時
article = Article.create!(title: "古い記事", ...)  # メモリ上のオブジェクト

# PATCHリクエストでデータベースは更新される
patch "/articles/#{article.id}", params: { ... }

# でも、メモリ上のarticleオブジェクトは古いまま!
puts article.title  # => "古い記事"(更新されていない)

# reloadでデータベースから最新データを取得
article.reload
puts article.title  # => "更新された記事"(最新の状態)

異常系のテスト:バリデーションエラー

RSpec.describe "Articles", type: :request do
  describe "PATCH /articles/:id" do
    let(:article) { Article.create!(title: "古い記事", content: "これは古い記事の内容です。") }
    
    context '無効なパラメータの場合' do
      it '記事を更新できない' do
        # 更新前の値を保存
        original_title = article.title
        original_content = article.content
        
        # 無効なデータで更新を試みる
        patch "/articles/#{article.id}", params: {
          article: {
            title: "",      # 空文字(バリデーションエラー)
            content: "新しい内容"
          }
        }
        
        # エラー時のステータスコードをチェック
        expect(response).to have_http_status(:unprocessable_entity)  # 422
        
        # エラーメッセージの確認
        json = JSON.parse(response.body)
        expect(json['title']).to include("can't be blank")
        
        # データベースの内容が変更されていないことを確認
        article.reload
        expect(article.title).to eq(original_title)      # 元のタイトルのまま
        expect(article.content).to eq(original_content)  # 元の内容のまま
      end
    end
  end
end

異常系テストのポイント

1. 元の値を保存

original_title = article.title
original_content = article.content
  • 更新前の値を変数に保存
  • 後で「更新されていないこと」を確認するため

2. 部分的に無効なデータ

patch "/articles/#{article.id}", params: {
  article: {
    title: "",              # ❌ 無効(空文字)
    content: "新しい内容"    # ✅ 有効
  }
}
  • 一部だけ無効なデータを送信
  • 重要:有効な部分も更新されないことを確認

3. データが変更されていないことを確認

article.reload
expect(article.title).to eq(original_title)
expect(article.content).to eq(original_content)
  • reloadで最新状態を取得
  • 元の値と比較して、変更されていないことを確認

異常系のテスト:存在しない記事

RSpec.describe "Articles", type: :request do
  describe "PATCH /articles/:id" do
    context '存在しない記事の場合' do
      it '404エラーが返される' do
        # 存在しないIDを指定
        patch "/articles/99999", params: {
          article: {
            title: "更新されるはずの記事",
            content: "この更新は実行されません。"
          }
        }
        
        # 404エラーが返されることを確認
        expect(response).to have_http_status(:not_found)  # 404
      end
    end
  end
end

なぜ404になる?

@article = Article.find(params[:id])  # ID=99999の記事を検索
# → 見つからないので ActiveRecord::RecordNotFound エラー
# → Railsが自動で404ステータスを返す

部分更新のテスト

PATCHの強みは「部分更新」です。一部のフィールドだけ更新できます。

it 'タイトルのみ更新できる' do
  original_content = article.content
  
  # タイトルだけを更新
  patch "/articles/#{article.id}", params: {
    article: {
      title: "新しいタイトル"
      # contentは送信しない
    }
  }
  
  expect(response).to have_http_status(:ok)
  
  article.reload
  expect(article.title).to eq('新しいタイトル')      # 更新された
  expect(article.content).to eq(original_content)   # 変更されていない
end

部分更新の流れ

  1. 元のデータ{ title: "古い記事", content: "古い内容" }
  2. 送信データ{ title: "新しいタイトル" }
  3. 結果{ title: "新しいタイトル", content: "古い内容" }

PUTとPATCHの違い

  • PUT:全項目を置き換え(送信しなかった項目は消える)
  • PATCH:指定した項目だけ更新(送信しなかった項目はそのまま)

よくある間違いとトラブルシューティング

1. reloadを忘れる(超頻出ミス!)

# ❌ 間違ったテスト
it '記事を更新できる' do
  patch "/articles/#{article.id}", params: { ... }
  
  # reloadなしでチェック
  expect(article.title).to eq('更新された記事')  # ❌ 古いデータを見ている
end

# ✅ 正しいテスト
it '記事を更新できる' do
  patch "/articles/#{article.id}", params: { ... }
  
  article.reload  # データベースから最新データを取得
  expect(article.title).to eq('更新された記事')  # ✅ 最新データを確認
end

2. ステータスコードの間違い

# ❌ 間違ったステータス期待
expect(response).to have_http_status(:created)  # 201は作成時のステータス

# ✅ 正しいステータス期待
expect(response).to have_http_status(:ok)       # 200は更新時のステータス

3. IDの指定忘れ

# ❌ IDがないとエラー
patch "/articles", params: { ... }  # どの記事を更新するの?

# ✅ IDを明確に指定
patch "/articles/#{article.id}", params: { ... }  # ID=5の記事を更新

実践的なテストパターン

パターン1:更新前後の比較テスト

it '更新前後で正しく変更される' do
  # 更新前の状態を記録
  expect(article.title).to eq('古い記事')
  expect(article.content).to eq('これは古い記事の内容です。')
  
  # 更新実行
  patch "/articles/#{article.id}", params: {
    article: {
      title: "完全に新しいタイトル",
      content: "完全に新しい内容でとても長い文章です。"
    }
  }
  
  # 更新後の状態を確認
  article.reload
  expect(article.title).to eq('完全に新しいタイトル')
  expect(article.content).to eq('完全に新しい内容でとても長い文章です。')
  
  # 更新前と異なることを明示的に確認
  expect(article.title).not_to eq('古い記事')
  expect(article.content).not_to eq('これは古い記事の内容です。')
end

パターン2:タイムスタンプの更新確認

it '更新日時が正しく更新される' do
  original_updated_at = article.updated_at
  
  # 時間を少し進める(確実に更新日時が変わるように)
  travel 1.second do
    patch "/articles/#{article.id}", params: {
      article: { title: "タイムスタンプテスト" }
    }
  end
  
  article.reload
  expect(article.updated_at).to be > original_updated_at  # 更新日時が新しくなった
  expect(article.title).to eq('タイムスタンプテスト')
end

travelって何?

  • テスト用の時間操作メソッド
  • travel 1.second:時間を1秒進める
  • 更新日時のテストで重宝します

パターン3:複数フィールドの部分更新

it '一部のフィールドのみ更新される' do
  original_content = article.content  # 元の内容を保存
  
  # タイトルだけ更新(contentは送信しない)
  patch "/articles/#{article.id}", params: {
    article: {
      title: "タイトルだけ変更"
      # contentは意図的に送信しない
    }
  }
  
  article.reload
  expect(article.title).to eq('タイトルだけ変更')    # 更新された
  expect(article.content).to eq(original_content)   # 変更されていない
end

異常系のテスト:詳細版

context '無効なパラメータの場合' do
  it '記事を更新できずエラーが返される' do
    # 更新前の値を全て保存
    original_title = article.title
    original_content = article.content
    original_updated_at = article.updated_at
    
    # 無効なデータで更新を試みる
    patch "/articles/#{article.id}", params: {
      article: {
        title: "",         # ❌ 空文字(必須項目違反)
        content: "短い"     # ❌ 10文字未満(長さ制限違反)
      }
    }
    
    # エラーレスポンスの確認
    expect(response).to have_http_status(:unprocessable_entity)  # 422
    
    # エラーメッセージの詳細確認
    json = JSON.parse(response.body)
    expect(json['title']).to include("can't be blank")           # タイトルエラー
    expect(json['content']).to include("is too short")          # 内容エラー
    
    # データベースが変更されていないことを確認
    article.reload
    expect(article.title).to eq(original_title)           # 元のタイトルのまま
    expect(article.content).to eq(original_content)       # 元の内容のまま
    expect(article.updated_at).to eq(original_updated_at) # 更新日時も変わっていない
  end
end

存在しない記事の更新テスト

context '存在しない記事の場合' do
  it '404エラーが返される' do
    # 存在しないID(99999)を指定
    patch "/articles/99999", params: {
      article: {
        title: "更新されるはずの記事",
        content: "この更新は実行されません。最低10文字以上の内容です。"
      }
    }
    
    # 404エラーが返されることを確認
    expect(response).to have_http_status(:not_found)  # 404
  end
end

エラーの流れ

  1. Article.find(99999)を実行
  2. ID=99999の記事が見つからない
  3. ActiveRecord::RecordNotFoundエラーが発生
  4. Railsが自動で404レスポンスを返す

実際のエラーレスポンス例

バリデーションエラー時

リクエスト

{
  "article": {
    "title": "",
    "content": "短い"
  }
}

レスポンス(ステータス422)

{
  "title": ["can't be blank"],
  "content": ["is too short (minimum is 10 characters)"]
}

記事が見つからない時

リクエスト

PATCH /articles/99999

レスポンス(ステータス404)

{
  "error": "Record not found"
}

テストで確認すべきチェックリスト

✅ 正常系で確認すること

  1. HTTPステータス:200(OK)が返るか?
  2. レスポンス内容:更新後のデータが正しく返るか?
  3. データベース:実際にDBのデータが更新されたか?
  4. 部分更新:指定しなかった項目は変更されないか?

✅ 異常系で確認すること

  1. バリデーションエラー:422ステータスが返るか?
  2. エラーメッセージ:分かりやすいメッセージか?
  3. データ保護:無効な更新でDBが変更されないか?
  4. 存在しない記事:404ステータスが返るか?

PATCHとPOSTの違いをテストで比較

項目 POST(作成) PATCH(更新)
URL /articles /articles/:id
目的 新規作成 既存データ更新
成功ステータス 201 (Created) 200 (OK)
データ変化 countが+1 countは変わらず
テスト対象記事 不要 事前に作成が必要

まとめ

PATCHリクエストのテストで重要なポイント:

  1. テスト用データの準備letで更新対象を作成
  2. URLにIDを含める/articles/#{article.id}
  3. reloadでデータベース確認:メモリとDBの状態は別物
  4. 3つのシナリオ:正常系・バリデーションエラー・存在しない記事
  5. 適切なステータス:成功時200、エラー時422、見つからない時404

覚えておきたい魔法の呪文

# データ準備
let(:article) { Article.create!(title: "古いタイトル", content: "古い内容で十分な長さがあります。") }

# 更新実行
patch "/articles/#{article.id}", params: { article: { title: "新しいタイトル" } }

# 確認の三点セット
expect(response).to have_http_status(:ok)     # ステータス確認
json = JSON.parse(response.body)              # レスポンス解析  
article.reload                                # DB状態更新

次回は、DELETEリクエスト(削除処理)のテストについて学びます。
更新まで理解できれば、削除はもっと簡単ですよ!

Happy Testing! 🎉

Discussion