本番バグを最小限にするためのrspecテストの観点の抑え方
困った!なんとなく分岐網羅すりゃいいか〜くらいでテスト書いてたぜ!😇
みたいに考えてテストケースを考えている方、意外と多いんじゃないでしょうか?
自分自身も、テストをちゃんと意識して書く前は、
それを質問されると結構タジタジになっちゃうタイプでした😅
そこからいくつかプロダクトに関わるようになり、テストを結構
しっかり書く経験をしてきた自負はありますが、いざ、他者に
「テストは書いていましたか?どんな観点で書いていたのでしょうか?」
と聞かれたときに、都度口頭で説明するのって手間だし、
何よりイメージしづらくて伝えにくい気がするなと感じました🙄
それならテキストとして、かつコードベースで書いちゃって、
イメージしやすいものを書いてしまえば、毎回口頭で説明する必要もなく、
誰にでも同じように伝えやすいと思い、こちらを書くモチベーションが湧きました💪
また、コードはrubyで書いていますが、一種の観点的な部分は
抽象化して、他の言語にも応用できるとこはあるんじゃないかと思います。
model(unit) spec
1: 分岐は網羅し、返り値を検証せよ
はい、やっぱりこれは基本として、ちゃんと必要ですね。
例として、
# 予約の終了時間を取得するメソッド
# 「予約」には「延長時間」のオブジェクトがhas_oneで存在する
def reserve_end_at
reserve_at + (extra_time&.extra_time || 0).minutes
end
上のメソッドのテストのケースとしては、
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
以下のように、引数で値が変わる処理は、それぞれのステータスでの検証を行います
def self.reserve_label(status)
case status
in 'reserved'
'予約済'
in 'started'
'実行中'
in 'finished'
'終了'
end
end
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の中身もチェックせよ
# 予約データと延長データをまとめて作成
def save_reserve!
ActiveRecord::Base.transaction do
save! && extra_time&.save!
end
end
このようなメソッドの場合、トランザクションで囲っています。
そのため、どちらかの更新が失敗したら、ロールバックされて、未更新のままになるようになるはずです。
ただ、extra_timeは&.
で書かれているため、紐づかない可能性があります。
そのような場合は、以下のように、分岐も考慮しつつ、
DBの中身をchangeなどで作成されていることを確認しつつ、
DBの値も検証すると、十分網羅している、かなと思います🤔
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
callしている
ことだけ検証せよ
3: 他のmodelメソッドを呼んでる場合は、モック化して、テストをするうえで、別の関数をそのテストの中でcallしていると、どこまで網羅したらいいんや!
って悩んじゃう人もいたりするのではないでしょうか?(そんなことない?)
例えばさっきの関数を書き換えた場合のケースの話をすると
# 予約データと延長データをまとめて作成
def save_reserve!
ActiveRecord::Base.transaction do
save! && extra_time&.save!
user.send_notification!(message: '予約できました') # ユーザーに通知を送る(この中身も検証する??)
end
end
このsend_notification!の振る舞いに関してどこまで担保すればいいのか?
という気持ちが生まれる人もいるのではないでしょうか?(自分も前はそうでした)
ただ、ここではあくまで該当関数のテストのみを網羅する形にしたいと思いますので
以下のようにモック化して、確認したい関数のテストに集中できるようにしましょう😀
(該当関数の返り値で結果が変わる場合は、それも網羅しておきましょう。)
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のバリデーションを定義していた場合
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
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: 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
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が定義されていたとします。
# 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のエンドポイントの検証として、網羅できていると言える、と個人的には思います。
# 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が定義されている場合を想定したとします。
# 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
この場合はisFinished
とextraTime
が分岐によって返却内容が異なるので、
網羅するようなテストケースを記載します。
# 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になる
という形にしても個人的にはいいかなと思っています👀
# 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
みたいに呼んでいる場合は、
ちゃんと対象のカラムが更新されているかも確認したほうがより安全かなと思います👌
# 予約データと延長データをまとめて作成(テスト済)
def save_reserve
return false if invalid?
ActiveRecord::Base.transaction do
save! && extra_time&.save!
true
end
end
# 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
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