Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Table-driven testing に縛られないGoのテストパターン

abekoh
June 08, 2024

Table-driven testing に縛られないGoのテストパターン

2024/06/08に開催されたGo Conference 2024のLTで発表した資料です。

https://gocon.jp/2024/sessions/19/

abekoh

June 08, 2024
Tweet

More Decks by abekoh

Other Decks in Programming

Transcript

  1. ⾃⼰紹介 2 • 名前: abekoh / Kotaro Abe • Twitter/X:

    @abekoh_bcky • Go歴: 出会って6年、業務で3年 • 所属: 株式会社MICIN ◦ 治験業務支援のSaaS「MiROHA」を開発しております ◦ バックエンドをGoで書いてます
  2. 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) } }) } }
  3. 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)
  4. 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)
  5. 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)
  6. 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)
  7. 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)
  8. 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
  9. 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を考慮してリファクタリング
  10. Table-driven testingはいつ使うべきか • テスト項目ごとにArrange, Act, Assertで行いたいことの差分が少ないと きはTable-driven testingは光る ◦ 同じ使い方で、大量の入力値・期待値を試したい場合には有効

    • テスト項目ごとに必要な準備が異なる、確認したいものにバリエーションが ある場合は、素直に諦めるのも手 ◦ 無理にTable-driven testingにするとパラメータが増大、ifが大量発生 →読みにくいテストコードに ◦ t.Run()で愚直にArrange-Act-Assertをまとめたほうがメリット大きい • そもそも、「状態を伴わない形に書き換えてみる」ことも 可能なら考える ◦ 関数型のエッセンスを取り入れてみる 15
  11. ちなみに…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
  12. 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
  13. 参考⽂献 • 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