コンテンツへスキップ

ユニットテストとリグレッションテストの違い — なぜ同じ機能に何度もテストを書くのか

保守ツールのテストスイートを見ていると、ひとつの疑問にぶつかることがあります。すでにその関数のテストがあるのに、なぜ別のファイルにもう一本テストを追加するのか。同じ機能を検証しているように見えて、実は目的がまったく違う2種類のテストが、同じスイートの中に混在しているケースが少なくありません。

本稿では、「ユニットテスト」と「リグレッションテスト」という2つの言葉が指す範囲の違いを、実際のテストコードを題材に整理します。

ユニットテスト — 関数を単独で検証する

補足: ユニットテスト(unit test)とは、プログラムを構成する最小単位(関数・クラス・メソッドなど)を、他の部分から切り離して検証するテストのこと。「単体テスト」とも呼ばれる。

ユニットテストの役割は単純です。ある関数に、ありうる入力パターンをひととおり与えて、期待どおりの出力が返るかを確認する。それだけです。実装したその場で、あるいは実装した直後に書くのが自然な流れになります。

たとえば、SSH秘密鍵のパーミッション(アクセス権限)を診断・修正するヘルパー関数群のテストは、次のような構成になっています。

class TestIsPermissionError(unittest.TestCase):
    """事後リカバリ用: エラー文言にパーミッション関連キーワードがあるか"""

    def test_openssh_too_open(self):
        msg = "Permissions 0644 for '/Users/x/.ssh/id_rsa' are too open."
        self.assertTrue(key_perms.is_permission_error(msg))

    def test_windows_openssh(self):
        msg = "Permissions on the private key file 'C:\\Users\\x\\.ssh\\key' are too open."
        self.assertTrue(key_perms.is_permission_error(msg))

    def test_case_insensitive(self):
        self.assertTrue(key_perms.is_permission_error("ARE TOO OPEN"))

is_permission_error() という1つの関数に対して、OpenSSHの典型的なエラー文言・Windows版の文言・大文字小文字の揺れなど、ありうる入力パターンを1件ずつ列挙して確認しています。POSIX(Mac/Linux)側は実際に chmod を実行して検証し、CIで実行できないWindowsの icacls はモック(偽の代役オブジェクト)で代用する、という書き分けも入っています。

このテストが答えようとしている問いは「この関数は、設計どおりに動いているか」です。関数の仕様を確認する行為であり、対象の関数さえ存在すれば、いつでも独立して書けます。

リグレッションテスト — 一度起きた事故を封じ込める

補足: リグレッション(regression)とは「後退・逆戻り」の意味。リグレッションテストは、過去に発生した不具合が将来のコード変更で再発していないかを確認するテストのこと。「回帰テスト」とも呼ばれる。

一方でリグレッションテストは、性質がまったく異なります。書かれるきっかけは「仕様の確認」ではなく「実際に一度、本番相当の環境で起きた事故」です。

v1.6.11の開発中に、実際に踏んだ事故を例に見てみます。ビルド前の版番号整合性チェックスクリプト(tools/bump_version.py)は、成功メッセージに絵文字(✅など)を出力していました。Macのコンソールは既定でUTF-8のため何の問題もなく動いていたのですが、Windows(日本語ロケール)でこのスクリプトをサブプロセスとしてパイプ経由で呼び出すと、Pythonは既定でロケール由来のエンコーディング(cp932)を使おうとし、絵文字をcp932に変換できずに UnicodeEncodeError でクラッシュしていました。しかもそのクラッシュを呼び出し元(build_app.py)が「版番号不一致」と誤表示してしまい、原因の特定を余計に難しくしていました。

この事故を受けて書かれたのが tests/test_windows_cp932_safety.py です。中身は大きく2段構えになっています。

1段目(静的検証): ビルド/リリース時にサブプロセスとして呼ばれるスクリプトのソースコードを1文字ずつ走査し、cp932にエンコードできない文字が混入していないかを確認する。

CP932_CRITICAL_SCRIPTS = [
    "tools/bump_version.py",
]

def test_critical_scripts_are_fully_cp932_encodable(self):
    for rel in CP932_CRITICAL_SCRIPTS:
        with open(os.path.join(ROOT, rel), encoding="utf-8") as f:
            text = f.read()
        for ch in text:
            try:
                ch.encode("cp932")
            except UnicodeEncodeError:
                # 失敗を記録して self.fail()
                ...

2段目(挙動検証): 実際に環境変数 PYTHONIOENCODING=cp932 を設定した子プロセスで対象スクリプトを実行し、UnicodeEncodeError を吐かずに正常終了(returncode 0)することを確認する。静的な文字チェックだけでは「実行時に本当にクラッシュしないか」までは保証できないため、挙動そのものも別レイヤーで確認しています。

このテストが答えようとしている問いは「この関数は正しいか」ではなく「あの事故は、もう二度と起きていないか」です。対象の関数自体は事故発生時にすでに修正済みであり、修正が正しいこと自体は挙動検証で確認できています。それでもこのテストがスイートに残り続けるのは、将来誰かがこのスクリプトに手を加えたときに、絵文字を含む出力メッセージをうっかり再び書いてしまう可能性を封じるためです。

なぜ「関数は正しい」のに事故は起きたのか

ここで整理しておきたいのは、bump_version.py の絵文字出力は、それ単体を見ればロジックとして間違っていたわけではないという点です。「成功したら✅を表示する」という処理自体は、意図どおりに動いていました。問題は、その出力がどの環境の、どういう経路で読まれるかという前提のほうにありました。

ユニットテストは基本的に「この関数に、こういう入力を与えたら、こう返る」という関数の契約を検証します。関数を単独で見ている限り、絵文字の出力それ自体はテストを通過してしまいます。cp932という特定のロケール・パイプ経由の出力・サブプロセスからの呼び出し、という3つの条件が重なって初めて表面化する種類の不具合は、関数単体のロジックを網羅的にテストしていても発見できません。

だからこそ、実際に環境をまたいで起きた事故を、そのまま自動テストとして固定化する必要が出てきます。これがリグレッションテストの存在理由です。

テストファイル名の付け方にも意図が表れている

このリポジトリのテストスイートには、test_stability_v47.pytest_db_backup_v48.py のように、開発ラウンド番号(社内での作業回次を指す通し番号)をファイル名に含めたテストが複数あります。たとえば test_db_backup_v48.py の冒頭コメントには、次のような記述があります。

#299(2026-04-24 修正): os.path.join(local_site_backup_dir, backup_file) の
バグ(backup_file がリモート絶対パスになったため第2引数が絶対扱いされ
第1引数が丸ごと捨てられていた)の回帰防止。

os.path.join() は、第2引数以降に絶対パスが渡されると、それより前のすべての引数を無視して絶対パスだけを結果として返すという仕様があります。この挙動自体はPythonの正式な仕様であり、バグではありません。しかし呼び出し側が「渡す文字列は常に相対パスのファイル名」だと思い込んでいたために、実際にはリモート側の絶対パスが渡ってしまい、意図したローカル保存先ディレクトリが丸ごと捨てられていた、というのが実際の事故内容です。

この種の不具合番号(#299)や開発ラウンド番号がファイル名やコメントに残っているテストは、一般的な機能仕様の網羅ではなく、特定のインシデントの再発防止を目的に書かれた合図だと読み取れます。逆に test_key_perms.py のように、対象モジュール名だけを冠したテストは、機能そのものの仕様確認を目的としたユニットテストである、という書き分けです。

T12(ビジュアルリグレッションテスト)との接続

以前の記事で紹介した ビジュアルリグレッションテストとは何か — スクリーンショット差分で崩れを検知する仕組み も、実はこのリグレッションテストという考え方の一種です。あちらは「レイアウトが崩れていないか」を画面のピクセル比較という形で固定化したものであり、本稿で扱ったcp932の例は「特定のロジックが特定の環境で壊れていないか」をコードレベルの実行結果として固定化したものです。検証手段(画像比較 vs コード実行)は異なりますが、「一度発生した不具合の状態を、繰り返し確認できる形に変換して残す」という設計思想は共通しています。

まとめ

ユニットテストは、関数やモジュールが仕様どおりに動くことを、実装と同じタイミングで確認するためのものです。リグレッションテストは、実際に一度発生した不具合を、将来のコード変更で二度と再発させないための封じ込めとして、事故が起きたあとに書かれます。どちらも「テスト」という同じ形式を取りますが、生まれる瞬間もその後の役割も別物です。同じ機能に複数のテストファイルが存在しているとき、そこには「仕様の確認」と「過去の事故の記録」という、異なる2つの意図が重なっていることが多いという見方をしておくと、テストスイート全体の構造が読み解きやすくなります。