前置き
以下の文章は、その昔自分が陥った「テストコード書いたことないけど書くことになったスプリント」という状況について、当時の愚痴を文章化したものを中心にChatGPTの力を借りつつ前向きな文章に仕立てたものです。
初めてのエンジニアでもないのにテストコードを書いたことない」があり得る
アプリケーション開発において、自分のスキルセットと開発内容の難易度が必ず一致しているとは限らない。使用する言語やフレームワークへの知識の度合い次第では機能を最低限実装するだけで時間を使い切ってしまうかもしれない程度にはストレッチなことかもしれない。
自分もご多分に漏れずその経験をした一人である。そんなとき、目の前の実装にアップアップな身としては、打鍵による動作確認がある程度できていればテストコードなどという冗長なものをわざわざ作っている場合ではないのだ。
そんな状態でPMやSMから「次のスプリントからはテストコードをちゃんと書こう!」と言われても現実的とは思えない。そんな簡単にできるもの?と思ってサンプルコードを見てみても、やったことない身としてはよくわからん概念が頻出する。スタブ?tear down?アサーション?DI?みたいな。
テスト、導入しよう
とはいえこんな状態でもテストコードは導入したほうがいい。あるのとないのとでは追加開発やリファクタ時の品質保証具合が段違いなので、最終的に自分たちを楽にしてくれるものには違いがない。ではどのように導入していくのが現実的か?
テストコード記述を中心としたリファクタ期間を調達する
テストコード初心者が多いにも関わらずテストを組まなければならなそうな場合、もうまとまった期間を顧客と調整していただいてしまうのが一番である。
顧客目線からすると機能が増えないから価値でなくない?なのだが、彼らは開発中のシステムが障害を起こさないこと前提で語っているケースが多い。その前提を確固たるものにするためにも、テストがない場合はそれを用意する期間が必要なのだ。
「今までは立ち上がりの速度重視で開発してきたが、機能が充足してきた今こそ品質を向上させる期間を設け、のちの開発体験や保守性をより高度なものにしていきたい」みたいな理由を中心に説得を試みよう。タイミングが合えば1週間くらいもらえるかもしれない。
書きやすいテストから書く
もう初めてテストコード書きます、みたいなときは、小さな関数、それもDBやオブジェクトストレージなどの外部サーバと接続しない関数のテストコードだと、一番考えることが少ないので入門しやすい
テストを書きやすいコードにリファクタする
まとまった期間をいただいた場合、もうこのレベルでリファクタしたほうがいいと思う。以下の順番でリファクタしていくのがよさそう
テスト可能な環境を整える
まず、既存コードの挙動を守るために 回帰テスト を導入。
目的: 既存の動作を保証しながらリファクタリングを進める。
手順:
- 既存のコードの振る舞いを可能な限りキャプチャする簡易なテストを作成(スナップショットやブラックボックステストなど)
- テストフレームワークを導入(例: Pythonならunittestやpytest)
- 外部システムに依存する部分をモック化
単一責任に分割する
次に、関数やクラスを単一責任に分解。
目的: 複雑な関数やクラスを小さな単位に分割し、各単位をテスト可能にする。
手順:
- 大きな関数を小さなヘルパー関数に分割
- 各ヘルパー関数に対するテストを追加
- 複雑なクラスの場合は、1つのクラスの責務を複数の小さなクラスに分ける
例:
# Before
def process_user_data(data):
# データバリデーション
if not validate(data):
raise ValueError("Invalid data")
# データ変換
processed = transform(data)
# データ保存
save(processed)
# After
def validate(data):
pass
def transform(data):
pass
def save(data):
pass
def process_user_data(data):
if not validate(data):
raise ValueError("Invalid data")
processed = transform(data)
save(processed)
副作用の分離
グローバル変数や外部システムに依存する部分を取り除き、関数の純粋性を高める。
目的: 外部依存を切り離すことで、関数のテストを容易にする。
手順:
- 外部リソースへのアクセス(ファイル操作、DB接続、API呼び出しなど)をラップする関数を作成
- 外部アクセス部分をモック可能にする
例:
# Before
def fetch_data():
# データベースに直接アクセス
return db.query("SELECT * FROM users")
# After
def fetch_data(db_client):
return db_client.query("SELECT * FROM users")
依存性注入 (Dependency Injection) を導入
外部システムやモジュールへの依存を引数として注入する形に変更。
目的: モック可能な構造を作り、単体テストを容易にする。
手順:
- 関数やクラスに外部依存を注入可能にする
- テストではモックを渡してテスト
例:
# Before
class UserService:
def __init__(self):
self.db = Database()
# After
class UserService:
def __init__(self, db_client):
self.db = db_client
状態を引数として明示的に受け渡す
関数やメソッドがクラスやグローバル状態に依存している場合、それらを引数に渡す形に変更。
目的: 関数の入力と出力を明確化
手順:
- クラス内部やグローバル変数の状態を削除し、必要なデータを引数として受け渡す
- 出力も戻り値として明示的に返す
エラーハンドリングを整理
エラー処理を明確にして、異常系のテストをしやすくする。
目的: 予測可能なエラーを明示し、エラー処理コードのテストを可能にする
手順:
- エラーハンドリングを一貫した形で実装
- カスタム例外を導入して、特定のエラー条件を明確化
動作確認、そしてCI/CD中自動テストへ
E2Eテストレベルまで作ろうとなるとコスパが悪い。それをやるくらいならCI/CDを導入してその中に自動テストを組み込むほうがいい。具体的な方法はここでは割愛する
終わりに
今の時代、Copilotなどを駆使することでテストコードは容易に作成できる。面倒なMockデータの作成も簡単になったほうである。
もうすこしきっちりとテストについて知りたければ「テスト書いたことないと目の前で言ってはいけないあの人」および彼の著書について調べていこう。