こんにちは。SmartHRプロダクトエンジニアをしているdelhi09です。私が所属するチームは「基本機能」と呼ばれるSmartHR最大のRailsアプリケーションに携わっています。
SmartHRには「従業員情報更新申請」という従業員情報の更新を申請する機能が存在します。例えば結婚による姓の変更や引っ越しによる通勤経路の変更が代表的なユースケースです。先日、「従業員情報更新申請」の適用日に申請当日よりも未来の日付を設定できる機能がリリースされました。
本リリースにより、来月に引っ越すことが決まっている場合に事前に通勤経路の変更申請を提出しておくといったことが可能になりました。
この記事では本機能の開発において、ユニットテストにモックを使うべきか悩んだ事例を題材に、ユニットテストにおけるモックを巡る思想について深掘りしてみたいと思います。
前提: SmartHRは従業員情報にBitemporal Data Modelを採用している
SmartHRは従業員データにBitemporal Data Modelを採用しています。Bitemporal Data Modelは「有効期間」と「システム期間」という2つの時間の概念を使って、リレーショナルデータベースで複雑な要件の履歴を扱える手法です。理論的な説明は例えばマーティン・ファウラーのブログに詳しいです。 https://martinfowler.com/articles/bitemporal-history.html
SmartHRでのBitemporal Data Modelの活用に関しては、Kaigi on Rails 2025の登壇資料や過去のテックブログの記事が複数存在するので詳細な説明は割愛します。
- 履歴 on Rails: Bitemporal Data Modelで実現する履歴管理
- ActiveRecord::Bitemporalとの歩み方
- 難解! SmartHRの部署のデータ構造とBitemporal Data Modelが組み合わさると...
- 入社してわかったSmartHR本体の難しさ
Bitemporal Data Modelで未来の従業員情報を作成する処理は複雑である
Bitemporal Data Modelでは、未来に適用される申請を承認した時点で以下のようなデータが作成されます。
申請前
| ID | 従業員ID | 通勤経路 | 有効期間from | 有効期間to | システム期間from | システム期間to |
|---|---|---|---|---|---|---|
| 1 | 625ff3d1 | 新宿駅〜六本木駅 | 2025/04/01 | 9999/12/31 | 2025/04/01 | 9999/12/31 |
申請後
| ID | 従業員ID | 通勤経路 | 有効期間from | 有効期間to | システム期間from | システム期間to |
|---|---|---|---|---|---|---|
| 1 | 625ff3d1 | 新宿駅〜六本木駅 | 2025/04/01 | 9999/12/31 | 2025/04/01 | 2026/02/10 |
| 2 | 625ff3d1 | 新宿駅〜六本木駅 | 2026/02/10 | 2026/03/01 | 2026/02/10 | 9999/12/31 |
| 3 | 625ff3d1 | 渋谷駅〜六本木駅 | 2026/03/01 | 9999/12/31 | 2026/02/10 | 9999/12/31 |
単純化した例ですが、これだけでも複雑に感じられる方もいるかもしれません。実際のアプリケーションでは未来の従業員情報が既に存在する場合のデータの調整や付随するデータ作成などがあるため、より複雑な処理になります。
※ Bitemporal Data Modelは本記事の主題ではないので、難しそうということを感じてもらえればOKです。
「未来の従業員情報を作成する処理」は別チームがオーナーシップを持っている
「未来の従業員情報を作成する処理」自体が複雑な処理であるため、当該処理は私たち従業員情報申請の開発チームとは別のチームが汎用的な関数(以降「未来の従業員情報作成関数」と呼ぶ)として実装しており、関数の仕様変更のオーナーシップもそのチームが持っています。
---
title: 未来の従業員情報作成の依存関係
---
flowchart LR
A[従業員情報申請]
B[["未来の従業員情報作成関数"]]
subgraph Global [SmartHRの共通機能のソースコード]
direction LR
subgraph TeamA [私たちのチーム管轄]
A
end
subgraph TeamB [別チーム管轄]
B
end
A -- 呼び出し --> B
end
style Global fill:#F3F4F6,stroke:#9CA3AF,stroke-width:2px
style TeamA fill:#FFE4E6,stroke:#F43F5E,stroke-width:2px
style TeamB fill:#DBEAFE,stroke:#3B82F6,stroke-width:2px
style A fill:#FFFFFF,stroke:#F43F5E,stroke-width:1px
style B fill:#FFFFFF,stroke:#3B82F6,stroke-width:1px
ユニットテストで「未来の従業員情報作成関数」の内部まで検証するべきか悩む
私たちのチームは「未来の従業員情報作成関数」を使用する側です。その際に、従業員情報更新申請のユニットテストの実装方針に関して以下の2つのアプローチのどちらにするかで悩みました。
- 「未来の従業員情報作成関数」が作成した分も含めてDBのデータの状態を検証する
- 「未来の従業員情報作成関数」をモックして、当該関数を意図したパラメータで呼び出していることを検証する
以下、それぞれのアプローチのイメージをRSpecのサンプルコードで記載しています。
アプローチ1のイメージ
it "適用日が未来日の場合に、未来の従業員情報が作成されること" do employee = FactoryBot.create(:employee) application = FactoryBot.create(:application, employee: employee, route: "渋谷駅〜六本木駅") # 依存コンポーネントも実際に動かし、副作用(DB更新)を直接確認する application.approve!(apply_date: 3.days.since) last_history = Employee.most_future(employee.id) expect(last_history.route).to eq "渋谷駅〜六本木駅" end
アプローチ2のイメージ
it "適用日が未来日の場合に、「未来の従業員情報作成関数」を正しいパラメータで呼び出していること" do employee = FactoryBot.create(:employee) application = FactoryBot.create(:application, employee: employee, route: "渋谷駅〜六本木駅") # 「未来の従業員情報作成関数」が意図した引数で呼ばれることを検証する expect(EmployeeFutureHistoryService).to receive(:create).with( employee_id: employee.id, application_id: application.id ) application.approve!(apply_date: 3.days.since) end
仮に 「未来の従業員情報作成関数」がマイクロサービス的な文脈での別サービスのAPIであり、ネットワークを介して呼び出すのであればアプローチ2しか選択肢がないので迷いません。しかし、今回のケースでは「未来の従業員情報作成関数」は従業員情報申請と同じコードベース上に存在する素朴な関数だったので、1.2.のどちらの方針でテストを書くことも可能でした。
結論から述べると私たちのチームは2.の方針にしました。本記事ではそこまでの思考の過程を紹介したいと思います。
判断のヒントを得るために『単体テストの考え方/使い方』という本を読む
判断のヒントを得るために『単体テストの考え方/使い方』という本の第5章「モックの利用とテストの壊れやすさ」を読みました。本書には「古典学派」と「ロンドン学派」というユニットテストに対する考え方が異なる2つの流派が紹介されています。2つの流派の違いをモック1の使い方に絞ってざっくり説明すると、以下のようになります。
- 古典学派
- なるべくモックを使わず、テスト対象の処理が実行された結果の状態を検証する
- ロンドン学派
- モックを多用し、テスト対象の処理時のモックとのコミュニケーションを検証する
今回の文脈では、アプローチ1が古典学派的、アプローチ2がロンドン学派的ということになります。
「基本機能」のユニットテストへのアプローチは古典学派寄りである
SmartHRの「基本機能」の既存のテストコードをみると全体的に以下の傾向です。
- 実物のテスト用のDBを使用する
- 処理後のDBの状態を検証する
これは処理後の状態を検証する古典学派のアプローチです。従って、SmartHRの「基本機能」のユニットテストの全体的な方針は古典学派寄りであるといえます。
古典学派もモックを使う
では、今回もアプローチ1の方針でモックを使わずに書くのがよいのでしょうか?ここで少し立ち止まってみたいと思います。
古典学派はなるべくモックを使わないと書きましたが、全く使わないわけではありません。『単体テストの考え方/使い方』の5章には古典学派もモックを使う場合があると書かれています。それは「システム間コミュニケーション」のケースです。以下に当該箇所を引用しました。
ロンドン学派は不変の依存を除くすべての依存に対してモックを使うことを推奨しており、その依存がシステム内コミュニケーションを行うものなのか、それとも、システム間コミュニケーションを行うものなのかの違いを意識していません。そのため、ドメイン・クラス同士のコミュニケーションに対する検証であっても外部アプリケーションとのコミュニケーションに対する検証と同じような検証をすることになります。(中略)
一方、古典学派はこの点に関して優れており、テスト・ケース間で共有される共有依存のみをモックに置き換えるように提唱しています。この共有依存とは、通常、メール・サービスやメッセージ・バスなどのプロセス外依存のことを意味します。
—— Vladimir Khorikov著/須田智之訳『単体テストの考え方/使い方』株式会社 マイナビ出版、2022年、p.161(強調は筆者による)
「システム間コミュニケーション」という言葉は本書内で「システム内コミュニケーション」との対比で使われている言葉です。「システム内コミュニケーション」とはアプリケーション内部のクラス間の通信を指します。これに対して「システム間コミュニケーション」の例として以下が挙げられています。
- メール配信サービス (筆者注: AWS SESやSendgridなど)
- メッセージ・バス (筆者注: AWS SNSやApache Kafkaなど)
要するに「システム間コミュニケーション」とは管理下にない外部システムとの連携のことを指していると解釈できます。
他チームがオーナーシップを持っている関数はシステム内なのかシステム外なのか
ここまでの『単体テストの考え方/使い方』の5章の内容をふまえると、私たちが属している古典学派におけるモック利用の方針は以下であるといえます。
- システム内部のコミュニケーション: モックを使わない
- 外部システムとのコミュニケーション: モックを使う
従って、元々の課題である「未来の従業員情報作成関数」をモックするか否かは、その関数をシステム内部と外部システムのどちらと見做すかという問いに置き換えられます。
システム内部と解釈する根拠としては「同一リポジトリ内に存在する」「素朴なRuby(Rails)の関数である」という点が挙げられます。一方、外部システムと解釈する根拠としては「私たちのチームがオーナーシップを持っていない」という点があります。どちらで解釈しても筋の通ったロジックは作ることができそうです。
そこで、実利観点で当該関数をモックする/しないメリットを考えてみると以下の表のように整理できました。
| 観点 | モックする | モックしない |
|---|---|---|
| 責務の明確さ | ✅ チーム境界と一致 | ❌ 責務が曖昧 |
| 認知負荷 | ✅ 内部実装を知らなくてよい | ❌ 内部実装の理解が必要 |
| テストの壊れにくさ | ✅ 関数変更の影響を受けない | ❌ 関数変更で壊れる |
| デグレ検出 | ❌ 内部変更のデグレに気付けない | ✅ 内部変更のデグレに気付ける |
私たちのチームでは、責務の明確さと認知負荷の軽減を重視しました。
結論: 「未来の従業員情報作成関数」はモックする
これまでの議論を踏まえ、「未来の従業員情報作成関数」は外部システムと解釈してモックする方針としました。
ただし、「従業員情報更新申請」の適用日に未来日を設定できる機能全体のオーナーシップは私たちにあるため、「未来の従業員情報作成関数」のバグが原因であっても不具合が発生したら私たちのチームの責任です。その点はシナリオテストで担保する方針としました。
おわりに
始まりはオーナーシップが入り組んだ機能のユニットテストでモックを使うべきかという疑問でした。開発全体で見るとささやかな話ではありますが、理論づけて意思決定したいと思い調べてみると、ユニットテストにおける古典学派とロンドン学派という深い議論が背景にあることが分かり、学びになりました。
We Are Hiring!
SmartHRでは今回の事例のように複数のスモールチームが連携し合ってお客様に素早く大きな価値を届けています。このような開発体制に少しでも興味を持っていただけたら、ぜひカジュアル面談でざっくばらんにお話ししましょう!
- 本書内ではテスト・ダブル、モック、スパイ、ダミー、フェイクといった用語が厳密に区別されていますが、本記事では「テスト対象の依存先の本来の振る舞いをテスト用に差し替えたもの」をざっくりモックと呼びます。↩