Slide 1

Slide 1 text

Table-driven testing に縛られない Goのテストパターン Kotaro Abe @Go Conference 2024 (2024/06/08) 1

Slide 2

Slide 2 text

⾃⼰紹介 2 ● 名前: abekoh / Kotaro Abe ● Twitter/X: @abekoh_bcky ● Go歴: 出会って6年、業務で3年 ● 所属: 株式会社MICIN ○ 治験業務支援のSaaS「MiROHA」を開発しております ○ バックエンドをGoで書いてます

Slide 3

Slide 3 text

Table-driven testing やってますか? 3

Slide 4

Slide 4 text

Table-driven testing ● Goでしばしば使われるテストパターン ● 入力値・期待値を定義した構造体を用意 →ループさせてt.Run()で実行 ● 値の定義箇所(=テーブル)をチェックする だけでどんな入力値・期待値か確認できる ● VSCodeプラグイン、GoLand等のテストテンプレートとしても デフォルトで生成される ○ cweill/gotestsが内部で実行される 4 func TestSum(t *testing.T) { tests := []struct { a int b int want int }{ {a: 0, b: 0, want: 0}, {a: 1, b: 2, want: 3}, {a: 2, b: 1, want: 3}, {a: 10, b: 10, want: 20}, {a: 10, b: -10, want: 0}, } for _, tt := range tests { t.Run(fmt.Sprintf("%d+%d", tt.a, tt.b), func(t *testing.T) { if got := Sum(tt.a, tt.b); got != tt.want { t.Errorf("Sum(%d+%d) = %d, want %d", tt.a, tt.b, got, tt.want) } }) } }

Slide 5

Slide 5 text

Table-driven testingは万能か? ● ある関数・メソッドに対して、1つのfunc TestXXX()内で Table-driven testingで全パターン列挙してみる →「なんか読みにくくない…?」ってテストコードになることがある ● 特に、次のようなテストで辛さが出てくる ○ 状態に動作が依存するテスト ○ 事前準備がテスト項目によって異なるテスト ○ 期待したいものがテスト項目によって異なるテスト 5

Slide 6

Slide 6 text

例: ユーザの保存を⾏う リポジトリのテストコード 6

Slide 7

Slide 7 text

func ExampleUserRepository() { r := NewUserRepository() _, _ = r.Create(UserInput{Name: "Alice"}) _, _ = r.Create(UserInput{Name: "Bob"}) fmt.Printf("%+v\n", r.List()) // [{ID:1 Name:Alice} {ID:2 Name:Bob}] } func TestUserRepository_Create_1(t *testing.T) { tests := []struct { name string input UserInput want User }{ { name: "create 1 user", input: UserInput{Name: "Alice"}, want: User{ID: 1, Name: "Alice"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := NewUserRepository() got, err := r.Create(tt.input) if err != nil { t.Errorf("UserRepository.Create() error = %v", err) return } if got != tt.want { t.Errorf("UserRepository.Create() = %v, want %v", got, tt.want) } }) } } ユーザの状態を持つ UserRepository ユーザ作成 →ID発番されて保存される 7 Table-driven testingが辛くなる例(1/5)

Slide 8

Slide 8 text

func TestUserRepository_Create_2(t *testing.T) { tests := []struct { name string input UserInput want User wantErr bool }{ { name: "create 1 user", input: UserInput{Name: "Alice"}, want: User{ID: 1, Name: "Alice"}, }, { name: "empty name", input: UserInput{Name: ""}, want: User{}, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := NewUserRepository() got, err := r.Create(tt.input) if (err != nil) != tt.wantErr { t.Errorf("UserRepository.Create() error = %v, wantErr %v", err, tt.wantErr) return } if got != tt.want { t.Errorf("UserRepository.Create() = %v, want %v", got, tt.want) } }) } } ユーザ名が””ならエラー →エラー検証を追加 8 Table-driven testingが辛くなる例(2/5)

Slide 9

Slide 9 text

func TestUserRepository_Create_3(t *testing.T) { tests := []struct { name string prepare func(*testing.T, *UserRepository) input UserInput want User wantErr bool }{ // ... { name: "duplicated name", prepare: func(t *testing.T, r *UserRepository) { _, err := r.Create(UserInput{Name: "Alice"}) if err != nil { t.Fatal(err) } }, input: UserInput{Name: "Alice"}, want: User{}, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := NewUserRepository() if tt.prepare != nil { tt.prepare(t, r) } got, err := r.Create(tt.input) if (err != nil) != tt.wantErr { t.Errorf("UserRepository.Create() error = %v, wantErr %v", err, tt.wantErr) return } if got != tt.want { t.Errorf("UserRepository.Create() = %v, want %v", got, tt.want) } }) } } すでに同じ名前があればエラー →事前データ準備を追加 9 Table-driven testingが辛くなる例(3/5)

Slide 10

Slide 10 text

func TestUserRepository_Create_4(t *testing.T) { tests := []struct { name string prepare func(*testing.T, *UserRepository) input UserInput want User wantErr bool wantList []User }{ // ... { name: "sequential IDs", prepare: func(t *testing.T, repository *UserRepository) { for i := 0; i < 5; i++ { _, err := repository.Create(UserInput{Name: fmt.Sprintf("User%d", i+1)}) if err != nil { t.Fatal(err) } } }, input: UserInput{Name: "Alice"}, want: User{ID: 6, Name: "Alice"}, wantErr: false, wantList: []User{ {ID: 1, Name: "User1"}, {ID: 2, Name: "User2"}, {ID: 3, Name: "User3"}, {ID: 4, Name: "User4"}, {ID: 5, Name: "User5"}, {ID: 6, Name: "Alice"}, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := NewUserRepository() if tt.prepare != nil { tt.prepare(t, r) } got, err := r.Create(tt.input) if (err != nil) != tt.wantErr { t.Errorf("UserRepository.Create() error = %v, wantErr %v", err, tt.wantErr) return } if got != tt.want { t.Errorf("UserRepository.Create() = %v, want %v", got, tt.want) } if tt.wantList != nil { if !reflect.DeepEqual(r.List(), tt.wantList) { t.Errorf("UserRepository.List() = %v, want %v", r.List(), tt.wantList) } } }) } } 連番で保存されているか確認したい →事後状態検証の追加 10 Table-driven testingが辛くなる例(4/5)

Slide 11

Slide 11 text

func TestUserRepository_Create_4(t *testing.T) { tests := []struct { name string prepare func(*testing.T, *UserRepository) input UserInput want User wantErr bool wantList []User }{ // ... { name: "sequential IDs", prepare: func(t *testing.T, repository *UserRepository) { for i := 0; i < 5; i++ { _, err := repository.Create(UserInput{Name: fmt.Sprintf("User%d", i+1)}) if err != nil { t.Fatal(err) } } }, input: UserInput{Name: "Alice"}, want: User{ID: 6, Name: "Alice"}, wantErr: false, wantList: []User{ {ID: 1, Name: "User1"}, {ID: 2, Name: "User2"}, {ID: 3, Name: "User3"}, {ID: 4, Name: "User4"}, {ID: 5, Name: "User5"}, {ID: 6, Name: "Alice"}, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := NewUserRepository() if tt.prepare != nil { tt.prepare(t, r) } got, err := r.Create(tt.input) if (err != nil) != tt.wantErr { t.Errorf("UserRepository.Create() error = %v, wantErr %v", err, tt.wantErr) return } if got != tt.want { t.Errorf("UserRepository.Create() = %v, want %v", got, tt.want) } if tt.wantList != nil { if !reflect.DeepEqual(r.List(), tt.wantList) { t.Errorf("UserRepository.List() = %v, want %v", r.List(), tt.wantList) } } }) } } 11 3箇所を行ったりきたりして理解 テスト項目によっては 関心が薄い入力値も ifたくさんの 複雑なテストコード Table-driven testingが辛くなる例(5/5)

Slide 12

Slide 12 text

Arrange-Act-Assert ● 準備(Arrange)・実行(Act)・確認(Assert)フェーズを明確にして テストコードを書く整理術 ○ 『テスト駆動開発』『単体テストの考え方 /使い方』などで紹介されている ● テーブルの作成・ループをやめて、t.Run() に入力値・期待値を 素直に書いていくことでArrange-Act-Assertで整理したコードが書ける 12

Slide 13

Slide 13 text

func TestUserRepository_Create_4(t *testing.T) { tests := []struct { name string prepare func(*testing.T, *UserRepository) input UserInput want User wantErr bool wantList []User }{ // ... { name: "sequential IDs", prepare: func(t *testing.T, repository *UserRepository) { for i := 0; i < 5; i++ { _, err := repository.Create(UserInput{Name: fmt.Sprintf("User%d", i+1)}) if err != nil { t.Fatal(err) } } }, input: UserInput{Name: "Alice"}, want: User{ID: 6, Name: "Alice"}, wantErr: false, wantList: []User{ {ID: 1, Name: "User1"}, {ID: 2, Name: "User2"}, {ID: 3, Name: "User3"}, {ID: 4, Name: "User4"}, {ID: 5, Name: "User5"}, {ID: 6, Name: "Alice"}, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := NewUserRepository() if tt.prepare != nil { tt.prepare(t, r) } got, err := r.Create(tt.input) if (err != nil) != tt.wantErr { t.Errorf("UserRepository.Create() error = %v, wantErr %v", err, tt.wantErr) return } if got != tt.want { t.Errorf("UserRepository.Create() = %v, want %v", got, tt.want) } if tt.wantList != nil { if !reflect.DeepEqual(r.List(), tt.wantList) { t.Errorf("UserRepository.List() = %v, want %v", r.List(), tt.wantList) } } }) } } Arrange① Arrange② Arrange③ Act① Act② Act③ Assert① Assert② Assert③ ● Arrange, Act, Assertが散らばる ● 全テスト項目同じ形を強いられる ● より複雑なケースだとさらに難解に… 13 Table-driven testingに見るArrange-Act-Assert

Slide 14

Slide 14 text

func TestUserRepository_Create_5(t *testing.T) { // ... t.Run("duplicated name", func(t *testing.T) { // arrange r := NewUserRepository() _, _ = r.Create(UserInput{Name: "Alice"}) // act got, err := r.Create(UserInput{Name: "Alice"}) // assert if err == nil { t.Errorf("UserRepository.Create() = %v, want error", got) } }) t.Run("sequential IDs", func(t *testing.T) { // arrange r := NewUserRepository() for i := 0; i < 5; i++ { _, _ = r.Create(UserInput{Name: fmt.Sprintf("User%d", i+1)}) } // act _, err := r.Create(UserInput{Name: "Alice"}) // assert if err != nil { t.Errorf("UserRepository.Create() error = %v", err) return } wantList := []User{ {ID: 1, Name: "User1"}, {ID: 2, Name: "User2"}, {ID: 3, Name: "User3"}, {ID: 4, Name: "User4"}, {ID: 5, Name: "User5"}, {ID: 6, Name: "Alice"}, } if !reflect.DeepEqual(r.List(), wantList) { t.Errorf("UserRepository.List() = %v, want %v", r.List(), wantList) } }) } Arrange Act Assert Arrange Act Assert Arrange-Act-Assertを意識して 各フェーズをまとめる→読みやすい テスト項目ごとに 各フェーズでやることが可変長になる 14 Arrange-Act-Assertを考慮してリファクタリング

Slide 15

Slide 15 text

Table-driven testingはいつ使うべきか ● テスト項目ごとにArrange, Act, Assertで行いたいことの差分が少ないと きはTable-driven testingは光る ○ 同じ使い方で、大量の入力値・期待値を試したい場合には有効 ● テスト項目ごとに必要な準備が異なる、確認したいものにバリエーションが ある場合は、素直に諦めるのも手 ○ 無理にTable-driven testingにするとパラメータが増大、ifが大量発生 →読みにくいテストコードに ○ t.Run()で愚直にArrange-Act-Assertをまとめたほうがメリット大きい ● そもそも、「状態を伴わない形に書き換えてみる」ことも 可能なら考える ○ 関数型のエッセンスを取り入れてみる 15

Slide 16

Slide 16 text

ちなみに…golang/goでは? ● t.Run()でバリエーションがあるテストに使われている例は 少数だがある ○ https://github.com/golang/go/blob/go1.22.4/src/archive/zip/zip_test.go #L347-L382 ○ https://github.com/golang/go/blob/go1.22.4/src/strconv/strconv_test.g o#L81-L129 ○ https://github.com/golang/go/blob/go1.22.4/src/internal/saferio/io_test .go#L13-L61 ● Webアプリ書いてるときによく苦労するが、 ライブラリ書いてるときはそうでもないかも 16

Slide 17

Slide 17 text

まとめ ● Table-driven testingは万能ではない。アンマッチな場合もある ● Arrange-Act-Assertを意識してテストコードを見つめ直そう ● t.Run()に愚直にArrange-Act-Assertで書くことで より理解しやすいテストコードにすることができることも 17

Slide 18

Slide 18 text

CREDITS: This presentation template was created by Slidesgo, and includes icons by Flaticon, and infographics & images by Freepik Thanks! Please keep this slide for attribution 18

Slide 19

Slide 19 text

参考⽂献 ● 3A - Arrange, Act, Assert - XP123 ○ https://xp123.com/3a-arrange-act-assert/ ○ Arrange-Act-Assert, 3Aの名付け親Bill Wake氏による解説 ● テスト駆動開発 ○ https://shop.ohmsha.co.jp/shopdetail/000000004967/ ○ 第19章にArrange-Act-Assertの紹介あり ● 単体テストの考え方/使い方 ○ https://book.mynavi.jp/ec/products/detail/id=134252 ● テーブル駆動テストと状態 ○ https://speakerdeck.com/hazumirr/teburuqu-dong-tesutotozhuang-tai ○ 同様の問題に対するアプローチのひとつ 19