はじめに
勉強会絶対わすれないくんの新規機能開発を行った。
※勉強会未定の日程をリマインドしたり、授業テーマ募集チャンネル上の人気のある投稿をpickupしたり

その際にこれまで整備していなかった自動テストを導入したのだが、工夫した点とそれによって実際に享受できたメリットについて共有したい。
勉強会絶対わすれない君のソースコードはこちら
(TRaT githubへのアクセス権があるアカウントでのログインが必要)
なぜ自動テストか? 〜自動テストの目的〜
ソフトウェア開発3本柱(引用元:和田卓人さんの講演動画)
- Version Control(バージョン管理)
- Automation(デプロイの自動化)
- Testing(自動テスト)
Version Control → TRaTのGitHub上で管理しているのでクリア
Automation → GitHub ActionsにてEC2へのデプロイをCI/CD化しているのでクリア
Testing → 未整備。NG。
自動テストで何を狙うか?
→ 一番の目的は、コード変更時にデグレ(=既存の機能へのバグ)をおこしていないことを、迅速かつ抜け漏れなく確認すること
例、自動テストがない場合

上記のようなことを考えているとなんだかんだテスト実行するまでに1分くらいかかる。実装←→テストのフィードバックループを回す上ではやや長い。
理想は5秒以内にフィードバックが欲しい。
あとは毎回勘と経験に頼っていると、いつかテストケース抜ける。
下記のような状態が理想。安心して機能追加やリファクタリングを進められる。

「リファクタリングへの耐性」の確保
「コード変更時のデグレの有無を迅速かつ抜け漏れなく確認」を目的にする場合、大事にしたいポイントの1つは「リファクタリングへの耐性」
「リファクタリングへの耐性」とは、リファクタリング(=外から見た振る舞いを変えずに実装をキレイにする)を実施した際に、テストの結果がかわらない性質のこと。
「リファクタリングへの耐性」を担保するためには実装の詳細には着目せず、入力に対する最終的な出力、つまりふるまいにだけ着目したテストを書くことがポイント
テストダブルと「リファクタリングへの耐性」の関係
基本的にはテストダブル(スタブ、モック)を使うと実装の詳細、内部の手順に着目したテストとなってしまう。
外部との連携(Firebase,Slack,現在時刻取得)以外にはテストダブルはつかわない方針で考えていく。
前提:「勉強会絶対わすれないくん」のアプリアーキテクチャ
- ドメインロジックの層(domin層)
- 技術依存の層(infrastructure/exinterface層)
- 技術依存の処理を抽象化する層(infrastructure層)
- domain層とinfrastructure層の処理を統合する層(??アプリケーション層?)
などでレイヤーが分かれている
今回の自動テストの方針
「リファクタリングへの耐性」を確保するために、基本テストダブルは使わない方針。
今回は下記の2つのサイズのテストを用意する
- 単体テスト→1つの関数のふるまいに着目したテスト
最も重要かつ複雑なドメイン層に絞って単体テストを書く。テストダブルは使わない。 - 結合テスト→全レイヤー通しのテスト。FirebaseとSlack連携、現在時刻取得部分のみテストダブルを利用。絶対忘れない君のアプリ内のモジュール間ではテストダブルは使わない。
「デグレの迅速な確認」の目的だけ考えると結合テストだけで良いかなと最初は思ったが、ドメインロジックのなかに複雑な分岐・処理があったので、ここだけ単体テストを補強した。
単体テスト
単体テストを書くドメインロジックの例として、隔週水曜のなかで勉強会の予定が入ってない日付をピックアップする関数を挙げる。
※↓この機能につかっている

テストしやすいように対象の関数をリファクタリング
Before
元々この関数は引数でstartDate, endDate, intervalDaysを引数で受け取り、startDateからendDateまでintervalDays間隔でチェックしていき、勉強会の予定がない日程をreturnする関数だった。
例:startDate=2025/2/5, endDate=2025/3/10, intervalDays=14の場合、14日間隔で2025/2/5、2025/2/19、2025/3/5をチェックして勉強の予定がない日付をreturn

ただし単体テストをかく上で問題となる点としては、
関数の処理のなかで勉強会データの取得呼び出し処理や、本日日付の取得処理を書いており、この部分をテストダブルで置き換える必要が出てきてしまう。
After
lessonsやtodayDateも引数でもらう。
→テストダブルを使う必要がなくなり、リファクタリングへの耐性確保
今回のリファクタリングは言い換えると、副作用のない純粋関数にリファクタリングすることで、参照透過性を確保しテストしやすい構造にした。

今回のように副作用を伴う処理を、ドメインロジックの関数からは除外して純粋関数化し、テストを容易にするパターンをHumbleObjectパターンという(引用元:クリーンアーキテクチャ)
結合テスト
以下は勉強会当日朝9:00にリマインドする機能についての結合テスト

<処理概要>
1️⃣現在時刻(本日の日付)取得
2️⃣本日日付の勉強会データをTRaTSHOOLのDBから取得
3️⃣本日日付の勉強会データがあれば、送信するメッセージを組立
4️⃣TRaT Slackへ通知
【テストコード】
外部連携(現在時刻、Firebase上のTRaTSCHOOL DB)のみテストダブル(スタブ)で返ってくる値を固定。
それ以外はテストダブルを利用せず、入力(最初の実行)と最終結果(Slackクラスが送信しようとする内容)にのみ着目し検証。

このようなテストにすることで、実装の詳細にはせず「リファクタリングへの耐性」をもつテストとなる。すなわち内部の実装を、リファクタリングや新規機能実装時の何らかの都合で変更したとしても、最終的なふるまいがかわっていなければテストの結果はかわらないので、安心してリファクタリングや新規機能追加ができる。
カバレッジ
過去記事(単体テストの考え方/使い方)
以前の勉強会でも紹介した通り、「リファクタリングへの耐性」を優先するためにまずはブラックボックステスト(=外部からみた振る舞いのみに着目)を進めていき、結果的にカバレッジが低い(具体的にはステートメント網羅率が70~80%以下)ようであれば、ホワイトボックス的観点(内部処理の分岐に着目)からテストを追加で拡充していく。
今回カバレッジ率は下記のような結果となった。

FirebaseクラスとSlackクラス以外はかなり良好なカバレッジ率。
Firebase、Slackの単体テストだけ追加で用意する選択肢もあるが、現在テストできていないコード(※)は「デグレの確認」という目的からするとテストの効果が薄い箇所なので今回は無視。
※現状、Firebase.initialize()の処理や、Slackクラスのエラーハンドリング(エラーになったときアプリログ出力するだけ)の分岐がテストできていない。
実録:機能改修
ここで「勉強会絶対わすれないくん」に機能改修があったので、自動テストが実際にどのように役立ったかを紹介していく。
<機能改修の内容>
反応が多かった投稿を木曜朝9:00にリマインドする仕様だったが、これをリアクション総数が3を超えたタイミングでリアルタイムに通知するよう変更
実装終了後
今回のユースケース実現のための結合テスト1ケースと、新しく追加したドメインロジックの単体テスト1ケースを追加。
テストはall Greenのままであり、既存機能のデグレに関しては手動実行することなく確認完了。 最近は機能数が増えていたこともあって手動での挙動確認がめんどくさくなっていたが、自動テストにより確認がかなり楽になった肌感覚あり。

感想、学び
「リファクタリングへの耐性」を備えたことについて
前プロジェクトだとあまり「リファクタリングへの耐性」を意識せず自動テストを作っていた。
- 「リファクタリングへの耐性」がない自動テストだと少し内部の実装を変更するたびに、テストも同時にメンテナンスしなければならず、運用コストが膨らんでいる感覚があった。今回のテストは最初と最後の入出力にしか着目していないので、これがなくなった。
- 「リファクタリングへの耐性」がない自動テストだとテストが失敗したときに「外部からみたふるまいが変わってしまった」のか「内部構造がかわっただけ」なのかの判別がつかなかった。というかリファクタリングのたびにテストも直すので、せっかく自動テストつくってるのにリファクタによるデグレの発生を検知できない仕組みになっていることもあった。
しんどくない自動テスト導入のやり方
- 自動テストの網羅性をはじめから意識すると沢山単体テストを用意することになってしんどい。まずは網羅性を気にせず代表的な正常系のテスト1本かくだけでも、デグレの確認がかなり楽になる気がした。また、内部構造を無視して外部から見たふるまいにだけ着目してテストを書いても、意外とそれなりの網羅率が達成できたことも学びだった。
参考文献




