さくらコードSakura Code
小説/第6話

Novel

第6話 テストの意味

Script

Cold Open

シーン1 梅雨入りの月曜の机

 六月十三日。月曜日。

 梅雨入りした月曜のオフィスは、どこか、空気が薄かった。机の上に観葉植物の葉が一枚、落ちている。窓の外、街路樹の葉が、雨上がりの光を、いくぶん、重そうに、湛えていた。

 桜子は、まだ少しだけ気が抜けた状態で、自分の席についた。机の上には、付箋が一枚。鈴木の字だ。

桜子さんへ
先週の続きで、user.py の判定ロジックを整理しておこう。
急がなくていいです。設計の練習として。
- 鈴木

 付箋の角の丸みが、なんだか、子どもへの伝言のように見えた。

 (設計の、練習)

 その四文字が、嬉しいような、少しだけ重いような、不思議な感触で胸に残った。


Part A

シーン2 触りたくないコード

 VS Code を開いて、user.py を表示する。

 先月、桜子が境界条件バグを直したばかりのファイル。isvalidforsignup の不等号は、いま >= 18 になっている。けれど、その下にスクロールすると、桜子が触っていない判定メソッドが、いくつも並んでいた。半月前、自分のミスを最初に「ちがう」と言ってくれたテストは、いまも、tests/testuser.py の同じ場所に、静かに、ぶら下がっている。

class User:
    def __init__(self, name: str, age: int, premium: bool = False) -> None:
        self.name = name
        self.age = age
        self.premium = premium

    def is_valid_for_signup(self) -> bool:
        return self.age >= 18

    def is_eligible_for_premium(self) -> bool:
        if self.age > 19:
            if self.premium is True:
                return True
        return False

    def can_post_review(self) -> bool:
        return self.age > 18 and self.premium == True

 (また、不等号)

 桜子は、ぞわっと、肩のあたりが冷たくなった。

 iseligibleforpremium は > 19。canpostreview は > 18。それぞれの仕様がどうなのか、まだ分からない。けれど、あのときと同じ理屈の角が、そこここに、あちこちで顔を出している。

 桜子は、マウスを持ったまま、しばらく動けなかった。

 (触ったら、また、咲くんじゃないかな)

 赤い × のアイコンが、頭の隅に、ちらついた。


シーン3 翔太の速さ、桜子の手

 隣の席で、翔太は別の機能を直していた。社内SaaSのフォーム周り。彼のPRはすでに先週から走っていて、今朝、CIで赤と緑を行き来している。

 チラッと画面を見ると、エディタとブラウザを高速で切り替え、コードを直しては git push、また直しては git push、を繰り返している。

「中村くん、すごく早いね」

「うん、まあ。先にコード動かして、CIに走らせてる。テストは、最後にまとめて書く」

「最後に?」

「うん。テスト先に書くと、書くこと多すぎて遅くなる。動くって分かってから、骨を作る」

 翔太の口調は、いつものように断定的で、悪意はなかった。

 桜子は、自分の user.py に視線を戻した。翔太の言うことは、たぶん、効率としては正しいのかもしれない。けれど、いま自分の手の中にあるのは、あの日、一度赤く咲かせた User クラスだ。

 (先に動かして、CIで赤くしたら、わたし、また同じ怖さを感じる)

 桜子は、手を止めたまま、ただ、画面を眺めていた。


Part B

シーン4 テストを先に書こう

 午前中、遥が桜子の席の横を通って、紙コップを置いた。

「桜子さん、進んでる?」

「あ、山下さん。実は、まだ……、触れてなくて」

「触れない、ね。怖い?」

「はい。先月、isvalidforsignup で赤くしたばかりなので。同じファイルを触るのが」

 遥は、椅子を引いてきて、桜子の隣に腰を下ろした。

「いま、桜子さんが触りたくない理由は、健全だよ」

「健全、ですか」

「うん。この前学んだ通り、境界条件で咲く。だから、同じ理屈を持ってるかもしれないコードを、いきなり触ると、また咲くかもしれない、と思った。それは、全部、正しい直感」

「……」

「だからね、先に、テストを書こう」

「先に、ですか」

「うん。リファクタリングのときの定石。まず、現状の挙動を、全部テストに固める。直す前に。守ってから、触る」

 桜子は、「はい」と返事をしようとして、ほんのひと拍、口を、止めた。

 (テストを書いている時間で、コードのほう、直し終わるんじゃないかな)

 頭の中に、隣の翔太の背中が、ちらっと、浮かんだ。さっき覗いた、git push を繰り返していた、あの速度。テストを後ろに回す彼のやり方は、結果として、リリースが、速い。それは、事実だった。

「あの、山下さん」

「うん」

「これって、テストを揃えるところで、午前中、ぜんぶ、潰すこと、ですよね」

「うん、たぶん、そう。早瀬さんが入る前の、桜子さんひとりの午前中は、ほぼ、テストに使う日になる」

「設計の練習、って、付箋に書いてもらったんですけど。テストを書く時間が、設計の練習って、ちょっと、ピンと来ない、というか」

 桜子は、自分でも、語尾が、半分、しおれていくのが、分かった。

 遥は、桜子の声色の、しおれかけたところを、責めなかった。

「うん。最初は、たいていの人が、そう思うよ。わたしも、そうだった」

「そうなんですか」

「テストを書く時間は、書いた瞬間に、はっきり、かかる。本体を直す時間は、書いた瞬間は、ゼロのまま、進む。だから、最初の半日は、テストの分だけ、見た目には、明らかに、遅くなる」

「やっぱり」

「でもね、桜子さん。あのとき、桜子さんを、最初に『ちがう』って言ってくれたの、誰だった?」

 桜子は、息を、止めかけた。

「……テスト、です」

「うん。あれが、もし、桜子さんが事前に書いたテストだったら、桜子さんは、CIに上がる前に、自分のローカルで、自分で、気づけてた」

「……はい」

「テストを書くために使う半日は、本番に出てから二、三日かけて謝ったり、ロールバック手順を組んだりするより、ずっと安い。それが、わたしが、五年やってきていちばん、貯金が効いた時間だと思ってる」

 桜子は、自分の user.py の、知らない判定メソッドが並ぶ画面を、もう一度、見下ろした。> と >= の角が、今日も、また、ここに、いくつも、口を開いていた。

「分かりました。やります」

「うん」

 遥は、桜子のメモ帳を、ちらっと指した。

「メモ取れる?」

「取ります」

 桜子は、ペンを構えた。

「リファクタリング前にやること、三つ。

 一、いまのメソッドが、どの入力で何を返すかを、テストで書き出す。

 二、テストを全部緑にしておく。

 三、そのあとで、メソッドを書き換える。

 書き換えても、緑のままなら、外から見た振る舞いは変わってない、ってこと」

「……外から見た、振る舞い」

「うん。中身がきれいになっても、外から呼ぶ人の体験は同じ、って状態を作る。それが、リファクタリング」

 遥は、ノートPCを少し桜子側に向けて、pytest のドキュメントの一節を見せた。

@pytest.mark.parametrize("age, expected", [
    (17, False),
    (18, True),
    (19, True),
])
def test_is_valid_for_signup(age, expected):
    user = User(name="taro", age=age)
    assert user.is_valid_for_signup() is expected

「これ、parametrize。境界の数字を並べて、ひとつのテスト関数で、たくさんのケースを書ける」

「あ、これ、あのときの17/18/19 が、もっと綺麗に……」

「うん。だから、最初にこの形で、iseligibleforpremium と canpostreview の境界も並べてみよう。年齢、プレミアム、両方の境目を一覧で」

「やってみます」


シーン5 赤い護衛と、緑の通過

 桜子は、新しいテストファイル tests/testusereligibility.py を作った。

 まずは、現状のメソッドが何を返すか、頭の中で整理する。iseligibleforpremium は age > 19 and premium is True。つまり、19歳ちょうどのプレミアム会員はFalseが返るはず。canpostreview は age > 18 and premium == True。

 (この > は、本当に > なのかな……)

 桜子は、念のため、隣の翔太に声をかけた。

「中村くん、iseligibleforpremium の仕様、見たことある?」

「ああ、それ、たしか仕様書だと、二十歳以上のプレミアム会員、だった気がする」

「二十歳以上、だと、>= 20 ですね」

「いまのコードは > 19」

「同じ意味、ですね」

「だね。同じ意味。ただ、人間が読むときは、>= 20 の方が仕様の言葉に近い」

 翔太は、少しだけ、桜子の方に体ごと向き直って言った。

「先に、いまの > 19 のままの挙動をテストにしてから、>= 20 に書き直すと、たぶん、緑のままで通る」

 桜子は、うなずいて、テストを書き始めた。

import pytest
from sakura_app.user import User


class TestIsValidForSignup:
    @pytest.mark.parametrize("age, expected", [
        (17, False),
        (18, True),
        (19, True),
    ])
    def test_age_boundary(self, age: int, expected: bool) -> None:
        # Arrange
        user = User(name="taro", age=age)
        # Act
        result = user.is_valid_for_signup()
        # Assert
        assert result is expected


class TestIsEligibleForPremium:
    @pytest.mark.parametrize("age, premium, expected", [
        (19, True, False),
        (20, True, True),
        (20, False, False),
        (21, True, True),
    ])
    def test_age_and_premium_boundary(
        self, age: int, premium: bool, expected: bool
    ) -> None:
        # Arrange
        user = User(name="taro", age=age, premium=premium)
        # Act
        result = user.is_eligible_for_premium()
        # Assert
        assert result is expected


class TestCanPostReview:
    @pytest.mark.parametrize("age, premium, expected", [
        (18, True, False),
        (19, True, True),
        (19, False, False),
        (20, True, True),
    ])
    def test_age_and_premium_boundary(
        self, age: int, premium: bool, expected: bool
    ) -> None:
        # Arrange
        user = User(name="taro", age=age, premium=premium)
        # Act
        result = user.can_post_review()
        # Assert
        assert result is expected

 桜子は、書きながら、コメントの # Arrange # Act # Assert を、はじめは「これ、いるかな」と思った。けれど、いざ書いてみると、自分が「これからどの段階の処理を書いているか」を、自分自身に対してアナウンスしているような感じがして、不思議と、手の動きが、迷わなくなった。

 保存して、ターミナルを叩く。

$ poetry run pytest tests/test_user_eligibility.py
======================= 11 passed in 0.32s =======================

 全部、緑だった。

 (守れた)

 桜子は、初めて、そう思った。

 ここから、user.py を書き換えていく。iseligibleforpremium を >= 20 に。canpostreview を > 18 から >= 19 に。if self.premium is True: の冗長な書き方も、if self.premium: にまとめる。

 書き換えるたびに、テストを走らせる。

======================= 11 passed in 0.30s =======================
======================= 11 passed in 0.31s =......

 二回までは、緑だった。

 最後に、if self.premium is True: を if self.premium: にまとめた、その瞬間。一見、意味は変わらない、はずだった。

 保存して、ターミナルを叩く。

============================= test session starts =============================
collected 11 items

...
TestIsEligibleForPremium::test_age_and_premium_boundary[20-True-True]   PASSED
TestIsEligibleForPremium::test_age_and_premium_boundary[20-False-False] FAILED
...
======================= 1 failed, 10 passed in 0.32s =======================

 桜子の指先が、キーボードの上で、ひゅっと、止まった。

 (赤が、出た)

 画面の中で、testageandpremiumboundary[20-False-False] が、はっきり、赤くなっていた。20歳、プレミアムなしの場合、Falseが期待されている。

 桜子は、ターミナルを、もう一度、上から下に、たどった。

>       assert result is expected
E       assert 0 is False

 assert 0 is False。

 0 と False は、== では等価だが、is では同じオブジェクトではない。Pythonの真偽値の評価は、最後に評価された値を返す。self.age >= 20 は bool を返すが、self.premium が、もし bool ではなく int だったら、bool and int の結果は、最後に評価された int になる。

 is True で型を揃えていた一行を外したとたん、戻り値の型が、わずかに、滑っていた。

 桜子は、頬のあたりが、すうっと、冷たくなった。

 先月の、> と >= の一文字違いと、よく似た、目には見えにくい角だった。

 でも、今日は、本番では、ない。CIでも、ない。桜子のローカルの、桜子の手元で、桜子が午前中に書いたテストの、赤い一行が、桜子に向かって、咲いていた。

 桜子は、コードを、こう直した。

def is_eligible_for_premium(self) -> bool:
    return self.age >= 20 and bool(self.premium)

 保存して、もう一度、テストを、走らせる。

======================= 11 passed in 0.30s =======================

 全部、緑に戻った。

 桜子は、しばらく、ターミナルの緑の文字を、ただ、見ていた。

 (守られた)

 今度の「守られた」は、午前中、最初に書きあげたときの「守られた」とは、種類が、ちがった。

 あれは、テストが書いてあるから、安心して触れる、という、入口の安心。今度は、書いた当人の桜子が、自分の手で生んだ、目には見えにくい型のずれを、テストが、勝手に、見つけて、教えてくれた、という、出口の安心。

 赤いテストは、敵ではなかった。むしろ、あの日、CIから受けた一発の傷跡の、続きの瘢痕みたいなものだった。瘢痕は、痛みの記録ではあるけれど、同時に、皮膚を、ふたたび、外側から守る、層でもある。

 桜子は、修正したコードに、自分の手で、コメントを残した。

def is_eligible_for_premium(self) -> bool:
    """プレミアム加入可。20歳以上かつプレミアム会員"""
    # premium は bool を期待しているが、int が紛れ込みうるため bool() で包む。
    # `is True` を外したとき、戻り値が int に滑ってテストが赤くなった経験から
    return self.age >= 20 and bool(self.premium)

 あの日の、自分の名前のコミットハッシュを、桜子は、思い出した。あの日のコミットの、対になる、もうひとつのコミットが、いま、増えた。

 残りの canpostreview も、同じ手順で、bool() で包んで、書き換えた。

 すべて、緑だった。一度も、赤を、見過ごさなかった。

 最後に、整理し終えたメソッドを、桜子は声に出さずに読み直した。

def is_valid_for_signup(self) -> bool:
    """サインアップ可。日本の成人年齢18歳以上を有効とする"""
    return self.age >= 18

def is_eligible_for_premium(self) -> bool:
    """プレミアム加入可。20歳以上かつプレミアム会員"""
    return self.age >= 20 and self.premium

def can_post_review(self) -> bool:
    """レビュー投稿可。19歳以上かつプレミアム会員"""
    return self.age >= 19 and self.premium

 画面の中の User クラスは、あの朝に見たものとは、別の表情をしていた。


Part C

シーン6 安心して触れる感覚

 午後、桜子はリファクタリングPRを #73 として上げた。タイトルは、

refactor: clarify age/premium boundaries in User and lock with parametrize tests

 遥が、コメントを残す。

@yamashita-haruka: テストを先に固めてからリファクタしたの、いいね。
これがあると、桜子さんは、これから何度でも、安全にこのファイルを触れる。
品質って、こうやって、過去から未来に向けて貯金していくもの

 桜子は、その「貯金」という言葉を、しばらく見ていた。

 画面の少し下に、別の通知が来た。翔太のPRからだった。彼はもう自分の機能を一段落させていて、別のPRを上げようとしている。レビュワー欄に、桜子の名前が指名されていた。

@nakamura-shota: 望月さん、今日のリファクタの parametrize、
僕の方の `can_subscribe_newsletter` のテストにも使いたい。
レビューで、構造の真似していいか、教えてほしい

 桜子は、しばらく、画面を見つめていた。

 (中村くんが、わたしのコードを、参考にしたいって)

 胸の中で、ざわっと、ちいさな何かが咲いた。

 返信の下書きを、メモ帳に一度書いてから、丁寧に打った。

@mochizuki-sakurako: もちろん使ってください。
ただ、AAA(Arrange/Act/Assert)のコメントは、
もし邪魔なら外しても大丈夫です。
わたしは書きながら自分のためにアナウンスしてる感じで使ってます。

 翔太の返事は、すぐに来た。

@nakamura-shota: コメントも残します。読みやすいので

Ending

シーン7 帰り道、緑の余韻

 十九時すぎ。

 桜子は、帰宅の電車に揺られていた。窓の外、夜の街路樹は、すっかり夏に近い濃い緑をしていた。

 車内の蛍光灯の光に、自分のスマホの画面が反射して、頭の中では、今日のCIの結果画面が、まだ緑色で残っていた。

 桜子は、メモ帳を膝の上で開いた。

2022/06/13 新人日記

- リファクタの定石: 先にテストで現状を固める
- pytest.mark.parametrize で境界値を一覧にする
- AAA構造: Arrange / Act / Assert
- 中身を変えても、外から見た振る舞いは同じ、を作る
- テストは、未来の自分の保険。品質は、過去から未来への貯金
- 朝に「テスト書く時間で本体直したほうが速いんじゃ」と思った
  → 午後、自分の `is True` 外しのコミットで、自分が書いたテストに守られた
- 入口の安心 (テストが書いてあるから触れる) と
  出口の安心 (テストが、自分の見落としを見つけてくれる) は、別もの
- 赤いテストは、敵じゃない。傷跡の続きの、瘢痕

 ペンを止めて、桜子は窓の外を見た。

 (中村くんが、わたしの構造を、使いたいって言った)

 その事実が、胸の奥で、まだあたたかい。

 翔太の速さに、追いつけたわけじゃない。たぶん、追いつけなさは、これからもっと、強くなる。それでも、今日、自分の手の中で起きたのは、テストが先に立って、コードが安全に変わっていく、という、新しい感触だった。

 桜子は、メモの最後に、もう一行だけ書いた。

- 触る前に、守る

 電車が駅に着く。ドアが開く。葉桜のとっくに散った街路樹の濃い緑が、ホームの蛍光灯の下で、深く、静かに、揺れていた。