💬

本番バグを最小限にするためのrspecテストの観点の抑え方

2024/06/02に公開

困った!なんとなく分岐網羅すりゃいいか〜くらいでテスト書いてたぜ!😇

みたいに考えてテストケースを考えている方、意外と多いんじゃないでしょうか?
自分自身も、テストをちゃんと意識して書く前は、
それを質問されると結構タジタジになっちゃうタイプでした😅

そこからいくつかプロダクトに関わるようになり、テストを結構
しっかり書く経験をしてきた自負はありますが、いざ、他者に
「テストは書いていましたか?どんな観点で書いていたのでしょうか?」
と聞かれたときに、都度口頭で説明するのって手間だし、
何よりイメージしづらくて伝えにくい気がするなと感じました🙄

それならテキストとして、かつコードベースで書いちゃって、
イメージしやすいものを書いてしまえば、毎回口頭で説明する必要もなく、
誰にでも同じように伝えやすいと思い、こちらを書くモチベーションが湧きました💪

また、コードはrubyで書いていますが、一種の観点的な部分は
抽象化して、他の言語にも応用できるとこはあるんじゃないかと思います。

model(unit) spec

1: 分岐は網羅し、返り値を検証せよ

はい、やっぱりこれは基本として、ちゃんと必要ですね。
例として、

reserve.rb
# 予約の終了時間を取得するメソッド
# 「予約」には「延長時間」のオブジェクトがhas_oneで存在する
def reserve_end_at
    reserve_at + (extra_time&.extra_time || 0).minutes
end

上のメソッドのテストのケースとしては、

reserve_spec.rb
RSpec.describe Reserve, type: :model do
  describe '#reserve_end_at' do
    context 'reserveに紐づくextra_timeがない場合' do
      let!(:reserve1) { create(:reserve, reserve_at: Time.zone.parse('2023-11-14 14:00:00'), extra_time: nil) }
      it '予約終了日時が予約日時から30分後であること' do
        expect(reserve1.reserve_end_at).to eq Time.zone.parse('2023-11-14 15:30:00')
      end
    end

    context 'reserveに紐づくextra_timeがある場合' do
      let!(:extra_time) { create(extra_time, extra_time: 30) }
      let!(:reserve2) { create(:reserve, reserve_at: Time.zone.parse('2023-11-14 14:00:00'), extra_time:) }
      it '予約終了日時が予約日時から60分後であること' do
        expect(reserve2.reserve_end_at).to eq Time.zone.parse('2023-11-14 16:00:00')
      end
    end
  end
end

以下のように、引数で値が変わる処理は、それぞれのステータスでの検証を行います

reserve.rb
def self.reserve_label(status)
  case status
  in 'reserved'
    '予約済'
  in 'started'
    '実行中'
  in 'finished'
    '終了'
  end
end 
reserve_spec.rb
RSpec.describe Reserve, type: :model do
  describe '#reserve_label' do
    context 'statusがreservedの場合' do
      it '`予約済`が返却されること' do
        expect(Reserve.reserve_label('reserved')).to eq('予約済')
      end
    end

    context 'statusがstartedの場合' do
      it '`実行中`が返却されること' do
        expect(Reserve.reserve_label('started')).to eq('実行中')
      end
    end

    context 'statusがfinishedの場合' do
      it '`終了`が返却されること' do
        expect(Reserve.reserve_label('finished')).to eq('終了')
      end
    end
  end
end

2: リソース変更系は、DBの中身もチェックせよ

reserve.rb
# 予約データと延長データをまとめて作成
def save_reserve!
  ActiveRecord::Base.transaction do
    save! && extra_time&.save!
  end
end

このようなメソッドの場合、トランザクションで囲っています。
そのため、どちらかの更新が失敗したら、ロールバックされて、未更新のままになるようになるはずです。
ただ、extra_timeは&.で書かれているため、紐づかない可能性があります。

そのような場合は、以下のように、分岐も考慮しつつ、
DBの中身をchangeなどで作成されていることを確認しつつ、
DBの値も検証すると、十分網羅している、かなと思います🤔

reserve_spec.rb
RSpec.describe Reserve, type: :model do
  describe '#save_reserve!' do
    context 'reserveに紐づくextra_timeがある場合' do
      let!(:extra_time) { build(:extra_time, time: 30) }
      let!(:reserve2) { build(:reserve, reserve_at: Time.zone.parse('2023-11-14 14:00:00'), extra_time:) }

      it 'reserveとextra_timeのデータが作成されること' do
        expect { reserve1.save_reserve! }.
        to change { Reserve.count }.by(1). # from(0).to(1)でも同じ
        and change { ExtraTime.count }.by(1)

        result_reserve = Reserve.last
        result_extra_time = ExtraTime.last
        expect(result_reserve.extra_time).to eq(extra_time)
        expect(result_reserve.reserve_at).to eq(Time.zone.parse('2023-11-14 14:00:00'))
        expect(result_extra_time.time).to eq(30)
      end
    end

    context 'reserveが不正な値の場合' do
      let!(:extra_time) { build(:extra_time, time: 30) }
      let!(:reserve2) { build(:reserve, reserve_at: nil, extra_time:) }

      it 'reserveとextra_timeのデータが作成されないこと' do
        expect { reserve1.save_reserve! }.
        to change { Reserve.count }.by(0).
        and change { ExtraTime.count }.by(0)
      end
    end

    context 'extra_timeが不正な値の場合' do
      let!(:extra_time) { build(:extra_time, time: nil) }
      let!(:reserve2) { build(:reserve, reserve_at: nil, extra_time:) }

      it 'reserveとextra_timeのデータが作成されないこと' do
        expect { reserve1.save_reserve! }.
        to change { Reserve.count }.by(0).
        and change { ExtraTime.count }.by(0)
      end
    end
    context 'reserveに紐づくextra_timeがない場合' do
      let!(:reserve1) { build(:reserve, reserve_at: Time.zone.parse('2023-11-14 14:00:00'), extra_time: nil) }

      it 'reserveのみデータが作成されること' do
        expect { reserve1.save_reserve! }.
        to change { Reserve.count }.by(1).
        and change { ExtraTime.count }.by(0)
      end
    end
  end
end

3: 他のmodelメソッドを呼んでる場合は、モック化して、callしていることだけ検証せよ

テストをするうえで、別の関数をそのテストの中でcallしていると、どこまで網羅したらいいんや!
って悩んじゃう人もいたりするのではないでしょうか?(そんなことない?)
例えばさっきの関数を書き換えた場合のケースの話をすると

reserve.rb
# 予約データと延長データをまとめて作成
def save_reserve!
  ActiveRecord::Base.transaction do
    save! && extra_time&.save!

    user.send_notification!(message: '予約できました') # ユーザーに通知を送る(この中身も検証する??)
  end
end

このsend_notification!の振る舞いに関してどこまで担保すればいいのか?
という気持ちが生まれる人もいるのではないでしょうか?(自分も前はそうでした)

ただ、ここではあくまで該当関数のテストのみを網羅する形にしたいと思いますので
以下のようにモック化して、確認したい関数のテストに集中できるようにしましょう😀
(該当関数の返り値で結果が変わる場合は、それも網羅しておきましょう。)

reserve_spec.rb
RSpec.describe Reserve, type: :model do
  describe '#save_reserve!' do
    let!(:user) { create(:user) }
    context 'reserveに紐づくextra_timeがある場合' do
      let!(:extra_time) { build(:extra_time, time: 30) }
      let!(:reserve2) { build(:reserve, reserve_at: Time.zone.parse('2023-11-14 14:00:00'), extra_time:, user:) }

      it 'reserveとextra_timeのデータが作成されること' do
        expect { reserve1.save_reserve! }.
        to change { Reserve.count }.by(1). # from(0).to(1)でも同じ
        and change { ExtraTime.count }.by(1)

        result_reserve = Reserve.last
        result_extra_time = ExtraTime.last
        expect(result_reserve.extra_time).to eq(extra_time)
        expect(result_reserve.reserve_at).to eq(Time.zone.parse('2023-11-14 14:00:00'))
        expect(result_extra_time.time).to eq(30)
      end

      # これを追加
      it 'send_notificationが呼び出されること' do
        expect(user).to receive(:send_notification!).with(message: '予約できました')
        reserve1.save_reserve!
      end
    end

    context 'reserveが不正な値の場合' do
      let!(:extra_time) { build(:extra_time, time: 30) }
      let!(:reserve2) { build(:reserve, reserve_at: nil, extra_time:) }

      it 'reserveとextra_timeのデータが作成されないこと' do
        expect { reserve1.save_reserve! }.
        to change { Reserve.count }.by(0).
        and change { ExtraTime.count }.by(0)
      end

      # これを追加
      it 'send_notification!が呼び出されないこと' do
        allow_any_instance_of(User).to receive(:send_notification!).and_raise(ActiveRecord::RecordInvalid)
        expect(user)).to_not receive(:send_notification!)

        reserve1.save_reserve!
      end
    end
    
    ...(中略)

    # これを追加
    context 'send_notification!が失敗する場合' do
      it 'reserveとextra_timeが作成されないこと' do
        allow_any_instance_of(User).to receive(:send_notification!).and_raise(ActiveRecord::RecordInvalid)

        expect { reserve1.save_reserve! }.to raise_error(ActiveRecord::RecordInvalid)
        # 作成されていないことを確認
        expect(Reserve.count).to eq(0).
        expect(ExtraTime.count.to eq(0)
      end
    end  
  end
end

unitのテストは最低限この辺を網羅したら問題ないと思います。

4: バリデーションまで網羅したら尚良し

更に細かく見ておきたいという方は、validatesの方までしっかり網羅すると
バリデーションの定義漏れによるSQL側のエラーが発生するのを防げて更に安全かと思います。
検証についてはrspec-parameterizedのgemを用いると、簡潔に書けるかと思います🙆🏻‍♂️

例えば、以下のようにreserveのバリデーションを定義していた場合

reserve.rb
class Reserve < ApplicationRecord
  validates :reserve_at, presence: true
  validates :total_price, presence: true, numericality: { only_integer: true, greater_than: 0, allow_blank: true }
  enum status: {
    reserved: 0,
    working: 1,
    finished: 2,
    canceled: 3,
  }
end
reserve_spec.rb
 describe 'validate' do
    let!(:reserve) { build(:reserve) }
    describe 'reserve_at' do
      # presenceのチェック
      where(:case_name, :it, :value, :is_valid, :error_message) do
        [
          ['空でない場合', '有効であること', 1.hour.ago, true, []],
          ['空の場合', '無効であること', nil, false, ['が入力されていません']],
        ]
      end

      with_them do
        it params[:it].to_s do
          reserve.reserve_at = value
          expect(reserve.valid?).to eq is_valid
          expect(reserve.errors[:reserve_at]).to eq error_message
        end
      end
    end

    # not null なintegerのカラム
    describe 'total_price' do
      where(:case_name, :it, :value, :is_valid, :error_message) do
        [
          ['空でない場合', '有効であること', 1, true, []],
          ['空の場合', '無効であること', nil, false, ['が入力されていません']],
          ['0より大きい場合', '有効であること', 1, true, []],
          ['0の場合', '無効であること', 0, false, ['は0より大きい値にしてください']],
          ['マイナスの場合', '無効であること', -1, false, ['は0より大きい値にしてください']],
        ]
      end

      with_them do
        it params[:it].to_s do
          reserve.total_price = value
          expect(reserve.valid?).to eq is_valid
          expect(reserve.errors[:total_price]).to eq error_message
        end
      end
    end

    # enumの検証(定義している値が有効で、それ以外が無効である場合を検証)
    describe 'status' do
      where(:case_name, :it, :value, :is_valid, :error_message) do
        [
          ['reservedの場合', '有効であること', 'reserved', true, []],
          ['canceledの場合', '有効であること', 'canceled', true, []],
          ['finishedの場合', '有効であること', 'finished', true, []],
          ['canceledの場合', '有効であること', 'canceled', true, []],
        ]
      end

      with_them do
        it params[:it].to_s do
          reserve.status = value

          expect(reserve.valid?).to eq is_valid

          expect(reserve.errors[:status]).to eq error_message
        end
      end

      context 'enumで定義していない値が入力された場合' do
        it '例外が発生すること' do
          expect do
            reserve.status = :hoge
          end.to raise_error(ArgumentError, "'hoge' is not a valid status")
        end
      end
    end
  end
end

model specで満たしたいことは、

  • シンプルなif/else等の分岐が網羅されていること
  • 引数によって返り値が変わる部分を網羅している
  • その結果、返り値がテストケースに即した値を返却していること
  • オブジェクトのバリデーションが意図したとおりに設定されていること
  • 他のメソッド呼び出しは、呼び出せていることが担保されていること

という観点で、概ね品質は担保できるmodel specのコードが書けるではないかなと思います👼

request spec

自分が作るプロジェクトはrailsのapiモードにして、スキーマ定義に
openapiを使っているので、その前提のお話をします。

1: ステータスコードは基準を決めた上で網羅する

いままで関わったプロジェクトに関しては、ステータスコードは以下を主に用いることが多いです。

  • 200(OK)
  • 201(Created)
  • 204(No Content)
  • 401(Unauthorized)
  • 403(ForBidden)
  • 404(Not Found)
  • 422(Unprocessable Entity Error)
  • 500(Internal Server Error)

この中で、成功パターンである200と201,204あたりはまず検証するとして、
エラーパターンをどこまで検証する?という疑問が出るかと思います。

個人的な観点ですが、基本的にエラーに関しては

  • ユーザーの入力ミス
  • DB側のデータ欠損
  • アプリケーション側の権限不足

が大まかな原因となるため、
それ以外に関しては、意図的に500が発生することを検証するのは冗長である
(サーバーダウンやコードtypoなど、プロダクションのコードでは人為的に起こり得ない
ものなので、それはコードベースで確認しなくてもいいかな〜)と筆者は考えています👀

401の未認証も、おそらくRailsに関してはその確認処理をapplicationControllerに
定義していることが多いと思うので、各controllerでの検証の観点ではない、という考えで、
網羅しなくてもいいのかなと考えています。(もちろん、継承漏れとかまで事前検知できないですが、
そもそもその場合は認証ありきのテストが落ちるので、テスト実行すれば検知できると思う)

そのため、自分の場合は以下のステータスコードが発生するケースをrequest specで
検証するようにしています👌

  • 200(OK)
  • 201(Created)
  • 204(No Content)
  • 403(ForBidden)
  • 404(Not Found)
  • 422(Unprocessable Entity Error)

仮に、以下の様にopenapiのスキーマを定義していた場合、
ステータスコードに関しては、「200」と「404」の2つの観点のみ、
コードベースでの検証を行うと基準を決めるのが、観点を洗い出すときに楽になるかと思います👏

openapi.yml
openapi: 3.0.0
info:
  title: API
  description: knight navi Api definitions
  version: v1

servers:
  # docker-composeでprismをホストしているポート番号に変更
  - url: "http://localhost:8082/"

paths:
  /api/reserves/{label}:
    $ref: ./resources/paths/api/reserve.yml
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
reserve.yml
get:
  tags:
    - Reserves
  summary: 予約を取得
  operationId: getReserve
  security:
    - bearerAuth: []
  parameters:
    - in: path
      name: label
      required: true
      description: 予約のUID
      schema:
        type: string
  responses:
    "200":
      description: "OK"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/reserve_response"
    "401":
      description: "Unauthorized"
      content:
        application/json:
          schema:
            $ref: "../../../../components/schemas/responses.yml#/unauthorized"
    "404":
      description: "Not Found"
      content:
        application/json:
          schema:
            $ref: "../../../../components/schemas/responses.yml#/not_found"
    "500":
      description: "Internal Server Error"
      content:
        application/json:
          schema:
            $ref: "../../../../components/schemas/responses.yml#/internal_server_error"
components:
  schemas:
    reserve_response:
      type: object
      properties:
        reserve:
          $ref: "#/components/schemas/reserve_detail"
    reserve_detail:
      type: object
      required:
        - startAt
        - endAt
      properties:
        startAt:
          type: string
          description: 予約開始日時
          example: "2020-01-01 10:00:00"
          format: date-time
        end:
          type: string
          description: 予約終了日時
          example: "2020-01-01 10:00:00"
          format: date-time

2: スキーマの内容を担保せよ

committee-railsを使うと、openapiで定義したスキーマの中身やステータスコードが、
定義したものかを検証してくれるため、railsの動的型付けの弊害である、
型の定義ミス等(stringなのにintで定義していた!)を事前に防げます。
細かい設定方法等は他の方の記事を見ていただければと思います。

1のスキーマを検証するケースでは、例えば以下のようにcontrollerが定義されていたとします。

reserves_controller.rb
# frozen_string_literal: true

module Api
  class ReservesController < BaseController
    def show
      reserve = user.reserves.find_by!(label: params[:label])
      render json: {
        reserve: {
          startAt: reserve.reserve_at,
          endAt: reserve.reserve_end_at,
        },
      }
    end
  end
end

その時は、以下のように200と404のスキーマとステータスコードの確認を行うことで、
apiのエンドポイントの検証として、網羅できていると言える、と個人的には思います。

requests/reserves_spec
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe 'Api::ShopApi::Casts::Reserves', type: :request do
  describe 'GET /show' do
    let!(:user) { create(:user) }
    let!(:reserve) { create(:reserve, user:) }

    before do
      mock_login(user) # 認証はモック化している
    end

    context '成功パターン' do
      it 'return 200' do
        get api_reserve_path(label: reserve.label)
        expected_body = {
          reserve: {
            startAt: reserve.reserve_at,
            endAt: reserve.reserve_end_at,
          },
        }.to_json
        expect(response.body).to eq(expected_body)
        assert_response_schema_confirm(200)
      end
    end

    context '存在しないreserveのlabelを指定した場合' do
      it 'return 404' do
        get api_reserve_path(label: 'not_exist')
        expect(response.body).to eq({ message: '対象のデータは存在しません' }.to_json)
        assert_response_schema_confirm(404)
      end
    end
  end
end

3: controllerに分岐処理があれば、そこも網羅せよ

例えばですが、以下のようにcontrollerが定義されている場合を想定したとします。

reserves_controller.rb
# frozen_string_literal: true

module Api
  class ReservesController < BaseController
    def show
      reserve = user.reserves.find_by!(label: params[:label])
      render json: {
        reserve: {
          startAt: reserve.reserve_at,
          endAt: reserve.reserve_end_at,
          isFinished: reserve.status == 'finished'
          extraTime: reserve.extra_time&.time || 0,
        },
      }
    end
  end
end

この場合はisFinishedextraTimeが分岐によって返却内容が異なるので、
網羅するようなテストケースを記載します。

requests/reserves_spec
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe 'Api::ShopApi::Casts::Reserves', type: :request do
  describe 'GET /show' do
    let!(:user) { create(:user) }

    before do
      mock_login(user) # 認証はモック化している
    end

    context '成功パターン' do
      context 'extra_timeが存在する場合' do
        let!(:extra_time) { create(extra_time, extra_time: 30) }
        let!(:reserve) { create(:reserve, user:, extra_time:, status: 'finished') }

        it 'return 200' do
          get api_reserve_path(label: reserve.label)
          expected_body = {
            reserve: {
              startAt: reserve.reserve_at,
              endAt: reserve.reserve_end_at,
              isFinished: true,
              extraTime: 30,
            },
          }.to_json
          expect(response.body).to eq(expected_body)
          assert_response_schema_confirm(200)
        end
      end
      context 'extra_timeが存在しない場合' do
        let!(:reserve) { create(:reserve, user:, status: 'finished') }

        it 'return 200(extraTimeが0であること)' do
          get api_reserve_path(label: reserve.label)
          expected_body = {
            reserve: {
              startAt: reserve.reserve_at,
              endAt: reserve.reserve_end_at,
              isFinished: true,
              extraTime: 0,
            },
          }.to_json
          expect(response.body).to eq(expected_body)
          assert_response_schema_confirm(200)
        end
      end

      context 'statusが`finished`の場合' do
        let!(:reserve) { create(:reserve, user:, status: 'finished') }

        it 'return 200(finishedがtrueであること)' do
          get api_reserve_path(label: reserve.label)
          expected_body = {
            reserve: {
              startAt: reserve.reserve_at,
              endAt: reserve.reserve_end_at,
              isFinished: true,
              extraTime: 0,
            },
          }.to_json
          expect(response.body).to eq(expected_body)
          assert_response_schema_confirm(200)
        end
      end

      context 'statusが`finished`以外の場合' do
        let!(:reserve) { create(:reserve, user:, status: 'started') }

        it 'return 200(finishedがfalseであること)' do
          get api_reserve_path(label: reserve.label)
          expected_body = {
            reserve: {
              startAt: reserve.reserve_at,
              endAt: reserve.reserve_end_at,
              isFinished: false,
              extraTime: 0,
            },
          }.to_json
          expect(response.body).to eq(expected_body)
          assert_response_schema_confirm(200)
        end
      end
    end

    context '存在しないreserveのlabelを指定した場合' do
      it 'return 404' do
        get api_reserve_path(label: 'not_exist')
        expect(response.body).to eq({ message: '対象のデータは存在しません' }.to_json)
        assert_response_schema_confirm(404)
      end
    end
  end
end

ただ、これだと、返却内容が多いかつ、分岐が多いエンドポイントだと、
毎回の返却スキーマやステータスコードの検証が冗長になってしまうことがあるので、
1件目のテストに関しては

  • 分岐がすべてtrueになるケース

という条件で検証をして、その後に各ケースの方では、対象のデータがfalseになる
という形にしても個人的にはいいかなと思っています👀

requests/reserves_spec
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe 'Api::ShopApi::Casts::Reserves', type: :request do
  describe 'GET /show' do
    let!(:user) { create(:user) }

    before do
      mock_login(user) # 認証はモック化している
    end

    context '成功パターン' do
      context '分岐がすべてtrueになるパターンの場合' do
        let!(:extra_time) { create(extra_time, extra_time: 30) }
        let!(:reserve) { create(:reserve, user:, extra_time:, status: 'finished') }

        it 'return 200' do
          get api_reserve_path(label: reserve.label)
          expected_body = {
            reserve: {
              startAt: reserve.reserve_at,
              endAt: reserve.reserve_end_at,
              isFinished: true,
              extraTime: 30,
            },
          }.to_json
          expect(response.body).to eq(expected_body)
          assert_response_schema_confirm(200)
        end
      end

      context 'extra_timeが存在しない場合' do
        let!(:reserve) { create(:reserve, user:) }

        it 'extraTimeが0であること' do
          get api_reserve_path(label: reserve.label)
          expect(response.body['reserve']['extraTime']).to eq(0)
        end
      end

      context 'statusが`finished`以外の場合' do
        let!(:reserve) { create(:reserve, user:, status: 'finished') }

        it 'finishedがfalseであること' do
          get api_reserve_path(label: reserve.label)
          expect(response.body['reserve']['isFinished']).to be_falsey
        end
      end
    end

    context '存在しないreserveのlabelを指定した場合' do
      it 'return 404' do
        get api_reserve_path(label: 'not_exist')
        expect(response.body).to eq({ message: '対象のデータは存在しません' }.to_json)
        assert_response_schema_confirm(404)
      end
    end
  end
end

4: リソース更新系のエンドポイントは、最低限リソースの更新があったことを何かしらの値を用いて検証すれば良し

細かいリソースの更新処理に関しては、modelのspec等で網羅している場合は
そちらで検証できているので、そのケースであれば、changeメソッドを使ったりして、
リソースの更新があったことを検証してみましょう。
※ controllerでuser.saveみたいに呼んでいる場合は、
ちゃんと対象のカラムが更新されているかも確認したほうがより安全かなと思います👌

reserve.rb
# 予約データと延長データをまとめて作成(テスト済)
def save_reserve
  return false if invalid?

  ActiveRecord::Base.transaction do
    save! && extra_time&.save!
    true
  end
end
reserves_controller.rb
# frozen_string_literal: true

module Api
  module ShopApi
    module Casts
      class ReservesController < BaseController
        def create
          reserve = user.reserves.build(permitted_params)
          reserve.build_extra_time(permitted_extra_time_params)

          if reserve.save_reserve
            render json: { message: '予約できました' }, status: 201
          else
            render json: reserve.errors.full_messages.join(', ')), status: 422
          end
        end

        private

        def permitted_params
          params.require([:start_at, :end_at])
          params.permit(:start_at, end_at)
        end
        def permitted_extra_time_params
          params.require([:time])
          params.permit(:time)
        end
      end
    end
  end
end
reserves_spec.rb
RSpec.describe 'Api::ShopApi::Casts::Reserves', type: :request do
  describe 'POST /create' do
    let!(:user) { create(:user) }

    before do
      mock_login(user) # 認証はモック化している
    end

    context '成功パターン' do
      let!(:params) do
        {
          start_at: '2024-04-01 10:00:00',
          end_at: '2024-04-01 11:00:00',
          time: 30,
        }
      end

      it 'return 201' do
        post api_reserves_path, params: params
        expect(response.body).to eq({ message: '予約できました' }.to_json)
        assert_response_schema_confirm(201)
      end

      it '予約データと延長のデータが作成されていること' do
        # 細かい検証はsave_reserveのmodelテストで検証
        expect { post api_reserves_path, params: params }.
        to change { user.reserves.reload.count }.by(1).
        and change { user.reserves.reload.last&.extra_time&.time }.from(nil).to(30)
      end
    end

    context 'バリデーション不足している場合' do
      let!(:params) do
        {
          start_at: '2024-04-01 10:00:00',
          end_at: '2024-04-01 11:00:00',
          time: 0,
        }
      end

      it 'return 422' do
        post api_reserves_path, params: params
        expect(response.body).to eq({ message: '延長時間は0以上の値にしてください' }.to_json)
        assert_response_schema_confirm(201)
      end

      it '予約データと延長のデータが作成されていないこと' do
        expect { post api_reserves_path, params: params }.
        to change { Reserve.count }.by(0).
        and change { ExtraTime.count }.by(0)
      end
    end

    context 'paramsに不足がある場合' do
     let!(:params) do
        {
          end_at: '2024-04-01 11:00:00',
          time: 30,
        }
      end
      it 'return 400' do
        post api_reserves_path, params: params
        expect(response.body).to eq({ message: 'パラメータが不足しています' }.to_json)
        assert_response_schema_confirm(404)
      end

      it '予約データと延長のデータが作成されていないこと' do
        expect { post api_reserves_path, params: params }.
        to change { Reserve.count }.by(0).
        and change { ExtraTime.count }.by(0)
      end
    end

    context '存在しないreserveのlabelを指定した場合' do
      it 'return 404' do
        post api_reserves_path(label: 'not_exist')
        expect(response.body).to eq({ message: '対象のデータは存在しません' }.to_json)
        assert_response_schema_confirm(404)
      end

      it '予約データと延長のデータが作成されていないこと' do
        expect { post api_reserves_path, params: params }.
        to change { Reserve.count }.by(0).
        and change { ExtraTime.count }.by(0)
      end
    end
  end
end

model specで網羅されている所を、全く同じ内容をrequest specで検証する必要はありません。
(controllerで呼んでいるmodelの関数を網羅するようにテストケースを書いてしまうと、model specの責務が曖昧になる)

request specで満たしたいことは、

  • エンドポイントのリクエストが
    • 意図したステータスを返却すること
    • 意図したスキーマのレスポンスを返却すること
    • リソースが適切に更新されていること
    • 起こり得るエラーが適切にハンドリングされていること

この観点でテストを追加すれば、リクエストの部分で意図しない不具合は
防げるようになるのではないでしょうか👍

これで十分なの?

きっと、上で書きすぎだと思う人、逆にもっと書いた方がいい。と思う人はそれぞれいると思います。
ただ、自身が3年間関わっていたIdaasの新規プロダクト(2023年10月に事業売却)は、pdmが、
「上のようなサーバーのテストに合わせて、フロント(React)のテストも
しっかり書いていたおかげで、リリース後3年間の間に、本番で致命的な不具合は一度も
発生しなかったのは素晴らしいことです🎉」と言っていたので、ここまで網羅しただけの
成果はあったと思います。

もちろん、他の要因もあると思いますが。笑

テスト頑張り過ぎは開発速度とのトレードオフじゃないですか??

分かります。
テスト書く時間を、早く次の機能のコードを書く時間に当てたい気持ち。

なので、テストコードを頑張るのは 最初の数ファイルだけしっかり書く をまず目標にしましょう。

...え?それでいいの?
って思った方。

もちろん、その後もテストは書いてください。

ただ、最初にテストの観点や、形式みたいなのをしっかり書いておくと、
チーム開発等で後から参加した人たちは、そのコードを見て真似るように書いてくれます。
それだけでその人達のコードの品質が担保されやすくなります。
自分も徐々に勘所が分かってきて、テストを書く速度が上がってきます。

そして何より、最近ならGithub Copilotあたりが、テストコードも
サジェストしてくれるようになるので、しっかり書いておくとその分、
Copilotが頑張って、あなたのお手伝いをしてくれるようになってくれます。

そうすることで、結果的に
機能の開発速度を担保しつつ
テストコードによる品質の担保

の両軸が叶えられるようになると思います😄

最後に

まだまだ私自身も、上には上がいて、未熟であると思いながら、
これまでに関わったプロダクトで経験したことのアウトプットが、
今後の様々なプロダクトに関わる方の一助になってくれたら嬉しいです。
特にテストの観点は何も考えないと属人性の高いものになることもあるので、
こういった観点もあるよというのを知ったうえで、自分のプロダクトに最適な
ものを適用できればいいのかなと思います。

※ フロントのテストに関しては、別の記事でまたまとめようと思っているので、
ぜひ書いてほしいという方がいたらいいねぜひ押してください!励みになります🙌

Discussion