Slide 1

Slide 1 text

internal/testlog で遊ぼう 2026-08-24 layerx.go #5 @rokuosan_dev

Slide 2

Slide 2 text

whoami @rokuosan_dev rokuosan(ろくお) id:rokuokun 敬称は省略できます LayerX バクラク事業部 (2026-08 -) Platform Engineering 部 Enabling チーム インターンです 京都在住 Kyoto.kt 主催 © LayerX Inc.

Slide 3

Slide 3 text

go test (cached)

Slide 4

Slide 4 text

緊急 キャッシュされる?されない?クイズ

Slide 5

Slide 5 text

Q1. 通常の Go test 例えばこんなテスト © LayerX Inc. 5

Slide 6

Slide 6 text

Q1. 通常の Go test go test を素直に実行する 当然テストが実行される © LayerX Inc. 6

Slide 7

Slide 7 text

Q1. 通常の Go test Q1. 2回目は? © LayerX Inc. 7

Slide 8

Slide 8 text

Q1. 通常の Go test A2. 2回目は cached そうですね © LayerX Inc. 8

Slide 9

Slide 9 text

Q2. 依存する環境変数やファイルを変更したテスト Q2. 環境変数やモジュール内のファイルを変更すると? © LayerX Inc. 9

Slide 10

Slide 10 text

Q2. 依存する環境変数やファイルを変更したテスト A2. 実行される © LayerX Inc. 10

Slide 11

Slide 11 text

Q3. モジュール外の変更 モジュール外のファイルを参照してみる © LayerX Inc. 11

Slide 12

Slide 12 text

Q3. モジュール外の変更 Q3. モジュール外のファイルを書き換えたら? © LayerX Inc. 12

Slide 13

Slide 13 text

Q3. モジュール外の変更 キャッシュされてテストは走らない ファイルを書き換えたのに走らない 困った © LayerX Inc. 13

Slide 14

Slide 14 text

Q3. モジュール外の変更 強制的に実行させる 無事失敗 © LayerX Inc. 14

Slide 15

Slide 15 text

cache の仕組み

Slide 16

Slide 16 text

キャッシュ利用の判定 testID で testlog.txt を取得 testID = テストバイナリの build ID + キャッシュ可能な引数 testlog の各行を今の状態でハッシュして inputsID を作る → 現在の値。未設定かどうかも込み open → mtime と size (中身は読まない) stat → Stat と Lstat の結果 GODEBUG はログに出てこなくても必ず混ぜる getenv subkey(testID, inputsID) © LayerX Inc. で前回の出力を取得できれば (cached) ※ subkey(parent, desc) は親キーと説明文字列を混ぜて派生キーを作る cmd/go 共通のヘルパ。stdout や coverprofile の保存にも使われている 16

Slide 17

Slide 17 text

キャッシュ判定の擬似コード © LayerX Inc. ref: cmd/go/internal/test/test.go tryCacheWithID / computeTestInputsID 17

Slide 18

Slide 18 text

testlog.txt テストが os 越しに触ったものが、そのまま1行ずつ載る モジュール root の外にあるファイルはログには載るが、ハッシュの対象にならない フォーマットについては後述 © LayerX Inc. 18

Slide 19

Slide 19 text

internal/testlog

Slide 20

Slide 20 text

internal/testlog Package testlog provides a back-channel communication path between tests and package os, so that cmd/go can see which environment variables and files a test consults. テストと os の間のバックチャネル cmd/go testing が「テストがどの env / file を見たか」を知るためにある が実装を挿し、 os がそれを呼ぶ 両者が共通で依存できる最下層に置かれている © LayerX Inc. ref: https://github.com/golang/go/blob/go1.26.7/src/internal/testlog/log.go#L5-L7 20

Slide 21

Slide 21 text

Interface os © LayerX Inc. 側は testlog.Open(name) と呼ぶだけ logger が nil なら何もしない ※ atomic.Pointer なのは、init で起きた goroutine と競合しても race detector に引っかからないようにするため 21

Slide 22

Slide 22 text

internal/testlog を自作する

Slide 23

Slide 23 text

Interface を用意する 同じメソッドを同じシグネチャで並べた interface を自前で定義する internal/testlog.Interface © LayerX Inc. は internal なため... 23

Slide 24

Slide 24 text

フォーマットにそって実装する 1行目は # test log で固定 これが無いと malformed 扱いで丸ごと捨てられる あとは op name を1行ずつ。末尾は必ず改行 op は open / stat / getenv / chdir の4つだけ © LayerX Inc. ref: cmd/go/internal/test/test.go testlogMagic 24

Slide 25

Slide 25 text

どうやって動かす? © LayerX Inc. 25

Slide 26

Slide 26 text

go:linkname による差し込み 本体の無い関数を宣言して、リンカに繋がせる 実体は internal/testlog.SetLogger 渡すのはさっきの自前 interface © LayerX Inc. 26

Slide 27

Slide 27 text

敗北... Go 1.23 以降のリンカの制限 定義側にマークが無いシンボルは引けない -ldflags=-checklinkname=0 © LayerX Inc. で黙らせる 27

Slide 28

Slide 28 text

checklinkname=0 でも敗北... SetLogger © LayerX Inc. は一度しか呼び出せない ref: https://github.com/golang/go/blob/go1.26.7/src/testing/internal/testdeps/deps.go#L117-L130 28

Slide 29

Slide 29 text

SetLogger を呼ぶ経路 © LayerX Inc. ref: https://github.com/golang/go/blob/go1.26.7/src/testing/testing.go#L2600-L2680 29

Slide 30

Slide 30 text

SetLogger を呼ぶ経路 -test.testlogfile © LayerX Inc. は go test が毎回付けてくる cmd/go がテストバイナリの os.Args を組み立てている *testlog はその値を flag.Parse が受け取ったもの ref: https://github.com/golang/go/blob/go1.26.7/src/testing/testing.go#L2600-L2680 30

Slide 31

Slide 31 text

SetLogger を呼ぶ経路 ここで一度目の SetLogger が呼ばれる さっきの panic はここと衝突 © LayerX Inc. ref: https://github.com/golang/go/blob/go1.26.7/src/testing/testing.go#L2600-L2680 31

Slide 32

Slide 32 text

SetLogger を呼ぶ経路 ここを false のままにしたい os.Args © LayerX Inc. から -test.testlogfile を消せば、この経路ごと実行されない ref: https://github.com/golang/go/blob/go1.26.7/src/testing/testing.go#L2600-L2680 32

Slide 33

Slide 33 text

SetLogger を呼ぶ経路 init © LayerX Inc. で os.Args を書き換えると良さそう ref: https://github.com/golang/go/blob/go1.26.7/src/testing/testing.go#L2600-L2680 33

Slide 34

Slide 34 text

os.Args の書き換えと Logger の置き換え を空文字にしてデフォルトの testlog を無効化 独自実装の testlog を差し込む *testlog © LayerX Inc. 34

Slide 35

Slide 35 text

自作 testlog を仕込む

Slide 36

Slide 36 text

blank import による利用 で書いたのでこれで完了 ただしパッケージ単位... init() 差し替えたい全パッケージに1行ずつ書く必要がある © LayerX Inc. 36

Slide 37

Slide 37 text

overlay による差し込み -overlay © LayerX Inc. は存在しないパスを実ファイルに割り当てられる 37

Slide 38

Slide 38 text

差し込んでみる © LayerX Inc. 38

Slide 39

Slide 39 text

実行してみる © LayerX Inc. 39

Slide 40

Slide 40 text

ここからが本題

Slide 41

Slide 41 text

自作 testlog で遊んでみる © LayerX Inc. 41

Slide 42

Slide 42 text

GitHub Actions で (cached) 出ない問題 checkout のたびに全ファイルの mtime が更新される キャッシュを復元しても無駄 中身ではなく mtime で判定しているため golang/go にも issue がある golang/go #58571 © LayerX Inc. ref: golang/go#58571 「cmd/go: test cache hash inputs include ModTime, often causing cache misses on CI」 42

Slide 43

Slide 43 text

mtime はどこでハッシュに入るか(1/2) / mode / mtime / isdir をハッシュに書く #58571 でも言及されている size © LayerX Inc. ref: https://github.com/golang/go/blob/go1.26.7/src/cmd/go/internal/test/test.go#L2138-L2140 43

Slide 44

Slide 44 text

mtime はどこでハッシュに入るか(2/2) これを実行すると以下の testlog が出る cmd/go はこの行を見て os.Stat し、実ファイルの mtime をハッシュに混ぜる これによって checkout のたびに実ファイルの mtime は新品になる © LayerX Inc. 44

Slide 45

Slide 45 text

余談: なぜ中身ではなく mtime なのか 中身を丸ごとハッシュしない 巨大なファイルを毎回読むコストを避けるため 代わりに mtime + size で「たぶん変わっていない」を推測する 正確だが遅い方法を捨てて、速いが不正確な方法を選んでいる © LayerX Inc. ref: https://github.com/golang/go/blob/go1.26.7/src/cmd/go/internal/test/test.go#L2107-L2110 45

Slide 46

Slide 46 text

中身が変わったものだけ mtime が動いてほしい © LayerX Inc. 46

Slide 47

Slide 47 text

自作 testlog で見る先をすり替える testlog.txt は扱えるようになった 実ファイルの代わりに、中身が変わった時だけ mtime が動く代理ファイルを指すよう に書き換える test 実行前に中身が更新されていたら上書きする © LayerX Inc. 47

Slide 48

Slide 48 text

余談: なぜ open ではなく stat で書くか hashOpen では2秒以内に更新されたファイルの結果を保存しない仕様がある mtime の精度ではごく短い間隔の更新を区別できないことがあるため © LayerX Inc. ref: https://github.com/golang/go/blob/go1.26.7/src/cmd/go/internal/test/test.go#L2112-L2118 48

Slide 49

Slide 49 text

こんな感じ テスト実行前に生成を忘れると破滅することには注意 古いハッシュを見続けるためずっと OK のまま © LayerX Inc. 49

Slide 50

Slide 50 text

まとめ (cached) は中身ではなくサイズや mtime で判断している internal/testlog を通じて、前回何を触ったかが記録されている internal package でも linkname と overlay で挙動を変更できる GitHub Actions で別ジョブを跨ぐと go test は cached にならない checkout のたびに mtime が新品になるため 自作 testlog を使えば常に成功する go test を爆誕させることも可能 © LayerX Inc. 50