はじめに
ExcelのWBSを進捗管理ツールに置き換えた話の4本目です。
- ExcelのWBSを捨てずに、AWSサーバーレスの進捗管理ツールへ置き換えた
- Excelとの往復を壊さない取込の作り方(下見・全件主義・列の持ち方)
- 同じAWSアカウントに似た名前のスタックが同居しているときのデプロイの守り方
- テストが通っても安心できないので、実装をわざと壊して確かめる仕組みを作った(この記事)
- 作った進捗管理ツールに入力が集まらなかったので、入れてもらうための機能を足した
テストが全部通っても、そのテストが本当に何かを確かめているとは限りません。この記事では、実装をわざと壊して「狙ったテストが落ちるか」を確かめる仕組みと、それを作る中で見つけた、通っているのに何も確かめていなかったテストの話を書きます。
きっかけ:3週間、黙って落ちていた
毎週月曜に進捗を控える Lambda が、3週続けて失敗していました。
Float types are not supported. Use Decimal types instead.
進捗率を round() で丸めた値を、そのまま DynamoDB に書いていました。round() は float を返し、boto3 は float を受け取りません。
3週間気づかなかった理由は2つありました。
| 理由 | いまの対策 |
|---|---|
| 失敗を知らせる口が無かった | CloudWatch アラームを Teams に流す Lambda を足した |
| テストが無かった | Lambda のテストを足した(いまは404件) |
アラームは「落ちた」と「動いていない」の両方を見ています。スケジュールのルール自体が止まると、Lambda は呼ばれないので、エラーの数には何も出ません。
float の問題は、1か所直すのではなく、書き込む直前に必ず通す関数を置きました。
def dynamo_safe(value):
if isinstance(value, float):
# float から直接 Decimal を作ると誤差がそのまま入る。str を経由する
return Decimal(str(value))
if isinstance(value, dict):
# …(入れ子も同じように変換する)
偽のテーブルは本物と同じように落とす
Lambda のテストは、AWS に出ずに動くように、DynamoDB の偽物(偽テーブル)を使っています。
偽テーブルを素直に作ると、渡されたものを何でも受け取ります。これだと、float を書いてもテストは通ります。本番で3週間落ちていたのと同じ書き方が、テストでは通ってしまいます。
そこで、本物と同じところで落ちるようにしました。
@staticmethod
def _reject_float(value, path="Item"):
if isinstance(value, float):
raise TypeError(
"Float types are not supported. Use Decimal types instead."
)
if isinstance(value, dict):
for k, v in value.items():
FakeTable._reject_float(v, f"{path}.{k}")
elif isinstance(value, list):
for i, v in enumerate(value):
FakeTable._reject_float(v, f"{path}[{i}]")
def put_item(self, Item):
self._reject_float(Item)
# …
ほかにも本物に合わせたところがあります。
| 合わせたところ | 合わせないと |
|---|---|
float を渡されたら TypeError
|
テストが通るのに本番で落ちる |
query は SK の昇順。ScanIndexForward=False で降順 |
「新しい順」のテストが、たまたまの並びで通る |
| 条件付き更新は、外れたら例外 | 同時編集を止めるテストが何も確かめていない |
数値は Decimal で返す |
「変更あり」の判定が全行で狂っても気づけない |
実装をわざと壊して、テストが落ちるかを見る
テストを足していくと、次に気になるのは「このテストは本当にこの不具合を捕まえるのか」です。
そこで、実装を1か所ずつ壊した写しを作り、狙ったテストが落ちるかを確かめるスクリプトを書きました。考え方はミューテーションテストと同じですが、壊し方は自分で選んで、sed で書き換えています。
落ちるか "カスケード削除が130を巻き込む" \
"lambda/api/handler.py" \
's| prefix = code + "."| prefix = code|' \
"130は13の配下ではない"
WBS 番号 13 のタスクを消すと、配下の 13.1 や 13.2 も一緒に消します。配下を探すときの接頭辞から . を落とすと、130 まで巻き込みます。その壊し方をしたときに、130は13の配下ではない というテストが落ちるかを見ています。
引数は「名前」「壊すファイル」「壊し方(sed)」「落ちてほしいテスト」の4つです。中身は次のようになっています。
落ちるか() {
local name="$1" target="$2" tamper="$3" want="$4"
rm -rf "$T/w"; mkdir -p "$T/w"
cp -r lambda tests pytest.ini "$T/w/"
( cd "$T/w" && sed -i "$tamper" "$target" )
# 書き換えられたか確かめる。実装が変わって sed が当たらなくなると、
# 壊していないのに「素通りした」と報告してしまう(実際そうなった)
if cmp -s "lambda/${target#lambda/}" "$T/w/lambda/${target#lambda/}"; then
printf ' NG %s → 壊せませんでした(sedが当たっていない)\n' "$name"
FAIL=$((FAIL+1)); return
fi
local out rc
out=$(cd "$T/w" && PYTHONDONTWRITEBYTECODE=1 $PY -m pytest -p no:cacheprovider 2>&1); rc=$?
if [ "$rc" = "0" ]; then
printf ' NG %s → 素通りしました(落ちるはず)\n' "$name"
FAIL=$((FAIL+1)); return
fi
if grep -q "$want" <<<"$out"; then
printf ' OK %s → 捕まえた\n' "$name"
PASS=$((PASS+1))
else
printf ' NG %s → 落ちたが別のテスト\n' "$name"
FAIL=$((FAIL+1))
fi
}
判定は3通りです。
| 結果 | 意味 |
|---|---|
| 捕まえた | 狙ったテストが落ちた |
| 素通りした | 壊したのに全部通った。テストが足りない |
| 落ちたが別のテスト | 落ちはしたが、狙ったテストではない。偶然落ちているだけかもしれない |
「落ちたが別のテスト」を分けているのは、別の理由で落ちたものを「捕まえた」と数えないためです。
いまは48項目を壊しています。一部を挙げます。
DynamoDBにfloatを書く / エラーがあっても書き込む / 競合を見逃す
カスケード削除が130を巻き込む / 楽観ロックを外す / 権限の検査を外す
取込中でも書かせる / 落ちた取込に閉じ込める / 下見なのに書き込む
アーカイブした案件にも週次サマリを送る / 案件を引けないと通知が消える
工数をintに丸めて比べる / ファイルに無い列も空で上書きする
人のコメントも消せる / 基準線を誰でも引ける
テストを足したら、壊す項目も1つ足すことにしています。
壊したつもりで、壊していなかった
最初の版には、sed が当たったかを確かめる処理がありませんでした。
実装を直して、sed で探している行が変わると、sed は何も書き換えずに終わります。壊していない写しでテストを流すので、全部通ります。それを「素通りしました」と報告していました。逆に、素通りを捕まえたつもりで、実は何も壊していない、ということも起こりえます。
いまは、元のファイルと写しを cmp で比べ、同じなら「壊せませんでした」と出して失敗にしています。実装を直したら、壊す側の sed も直すことになります。
判定が裏返る:printf | grep -q と pipefail
もう1つ、手元では通るのに GitHub Actions でだけ落ちる、ということがありました。最初は、テストの結果を次のように判定していました。
set -o pipefail
if printf '%s' "$out" | grep -q "$want"; then
grep -q は、一致を見つけた時点で読むのをやめ、パイプを閉じます。printf がまだ書いている途中だと、閉じたパイプに書こうとして失敗します(SIGPIPE)。pipefail はこの失敗を拾うので、パイプ全体が失敗になります。
その結果、一致しているのに「一致しなかった」と判定されます。出力が大きく、一致が先頭のほうにあるほど起きやすくなります。手元とCIで出力の量やタイミングが違ったため、CIでだけ起きていました。
いまはパイプを使わず、ヒアストリングで渡しています。
if grep -q "$want" <<<"$out"; then
画面のテストも、待ち時間が足りずに通っていた
画面(React)にも同じ仕組みを入れました。案件を素早く切り替えたときに、前の案件の応答が後から届いて画面に混ざる、という混線を防ぐ見張りのテストです。
見張りを外した写しでテストを流すと、メンバー一覧のテストだけが通ってしまいました。
調べると、テストの待ち時間が足りず、遅らせた応答が届く前に判定していました。見張りがあってもなくても、判定の時点では何も届いていないので、テストは通ります。
通るテストが、必ずしも何かを確かめているとは限りません。壊して確かめる仕組みが無ければ、このテストは通り続けていたはずです。
画面の判定は、vitest の JSON 出力から読んでいます。画面向けの出力を grep すると、折り返しや装飾で当たったり外れたりするためです。
壊して確かめられなかったもの
1つだけ、この仕組みに載せられなかったものがあります。先行タスクの「輪」(1.1 → 1.2 → 1.1 のような循環)を止める見張りです。
見張りを外すと、波及を数える処理が無限にたどり続けます。テストは落ちるのではなく、vitest ごと止まります。最初は8分待ってから気づきました。
テスト自体(輪が入っていても止まること)は残してあります。壊して確かめる側には入れていません。
また、テストを途中で強制終了したとき、壊した写しが元に戻らないまま次の項目が走り、判定がでたらめになったことがありました。いまは壊す前に src/ ごと控えておき、終わったら控えから戻しています。1ファイルだけ控える形だと、壊す先が別のファイルに移ったときに戻せなくなるためです。
時刻の形を固定するテスト
最後に、少し変わったテストを1つ紹介します。
datetime.utcnow().isoformat() + "Z"
この書き方は Python 3.12 で非推奨になりました。素直に置き換えると、次のようになります。
datetime.now(timezone.utc).isoformat()
ところが、末尾が Z から +00:00 に変わります。このツールでは、変更履歴のソートキーに時刻を入れていて、並び順そのものになっています。形が変わると、古い履歴と新しい履歴の並びが静かに崩れます。
そこで、時刻の形を固定するテストを置いています。いつか誰かが置き換えたときに、このテストが落ちて気づけるようにするためです。
検査が全部通ったまま、ガントが開かなくなっていた
この記事を書くためにスクリーンショットを撮ろうとして、もう1つ見つかりました。ガントのタブを開くと、画面が真っ白になります。
3,000行あった画面のファイルを、タブごとのファイルに分けたときのことです。分けた先のファイルで、使っている関数の import を5か所書き漏らしていました。
| ファイル | 書き漏らした名前 | いつ落ちるか |
|---|---|---|
| ガント | depth |
タブを開くと必ず |
| ダッシュボード |
dayDiff、StatusChip
|
遅延タスクなどの一覧に1件でもあるとき |
| 案件管理 | ProjectCreator |
「新規案件」を押したとき |
| タスク管理 | bulkDeleteTasks |
Excel に無いタスクをまとめて消すとき |
JavaScript は、存在しない名前を使っていても、実行するまでエラーになりません。ビルドは通り、テストも通りました。画面のテストは計算と混線の見張りを確かめていましたが、分けたパネルそのものは描画していなかったからです。
そこで、ソースのすべてのファイルを Babel で読み、「宣言も import もされていない名前」を数える検査を足しました。
export function 未定義の名前(code, filename) {
const ast = babel.parseSync(code, {
filename, babelrc: false, configFile: false,
parserOpts: { plugins: ["jsx"] },
});
const 出た = new Set();
babel.traverse(ast, {
Program(p) {
for (const g of Object.keys(p.scope.globals)) if (!既知.has(g)) 出た.add(g);
},
});
return [...出た];
}
既知 には、window や Date のように、ブラウザと JavaScript が最初から持っている名前を入れています。
この検査にも、壊して確かめる仕組みを付けました。ガントのファイルから depth の import を外した写しを作り、それを捕まえることを毎回確かめています。直す前のコードに流すと、5か所すべてが出ました。
ESLint の no-undef でも同じことができます。このリポジトリには ESLint を入れていなかったので、ビルドの道具がすでに連れてきている Babel を使いました。
いまの検査の全体
デプロイの前に、次の5つの「壊して確かめる」を毎回流しています。
| 対象 | 項目数 | 中身 |
|---|---|---|
| Lambda のテスト | 48 | 実装を壊して、狙ったテストが落ちるか |
| 画面のテスト | 14 | 混線の見張り・集約・その場更新を壊す |
| 配色の検査 | 8 | 配色を壊して、コントラストの検査が捕まえるか |
| デプロイ先の確認 | 10 | 期待値を壊して、本当に止まるか(3本目の記事) |
| 宣言の無い名前 | 1 | import を外した写しを捕まえるか |
おわりに
テストの件数は、テストが何を確かめているかを教えてくれません。404件のテストがあっても、肝心なところを踏んでいなければ意味がありません。
検査を足したら、それを壊して実際に落ちることまで確かめる。これをこのツールの決まりにしています。
次の記事では、作ったツールに入力が集まらなかったので、入れてもらうための機能を足した話を書きます。