Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions docs/decisions/0019-test-outside-in.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# 0019: テストは外側(api と DB)から今の動作を固定し、GAS と API をまたぐ E2E と admin は自動テストにしない

- 状態:採用
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- 決めた日:2026-07-07(テストロードマップを入れた日。PR #392)
- 決めた人:45th の PM(2026-07-06 の MT で出た宿題を受けて)
- 決定の信頼度:記録なし
- 根拠:PR #392 の本文と `docs/development/test-roadmap.md` には、決めたことと理由は書かれているが、どれだけ確かだと思っていたかは書かれていない。宿題が出た 2026-07-06 の MT の資料は、まだ読めていない
- 出典:PR #392(`docs/development/test-roadmap.md`)

## 背景

2026-07-06 の MT で、SeeFT 本体は新しい機能を足さず、保守性を上げると決めた([0003](0003-maintenance-over-features.md))。その次に、どこからテストを書くかを決める必要があった。

当時、`api/` のテストは 0 件、mobile は Flutter のひな形が1件あるだけで、CI はテストを走らせていなかった。

MT のたたき台は、静的解析から始めて、repository を直してからテストを書き、最後に GAS と API の結合テストをする順だった。MT では「結合テストは最初と最後のどちらか」が宿題になった。

## 候補

| 候補 | 良い点 | 悪い点 |
| --- | --- | --- |
| たたき台のとおり、repository を直してからテストを書き、最後に GAS と API の結合テストをする | 教科書どおりの「単体を固めてから結合」の順 | テストが無いままクエリを書き換えると、直したのか壊したのか分からない |
| 外側から、今の動作をそのまま期待値にするテスト(ゴールデンテスト)を先に書き、その中で内側を直す | 書き換えたあとに、動作が変わっていないかを機械で判定できる | 実際の DB を使うテストの環境を用意する必要がある |
| GAS にも自動テストを整える | GAS の変更で壊れたことに気づける | GAS のコードはスプレッドシートの API に依存していて、Node で読み込んだ時点で落ちるファイルがある |

## 決定

- api は外側から、次の順に進める
1. テストの基盤
2. 依存の無い関数
3. repository のゴールデンテスト(実際の DB)
4. repository のクエリの修正
5. usecase
6. controller
- GAS と API をまたぐ E2E は自動化しない。api 側で、GAS から受け取るデータの形を確かめるテスト(controller のテスト)と、GAS を変えたときの手動の確認で代える
- GAS は自動テストの対象にしない。GAS を変える必要が出たら、変える部分を引数を取る関数として別のファイルに出してから触る
- admin は凍結しているので、テストを書かない。動かし直すと決めたら、CI に `next build` の確認を足すところから始める
- mobile は api とは別に、並行して進める

「採用」の範囲は次のとおりである(2026-09-30 に develop で確かめた)。

- 実際にそうしているもの:GAS と API をまたぐ E2E と、GAS・admin を自動テストにしないこと。mobile のテストを api とは別に足していること。api の順番のうち、テストの基盤・依存の無い関数・usecase のテストがあること
- まだ行っていないもの:api の順番のうち、実際の DB を使うゴールデンテスト(3)、それを安全網にしたクエリの修正(4)、controller のテスト(6)。usecase のテストは、ゴールデンテストより先に書かれた(下の「結果」)

## 理由

テストの無いコードを保守するときは、先に外側から今の動作を固定し、その中で内側を整える。単体テストを書くためにコードを分けたりモックに置き換えたりすると、その作業自体が動作を変えるおそれがあるためである。「単体を固めてから結合」は、新しく作るときの順である(ロードマップの「MT の宿題」の節)。

GAS は、スプレッドシートと API の間でデータをやり取りするコードで、GAS の API に全体が依存している。ファイルの先頭でスプレッドシートの画面を呼ぶものは、Node で読み込んだ時点で落ちる。自動テストを整える手間に見合わないと判断した(ロードマップの「GAS の方針」の節)。

admin は、ロードマップを書いた時点で直近 12 か月のコミットが 0 件だった(ロードマップの「admin の方針」の節)。

## 前提

- 方針の本体は `docs/development/test-roadmap.md`
- repository が `*sql.Rows` を返す作りであること。モックの方式は [0018](0018-test-mock-with-sqlmock.md) にある
- admin に手を入れないこと。admin を動かし直すなら、この ADR の admin の部分を見直す
- GAS のコードが、スプレッドシートの API に強く依存していること

## 結果

2026-09-30 に develop で確かめた結果(`git ls-tree` で `api/` のテストのファイルを数え、`git grep` で build tag を探した)。

- テストの基盤(`.github/workflows/go-test.yml`。`api/` を変える PR ごとに `go test` が走る)は入った
- `api/` のテストは 18 ファイルになった。repository のテストも go-sqlmock で書かれている
- 実際の DB を使うゴールデンテスト(build tag `integration` を付けたテスト)は、まだ1本も無い。ゴールデンテストを安全網にしてからクエリを直す、という順は、45th の間には実現しなかった。クエリのプレースホルダ化(#266)は 2026-09-30 の時点で終わっていない

## 追記
59 changes: 59 additions & 0 deletions docs/decisions/0020-bundle-subset-japanese-font.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# 0020: 技大祭の前は Flutter を上げず、日本語フォントの Regular と Bold を削って同梱する

- 状態:採用
- 決めた日:2026-09-13(#513 を立てた日。PR #515 は 2026-09-14 にマージ)
- 決めた人:45th の PM
- 決定の信頼度:記録なし
- 根拠:#513・PR #515・#514 の本文には、決めたことと計測の結果は書かれているが、どれだけ確かだと思っていたかは書かれていない。#513 の本文には、最初の原因の見立てが誤りだったと 2026-09-13 に訂正し、調べ直してからこの方針に変えたことが書かれている。同梱を続けるかは、Flutter を上げたときに決め直す前提だった(#514)
- 出典:#513、PR #515、#514

## 背景

2026-09-13、mobile のシフトカードで、太字の日本語の一部(「閉」「開」「関」「問」など)が輪郭線だけになると分かった(#513)。

Flutter 3.27.3 の Web(CanvasKit)は、Regular しかない日本語フォントの太字を、Regular から太らせて描く(疑似ボールド)。その途中で輪郭の向きが食い違い、塗りが打ち消される。Flutter 3.35.0 以降ではこの処理が消えていて、崩れない(仕組みと検証は #513 の本文)。

技大祭は 9/19 からで、1週間を切っていた。

## 候補

| 候補 | 良い点 | 悪い点 |
| --- | --- | --- |
| Flutter を 3.35 以上に上げる | 根本から直る | 技大祭の直前に SDK を上げることになり、ほかの不具合が出たときに切り分けられない |
| 本物の Bold を同梱し、疑似ボールドを使わないようにする(削らずに同梱) | Flutter を上げずに直る | 2ファイルで計 10.9MB。圧縮なしで配信して 1.6Mbps の回線で計ると、76.8 秒かかった |
| 本物の Bold を、使う字だけに削って同梱する | Flutter を上げずに直る。大きさは半分になる | 削った範囲の外の字(絵文字など)を太字にすると、崩れる可能性が残る。初回の起動は、1.6Mbps で約 16 秒長くなる |
| 可変フォント(1つのファイルで太さを変えるフォント)を同梱する | 1ファイルで済む | `FontWeight` で太さが変わらず、極細のまま描かれた(PM の作業メモ) |

## 決定

技大祭の前は Flutter を上げない。代わりに、Noto Sans JP の Regular と Bold(Google Fonts の static 版)を同梱する。2ファイルとも、CP932(Windows の Shift_JIS)で表せる 7,490 字に削る(各 5.47MB → 2.76MB)。

- 2ファイルは同じファミリー名 `NotoSansJP` で登録し、既存の `FontWeight.bold` のまま Bold が選ばれるようにする
- フォントの指定は `textTheme.apply(fontFamily:)` に書く。`ThemeData(fontFamily:)` に書いても、SeeFT が渡している `Typography` の値に上書きされて、反映されない
- Flutter を上げることと、上げたあとも同梱を続けるかは、技大祭のあとに #514 で決める

## 理由

技大祭の直前に SDK を上げたくなかった。同梱なら、変わるのはフォントと指定の2か所だけで済む。

削った理由は、起動の時間である。圧縮なしで配信して 1.6Mbps で計ると、削らずに同梱したものは 76.8 秒、削ったものは 49.6 秒かかった(PR #515 の計測)。削ると、日本語が出るまでの時間は変更前より短くなった。変更前は、最初の画面が出たあと、Google から 6.35MB の中国語のフォント(Noto Sans SC)を取得し終わるまで、日本語が空白のままだったためである。

| 回線 | ビルド | 最初の画面 | 日本語が出る |
| --- | --- | --- | --- |
| 1.6Mbps | 変更前 | 14.0 秒 | 46.2 秒 |
| 1.6Mbps | 変更後(削って同梱) | 29.7 秒 | 29.7 秒 |

(PR #515 の計測。ヘッドレス Chrome で回線を絞り、圧縮して配信した3回の中央値。2回目以降の訪問はキャッシュから返り、通信は 0 だった)

## 前提

- 削る処理:`mobile/tool/subset_noto_sans_jp.py`。作り直すときは、この docstring の手順に従う
- 指定:`mobile/lib/theme/theme.dart#MaterialTheme.theme`、登録:`mobile/pubspec.yaml`
- Flutter が 3.35 未満であること。Flutter の版は `mobile/.fvmrc` で確かめる。3.35 以上に上げたら、この ADR を見直す(#514)
- 同梱したフォントは、Google の配信ではなく本番の mobile のコンテナ(`mobile/python/server.py`)から配られる。初めて開く人1人あたり、`server.py` が送る量が 2.51MB から 8.03MB に増える。そのため、静的配信を並行処理と圧縮にした([0021](0021-static-server-threaded-gzip.md))

## 結果

PR #515 を 2026-09-14 にマージした。PR #519([0021](0021-static-server-threaded-gzip.md))の直後にマージしている。

## 追記
57 changes: 57 additions & 0 deletions docs/decisions/0021-static-server-threaded-gzip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# 0021: mobile の静的配信は server.py のまま、並行処理と gzip の圧縮にする

- 状態:採用
- 決めた日:2026-09-14(#518 の開発の開始日。PR #519 は同じ日にマージ)
- 決めた人:45th の PM
- 決定の信頼度:記録なし
- 根拠:#518・PR #519 の本文と `docs/development/load-test-plan.md` の issue 6 には、決めたことと計測の結果は書かれているが、どれだけ確かだと思っていたかは書かれていない
- 出典:#518、PR #519、`docs/development/load-test-plan.md` の issue 6

## 背景

本番の mobile は、Flutter Web のビルド成果物を `mobile/python/server.py` が配っている。当時の `server.py` は `socketserver.TCPServer` で、1本のスレッドで1件ずつ返し、圧縮もしていなかった。

負荷試験の計画(`docs/development/load-test-plan.md`)の issue 6 は、朝に大勢が一斉に開くと、API より先にこの静的配信が詰まるおそれがあると挙げていた。

そこに、日本語フォントの同梱([0020](0020-bundle-subset-japanese-font.md))で、初めて開く人1人あたりに `server.py` が送る量が 2.51MB から 8.03MB(約3倍)に増えることになった。本番では、途中の経路でファイルがキャッシュされず、`server.py` が送った量がそのまま流れていた(#518。2026-09-13 に本番の応答で確かめた)。

## 候補

| 候補 | 良い点 | 悪い点 |
| --- | --- | --- |
| `server.py` を並行処理にし、gzip で圧縮して返す | 数行の変更で済む。起動の仕方(compose)を変えなくてよい | Python の標準のサーバーのままで、同時の接続が増えるとスレッドが増える |
| nginx などに置き換える | 静的配信に向いたサーバーを使える | イメージと compose を作り直す必要がある |
| Cloudflare でキャッシュする | `server.py` に届く量そのものが減る | Cloudflare の設定は SeeFT の外の管理者に相談が要る |

nginx と Cloudflare の案は、load-test-plan.md と #518 に候補として挙がっているが、比べて退けた記録は無い。

## 決定

`server.py` の `socketserver.TCPServer` を `http.server.ThreadingHTTPServer` に置き換え、リクエストごとにスレッドで処理する。

- html・js・json・css・wasm・フォント・svg・txt を gzip で圧縮して返す。圧縮するのは、`Accept-Encoding` の gzip に付いた q の値が 0 より大きく 1 以下のときだけ(q が無ければ 1 とみなす)。q が 0 のときや、数として読めないときは圧縮しない。圧縮はファイルごとに1回だけ行い、結果をメモリに持つ
- 接続の受け付け待ちの列(`request_queue_size`)を、標準の 5 から 1024 に広げる。スレッドの数に上限(スレッドプール)は付けない
- フォントの同梱(PR #515)だけが先に本番に出ないよう、PR #519 を先にマージする

## 理由

フォントの同梱で、初回の負担が約3倍になるのを避けたかった(PM の作業メモ)。この変更で、初回に `server.py` が送る量は 8.03MB から 4.03MB に減る(PR #519)。

受け付け待ちの列を広げたのは、負荷試験の結果による。遅い接続を 4,200 本(名簿の約 350 人 × 初回に取るファイル約 12)張ると、標準の 5 では 1,273〜1,737 本が列から溢れて切られた。1024 にすると 0 本だった(PR #519 の本文)。

スレッドの数に上限を付けなかったのは、4,200 スレッドが同時に動いてもメモリが最大 879MiB(4GiB の約 22%)で足りたからである(PR #519 の本文)。上限を付けると待ちが戻る、という判断は PM の作業メモにある。

nginx などに置き換えなかった理由は、記録に無い。数行の変更で技大祭(9/19)の前に間に合うから、というのは推測である。

## 前提

- 配信:`mobile/python/server.py#Server`、圧縮:`mobile/python/server.py#gzipped_body`、`mobile/python/server.py#MyHandler.accepts_gzip`、圧縮する拡張子:`mobile/python/server.py#COMPRESSIBLE_EXTENSIONS`
- 本番のサーバーに、この負荷を受けられるだけのスレッドの数とメモリの余裕があること(2026-09-14 に本番で確かめた。PR #519 の本文)。スレッドの数やメモリに上限を付けるときや、もっと小さいサーバーに移すときは、この ADR を見直す
- `request_queue_size` の 1024 が、OS の受け付け待ちの列の上限(`somaxconn`)に収まっていること
- フォントの同梱([0020](0020-bundle-subset-japanese-font.md))を続けていること。PR #519 だけを元に戻すと、初回に送る量が 8.03MB に戻る

## 結果

PR #519 を 2026-09-14 にマージし、その直後に PR #515 をマージした。

## 追記
3 changes: 3 additions & 0 deletions docs/decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,6 @@ python3 scripts/refcheck/refcheck.py docs/decisions/*.md
| [0016](0016-simple-manual-pdf-on-drive.md) | 簡易マニュアルは PDF にして Drive に置き、アプリからそこへ移動させる | 採用 |
| [0017](0017-manual-ops-not-automated.md) | マニュアル運用のスプレッドシートの操作は自動化しない | 見送り |
| [0018](0018-test-mock-with-sqlmock.md) | テストでは db.Client に go-sqlmock の偽の DB を差し込み、repository の戻り値は変えない | 採用 |
| [0019](0019-test-outside-in.md) | テストは外側(api と DB)から今の動作を固定し、GAS と API をまたぐ E2E と admin は自動テストにしない | 採用 |
| [0020](0020-bundle-subset-japanese-font.md) | 技大祭の前は Flutter を上げず、日本語フォントの Regular と Bold を削って同梱する | 採用 |
| [0021](0021-static-server-threaded-gzip.md) | mobile の静的配信は server.py のまま、並行処理と gzip の圧縮にする | 採用 |
Loading