Script
Cold Open
シーン1 朝のSlack通知
五月の終わりの朝、オフィスに着いた桜子は、すぐに様子の違いに気づいた。
社内チャットの開発チャンネルに、いつもは流れていない、赤い色のメッセージが立て続けに並んでいる。
[GitHub Actions] CI failed on main
test_user_age_is_valid - FAILED
build #482桜子はリュックを下ろしながら、画面を覗き込んだ。
(main、って書いてある)
昨日、桜子の二件目のプルリクエストが、ようやくmainに入ったばかりだった。タイトルは、fix: tighten age validation in user form。先輩の鈴木が、「桜子さんも次はちょっとだけロジック触ってみよう」と渡してくれた、初めての“一行以上”の修正だった。
席に座る前に、もう一度、画面の赤い文字を見る。
胃のあたりが、少し冷たくなった。
Part A
シーン2 赤い失敗ログ
PCを起動して、最初に開いたのはGitHub Actionsの該当ビルドだった。
ジョブの内訳が並ぶ。lint は緑、format-check も緑。pytest だけ、赤い × のアイコン。
クリックして、ログを開く。入社初日の環境構築で遥に言われたとおり、エラーの一番下だけを見ない、と心に決めながら、上から順に読んでいく。
============================= test session starts =============================
collected 47 items
tests/test_user.py::test_user_age_is_valid_18 FAILED [ 6%]
tests/test_user.py::test_user_age_is_valid_19 PASSED [ 10%]
...
================================= FAILURES ===================================
______________________ test_user_age_is_valid_18 ______________________________
def test_user_age_is_valid_18():
user = User(name="taro", age=18)
> assert user.is_valid_for_signup() is True
E AssertionError: assert False is True
tests/test_user.py:24: AssertionError(age=18 で落ちてる)
桜子は、もう一度、自分が触ったファイルを開いた。user.py。
def is_valid_for_signup(self) -> bool:
return self.age > 18その行を、桜子は昨日、こう書いた。前のコードでは >= 18 だったところを、ちゃんと「未成年は登録不可」にするつもりで、不等号をひとつだけ変えた。
(変えた覚えは、ある)
でも、いま見ると、それは「18歳ちょうどの人」を、登録できなくしている。
桜子の指は、キーボードの上で、止まった。
シーン3 git blameを消したくなる瞬間
ターミナルを開いて、桜子は手が震えそうになるのを、息を吐いて押さえながら打ち込んだ。
$ git blame src/sakura_app/user.py | headisvalidforsignup の行に、自分のメールアドレスが並んだ。コミットハッシュは、昨日の桜子のもの。
(わたしの)
桜子の頭の中に、ほんの一瞬だけ、よくない考えがいくつか浮かんだ。
別ブランチで急いで戻して、こっそりpushすれば、誰も気づかないかもしれない。git push --force という単語を、どこかで読んだ気がする。あるいは、自分のせいじゃなく、別の変更と組み合わさった偶然のせいだ、と説明する道もあるかもしれない。
桜子は、画面のターミナルに、何も打たないまま、深く息を吸った。
(だめ。それは、だめだ)
入社初日の、駅までの坂道。初めてのプルリクで受けた十四件のコメント。今朝の、赤い × のアイコン。それらが、頭の中で順番に並んでいく。
桜色のキーホルダーが、机の端の鞄の取っ手で、わずかに揺れている。
桜子は、ターミナルから手を離した。
Part B
シーン4 隠すほうが遅い
席の少し離れたところで、遥が、別のレビューを開いていた。気づいた遥が、紙コップ片手に、桜子の方へ歩いてきた。
「桜子さん、CI赤いね。原因、教えて」
声は、いつもどおり静かだった。怒っている感じも、責めている感じも、ない。
桜子は、口を開きかけて、一度、閉じた。それから、自分のディスプレイに視線を戻して、小さく言った。
「……わたしの、変更です。age > 18 にしてしまって。本当は >= 18 のままが、要件として合ってました」
言葉にした瞬間、自分の声が震えていることに気づいた。
遥は、椅子を引いてきて、桜子の隣に座った。
「言いにくかったね」
「はい」
「ほんの少しだけ、消したくなったでしょ」
桜子は、少しの間、黙ってから、うなずいた。
「ありますよね、あれ。force push とか、書き換えとか。考えるのはいい。考えるところまでで、止めたなら、十分」
「……すみません」
「謝らない。直そう」
遥はモニターの方に身体を寄せて、画面の赤い箇所を指した。
「ひとつだけ言うね。隠すほうが、遅い」
「遅い、ですか」
「うん。隠すと、原因の特定に、隠した分だけ時間がかかる。直しても、再発する。直したように見えても、別の場所で同じ理屈の不具合が出る」
「……はい」
「だから、ここからは、隠さない。原因を見て、影響範囲を見て、テンプレに沿って報告書を書く。修正PRを出す。レビューを受ける。マージする。これだけ」
遥の指は、画面の assert False is True の上で、一度だけ止まった。
「赤いやつは、ちゃんと、桜子さんに向かって咲いてる」
シーン5 境界条件という花
遥と一緒に、テストファイルを開く。
def test_user_age_is_valid_18():
user = User(name="taro", age=18)
assert user.is_valid_for_signup() is True
def test_user_age_is_valid_17():
user = User(name="taro", age=17)
assert user.is_valid_for_signup() is False
def test_user_age_is_valid_19():
user = User(name="taro", age=19)
assert user.is_valid_for_signup() is True「桜子さん、テストの並び、見て。なに気づく?」
「えっと、十七、十八、十九、と、年齢が連続してます」
「うん。なぜ十八が単独であるのか」
「……境目だから、ですか?」
「正解。十八は、登録できる側に入る、っていう仕様。日本の成人年齢が引き下げられたからね、二〇二二年は」
遥は、桜子のメモ帳の方を、ちらっと見た。
「こういうのを、境界条件って呼ぶ。仕様の角。十八と十七、十八と十九、ゼロと一、九十九と百。何かが切り替わるところ。バグはだいたい、ここに咲く」
「咲く、ですね」
「うん、咲く。赤く」
桜子は、自分の修正案を、声に出さずに頭の中で並べた。
def is_valid_for_signup(self) -> bool:
return self.age >= 18「直すのは、これでいいですか」
「うん。それで pytest 走らせて、全部緑になるか、見よう」
桜子はターミナルに戻り、pytest を打った。
======================= 47 passed in 0.91s =======================全部、緑だった。
「……通りました」
「うん。じゃ、次は、報告書」
Part C
シーン6 報告テンプレート
ちょうどそのとき、鈴木が、コーヒーカップ片手に通りかかった。
「桜子さん、CI、戻った?」
「あ、はい、いま、ローカルでは緑になりました」
「お、よかった。じゃ、ついでに、障害報告、書いてみよう」
「障害、報告……」
桜子は、思わず復唱した。鈴木は、うん、と軽くうなずいて、社内wikiのページを開いて、桜子の画面に共有した。
## 障害報告テンプレート
- タイトル
- 発生日時 / 検知日時
- 影響範囲
- 事象
- 一次原因
- 暫定対応
- 恒久対応
- 再発防止
- 学び「めちゃくちゃ大きい障害も、めちゃくちゃ小さい障害も、書く形式は同じね。今回は、本番には出てない、CI止まり。だから、影響範囲は『開発環境のみ』って書いていい」
「はい」
「今日のは、まあ、新人にとっては、いい教材になる規模。書いてみて。書いたら、開発チャンネルに貼って、終わり」
鈴木はコーヒーを一口飲んで、自分の席へ戻っていった。やわらかな声色のまま。それが、かえって、桜子の背中をまっすぐにさせた。
桜子は、テンプレートを自分のローカルにコピーして、空欄を、ひとつずつ埋めていった。
## 障害報告: User.is_valid_for_signup の境界条件不具合
- 発生日時: 2022/05/24 mainマージ時
- 検知日時: 2022/05/25 09:05 (CI失敗による)
- 影響範囲: 開発環境のみ。本番未反映。ユーザー影響なし
- 事象: pytest の test_user_age_is_valid_18 が FAILED
- 一次原因: 仕様(18歳は登録可)に対し、`self.age > 18` と書いてしまった。
正しくは `self.age >= 18`
- 暫定対応: 該当行を `>= 18` に戻すPRを作成 (#48)
- 恒久対応: 同PRに、年齢の境界値テスト (17/18/19) のコメントを追記。
test_user.py には既にあるが、user.py 側にも仕様の意図をコメントする
- 再発防止:
- 不等号を変更するときは、境界値ケースのテストを書いて(または見て)から触る
- 仕様(成人年齢)の根拠は user.py の docstring に記載する
- 学び:
- 境界条件の角でバグは咲く
- エラーは隠すより共有したほうが早い書き終えて、桜子は、画面を一度、上から下までゆっくり読み直した。
書いてある事実は、自分が起こしたミスのことなのに、文章として並んでみると、不思議と、自分のことを少し外から見られている感じがした。
遥の方を見る。遥は、桜子の画面を覗いて、ひとつだけうなずいた。
「学びの最後、いいね。あとで、桜子さんの席のディスプレイに貼っておきたい」
「……それは、ちょっと、恥ずかしいです」
「冗談。チャンネルに、貼ろう」
桜子は、開発チャンネルに、テンプレートをそのまま投稿した。スレッドに、鈴木からは絵文字、遥からは「ありがとう、読みました」のひとことが返ってきた。
翔太は、しばらくしてから、こう書いた。
@nakamura-shota: 17/18/19 のテスト並び、いいですね。
僕の最近のPRも、不等号触るときは、これで書こうと思います桜子は、画面を見ながら、目元が少しだけ熱くなった。
Ending
シーン7 夜の窓と、赤から緑へ
二十時前。
桜子の修正PR #48 は、遥の Approved を経て、mainにマージされた。GitHub Actions のページで、ジョブの一覧が、上から下まで、緑色で並んだ。
桜子は、その画面を、しばらく眺めていた。
今朝、同じ場所が赤かったこと。assert False is True の文字列。git blame に並んだ自分のメールアドレス。隠したくなった一秒。遥の「隠すほうが遅い」。鈴木のテンプレート。翔太のスレッドの返信。
全部、まとめて、緑色のチェックマークの裏側に、入っていく。
桜子は、ふと、tests/testuser.py のページに戻った。
今朝、自分のミスを、最初に「ちがう」と言ってくれたのは、人ではなく、testuserageisvalid18 という、九行ほどの、誰かが書いた、地味な関数だった。書いた人の名前は、git blame を辿らないと、桜子には、まだ、分からない。
(あの赤、なかったら、本番に、出てた)
その想像は、git push --force を一瞬だけ考えた朝の自分よりも、ずっと、こわかった。
(テストは、わたしを、責めるためにあるんじゃなくて、たぶん、わたしを、守るためにある)
書きおろしの実感ではなく、書かれていたものに、守られた、という実感だった。
窓の外では、すっかり葉桜になった街路樹が、少し強い初夏の風に揺れている。
桜子は、机の引き出しから、メモ帳を取り出した。
2022/05/25 新人日記
- エラーは赤く咲く。境界の角に咲く
- 隠すほうが、遅い
- 障害報告テンプレート(影響範囲 / 暫定対応 / 恒久対応 / 再発防止 / 学び)
- 17 / 18 / 19 を並べて見る
- 赤いテストは、わたしを責めるためじゃなくて、守るためにあったペンを置いて、桜子は、もう一行だけ、書き足した。
- わたしのコミットは、わたしの、もの
- 次に user.py を触るときは、自分でも、境界のテストを、先に書こう画面の中の、緑のチェックマークが、夜の窓に映っている自分の顔と重なった。
明日も、たぶん、どこかで赤く咲く。
でも、咲いたら、見にいこう。前後を、読みにいこう。隠さず、書いて、貼ろう。
桜子は、メモ帳を閉じて、退勤の準備を始めた。