スキップしていいテスト、スキップしてはいけないテスト 〜速さと信頼を兼ねたテストコードを構築する術〜 / Need for speed of testing in Perl5 Web Application.

B5582ce2d9959dfcff0384a07003e188?s=47 mackee
July 01, 2017

スキップしていいテスト、スキップしてはいけないテスト 〜速さと信頼を兼ねたテストコードを構築する術〜 / Need for speed of testing in Perl5 Web Application.

YAPC::Fukuoka 2017 HAKATA

B5582ce2d9959dfcff0384a07003e188?s=128

mackee

July 01, 2017
Tweet

Transcript

  1. スキップしていいテスト、スキップしては いけないテスト 速さと信頼を兼ねたテストコードを構築する術 YAPC::Fukuoka 2017 2017-07-01 macopy a.k.a @mackee_w 1

    / 58
  2. 誰 macopy/ マコピー github: @mackee twitter: @mackee_w PAUSE: MACOPY App::Prove::Plugin::MySQLPool

    language: 日本語, Perl5, Go kuiperbelt - WebSocket と普通のHTTP/1.1 を変換してくれる君 興味: リアルタイム通信, ワークフロー, ORM, MySQL Yokohama.pm Co-Organizer 最近: 3D プリンタ, altcoin 掘削 2 / 58
  3. 誰 macopy/ マコピー 所属: 面白法人カヤック ソーシャルゲーム事業部 ぼくらの甲子園ポケット サーバサイド 3 /

    58
  4. これは何の話 さてみなさんテストを書いていますか? そして継続的にテストを実行していますか? テストを書かない、またはCI を回さない理由としては以下のような物が考えられます。 - めんどくさい(わかる - テストの実行時間が長い(わかる -

    テストがコケたりコケなかったりする(わかる - テスト回してコケたらデプロイが出来ないルールとかだと デプロイがいつまで経ってもできない(わかる - そもそも人力テスト(QA など) やってるし、要らんやろ(わかる - コード書いた人間が想定する壊れそうな部分しか テスト書かれないんだから意味ないやろ(わかる というなどなど挙げられます。 このトークでは、未来へ継続していくWeb サービスアプリケーションを開発・運用する上で __ あえて__ テストをスキップする話や、テスト 4 / 58
  5. 要約すると 甲子園ポケットっていうソシャゲータイトルのテストの話 3 年もやっているとテストがクソ長くなってつらまる リリース当初からカバレッジC0 90% これだけテスト書かれまくっているとちょっとコード追加するだけでもテストコード書きたくなる 結果肥大化する フルスペックのソーシャルゲームのサーバ、そもそも複雑/ 機能多すぎ

    思考停止して便利関数によるintegration test を書きがち -> 遅くなる 参考: 行数 $ find lib -name \*.pm | xargs cat | wc -l 244691 $ find t -name \*.t | xargs cat | wc -l 269530 5 / 58
  6. 話すこと 現状あるテストを書き直したり仕組みを変えることで速くする、改善する話 「俺たちはこうしているよ」というドメインに特化したストーリー そのままは適用できないと思うのでヒントにしてもらえれば Perl5 のサーバサイドのWeb アプリケーションのテスト 話さないこと 一般的なテストの理論的な話 TDD

    とか フロントのテスト 既存のテストを消す話( 聞きたい) 影響範囲を頭いい感じで割り出して頭いい感じで必要なテストしかしない( すごい 6 / 58
  7. 昨日前夜祭の二次会で得た気付き 既に「テストを書こう」と言っていく時代は過ぎている 昔はテストがないからとにかく書こう、書きやすい環境を用意しよう、習慣をつけようという話になっていた 「良いテストとか、悪いテストがどうこうの前に、書かないとうまくならない」 perl な web application のためのテスト情報 -

    YAPC::Asia 2013 7 / 58
  8. 昨日前夜祭の二次会で得た気付き 今は「テストをいかにうまくやっていくか」 テストはみんな書いている さてみなさんテストを書いていますか? <- 愚問であった とにかく書いていたテストというのをうまく動かせるように、良いように変えていく必要がある 8 / 58

  9. とはいえテストしやすい環境の話です! サーバサイドのAPI 吐くサーバはテストしやすい 9 / 58

  10. 前提 10 / 58

  11. だいたい1 日1 回ぐらいサーバのアプリケーションはリリースする ガチャ イベント 機能追加 バグフィックス 調査でログ埋め込み etc... 11

    / 58
  12. 「更新しやすいのはサーバの方」 クライアントアプリの更新は難しいので変更されやすい部分や重要な部分はサーバの挙動やマスタデータに依存 するように作られている クライアント側開発者曰く「このアプリは独自API のブラウザ」 緊急でバグフィックスとなるとサーバの更新に頼ることになる 最近の実績 サーバ -> 1

    日1 回の更新 クライアント -> 1 ヶ月から半月に1 度の更新 12 / 58
  13. それでもリリース前にフルテストはかけたい フルテストに助けられた場面は何度もある 新機能を入れた -> デグレーション マスタデータのテスト サービスがちょっととまるだけでかなりの損失 ソーシャルゲームは普通のWeb アプリケーションと比べて1API リクエストあたりの価値が高い

    あとユーザは「安定している/ 安定していない」の観点があって、落ちまくってると信用を失ってユーザが離 れる 実際にでかいトラブルを起こしてそのまま終了したサービスもある 13 / 58
  14. 現状 is リリース頻度 xor フルテスト実行による安全性担保 14 / 58

  15. 両立させるためにはどちらかもしくは両方に改善/ 妥 協が必要 15 / 58

  16. 現状 リリース頻度 1 日1 回~2 回 ただしデプロイを避けたい時間帯がある GvG の時間帯 1

    日4 回 合計10 時間 イベントの定期実行バッチ時間 主に昼間の4 時間が狙い目 フルテスト実行にかかる時間 30 分 リリース初期は7 分ぐらいだったんだ…… 機能追加やらバグってたケースの追加とかで膨らむ デプロイフェーズ直前のテスト失敗は修正も含めて3 度ぐらいしか出来ない 16 / 58
  17. 現状 ほとんどの機能やマスタデータはtopic ブランチを経由して、master ブランチにマージされ、更にrelease ブラン チに入れている つまりtopic とmaster とrelease で3

    回フルテストが必要 master とrelease に入れるときのPullRequest には2 人以上のレビューが必要なルールにしている レビューも時間がかかるんや…… 17 / 58
  18. まずフルテスト30 分とかキレるやろ はい はい はい... 18 / 58

  19. 甲子園ポケットのテスト側の仕組み DB が絡むテストはTest::mysqld による本物のDB でやる しかしDB が絡まないテストなんてありますか??? うまいこと抽象化してたら下を分離できるんだけれどそうもいかないんや テストファイルごととかにポコポコDB が立つと寿命が足りない

    App::Prove::Plugin::MySQLPool によるワーカーごとのプール化 さらにgo-prove による並列化 image by Concurrency is not Parallelism 19 / 58
  20. 甲子園ポケットのテスト側の仕組み マスタデータはコード側のテストでは使わない 俺はここではマスタデータのテストをしたいのではなくコードのテストをしたいんや、テストの時に使うマ スタデータはテストに埋め込んでそれをINSERT するぜ 例えば1 人のユーザをデータの整合性を本番同様に保った状態で作成するのに必要なテーブル数は36 個 それを都度!テストケース内で!作る!遅い! 20

    / 58
  21. 甲子園ポケットのテスト側の仕組み DB のスキーマはワーカーごとにDDL を生成して食わせる サブテスト内のDB への変更はサブテスト終了時にクリーンアップする よく見るトランザクション使ってロールバックではなく全テーブルをTRUNCATE している 外側でトランザクションを貼るのはロールバックする場合のテストとかが出来なくて本番と同様の挙動を再 現できないため

    # apptest は内部でsubtest を実行しつつ前後でDB をクリーンアップしたりlogger のモック化やら時間の固定化などをしている apptest " なんとかのテスト" => sub { # create_user_model はユーザ登録に必要なマスタデータがなければ作ってその上で実際のユーザ登録メソッドを叩いてモデルを作る my $user_model = create_user_model; }; 21 / 58
  22. Q: だいたいDB のことしか言ってないやん A: だってDB 使う部分が結局重いんだもん 22 / 58

  23. さてどうするか 23 / 58

  24. よっしゃキャッシュや! 24 / 58

  25. 思い出されますね…… Web アプリケーションのキャッシュ戦略とそのパターン YAPC::Kansai 2017 25 / 58

  26. 何をどうキャッシュするか おさらい テストでのマスタデータの扱い マスタデータはコード側のテストでは使わない 俺はここではマスタデータのテストをしたいのではなくコードのテストをしたいんや、テストの時に使うマ スタデータはテストに埋め込んでそれをINSERT するぜ 例えば1 人のユーザをデータの整合性を本番同様に保った状態で作成するのに必要なテーブル数は36 個

    26 / 58
  27. テストコード内ではよくcreate_user_model が使われてい る こういうのが $ find t/ -name \*.t |

    xargs cat | grep 'create_user_model' | wc -l 3647 こんな感じ 36 個のテーブルのINSERT をしていると思うとゾッとしますね、はい # apptest は内部でsubtest を実行しつつ前後でDB をクリーンアップしたりlogger のモック化やら時間の固定化などをしている apptest " なんとかのテスト" => sub { # create_user_model はユーザ登録に必要なマスタデータがなければ作ってその上で実際のユーザ登録メソッドを叩いてモデルを作る my $user_model = create_user_model; }; 27 / 58
  28. Q: どうキャッシュする? しかし実際の登録メソッドを使ってほとんど本番と同じユーザが使えるがメリットであって、軽くするために似 て非なる物を作っても、ちゃんとしたテストにならないのではないか もちろんちゃんと抽象化して副作用を抑えればええやんという件はわかります、わかりますが つまり「ユーザ登録メソッドを使い」つつ「何らかの方法で作成を軽くする」 28 / 58

  29. A: 一気にINSERT する スクリプトでTest::mysqld を立ててそこにつなげてcreate_user_model を100 回繰り返しそれをmysqldump でsql ファイルに落とす テスト時のapptest

    時に一気にくわせる 29 / 58
  30. A: 一気にINSERT する my $mysqld = Test::mysqld->new( my_cnf => my_cnf(),

    ); my $sql_file = test_ddl_file(); $sql_file->remove; mysql_prepare($mysqld); mysql_override_config(); create_user_model for 1..$self->user_num; my $opts = mysqld_opts($mysqld); my $output_path = $sql_file->stringify; system(qq{mysqldump --no-create-info $opts test > $output_path}); 30 / 58
  31. キャッシュというかスナップショットでは? はい 31 / 58

  32. A: 一気にINSERT する 既存のテストでPK 指定でINSERT するやつとduplicate して死ぬ そもそもPK 指定でINSERT するな!(ID

    に依存したテスト書かない。。。) # apptest が呼ばれた時点でSQL ファイルが全部食われる apptest " ほげテスト" => sub { # このテーブルのid=1 のやつはすでに作ったSQL で作られているのでduplicate する create_dummy("FugaTable", id => 1); }; truncate_all という便利関数がせっかく作ったダミーデータを全て洗い流してくれるのでduplicate しない。 apptest " ほげテスト" => sub { truncate_all; # 全部消されるから便利…便利? create_dummy("FugaTable", id => 1); }; とりあえずコケたやつでこのケースであればこれで対処した 32 / 58
  33. 結果 30 分 -> 19 分 ほどに 他にも過程でテストファイルの分割などもおこなったりしています 33 /

    58
  34. 人はテストが早すぎると不安になる 人の手には負えない力を手に入れてしまったのか……俺は(意訳: 意味的に導入前と変わらないか怖い 34 / 58

  35. 同僚が件数を数えてくれました 35 / 58

  36. 発展 Test::mysqld のcopy_data_from というオプションがありそれを使えばもっと早くなる 参考: Test::mysqld のcopy_data_from でテストが更に捗る話 ただし利用しているApp::Prove::Plugin::MySQLPool とgo-prove

    は両方ともcopy_data_from 未対応です 前者は僕がメンテナなんだからさぼっているだけですm(_ _)m 36 / 58
  37. 更に現在は10 分弱でフルテストが回っている 37 / 58

  38. どうやったか? 38 / 58

  39. インスタンスサイズの拡大と並列度を増やす 過去: r3.8xlarge or cc2.8xlarge 32 CPU prove -j 16

    現在: i3.16xlarge やm4.16xlarge など64 CPU のものから安いやつを選ぶ prove -j 80 39 / 58
  40. 結局は金じゃ photo by 26 歳になりました - 職質アンチパターン 40 / 58

  41. 次の改善 -> 同じテストを何度もやる問題 topic -> master -> release... topic までは分かる、master

    とrelease はhotfix がない限りはだいたい一緒 都度10 分かかるのはどうか 41 / 58
  42. 疑問: 同じコードで同じテストを何度もやるのは意 味があるのか? 時々コケるテストを検知する 確率など 最近あったのはコールド勝ちで早期終了することが想定されずに9 回まで試合が回るかみたいなテストが確 率でコケていました 時々コケるテストがある場合は時々コケないように直す テストコードではなく本体を直さなければ行けないケースも稀にある

    別に時々コケるテストは同じコードで回す意味は厳密にはあるけれど、ほとんどのケースでは他のブランチでも 時々コケる( 経験上) ほとんどの場合、同一内容のブランチで何度も走らせる必要はないのでは? 42 / 58
  43. 何度も回しても意味が無いのでスキップします 43 / 58

  44. 何度も回しても意味が無いのでスキップします 解説 マージされるとgit のコミットハッシュが変わるので同一内容かどうかはわからない なのでmaster やmaster^ と比較してgit diff して差分がないようだったら、github のコミットステータスを見てテ

    ストをスキップさせる その他の解法 prove のstate を使うとか prove についてのおさらい 44 / 58
  45. その他テストを書くときのTIPS 45 / 58

  46. Table Driven Test TableDrivenTests - github.com/golang/go Go で知った話だけれどPerl でも出来る Example:

    note " 現在の連続ヒット数を計算する関数のテスト"; my @cases = ( { scores => [hit_single(), hit_double()], expect => 2, note => "2 連続ヒット" }, { scores => [hit_single(), bounder_out()], expect => 0, note => " ヒット後にゴロアウト" }, { scores => [bounder_out(), hit_single()], expect => 1, note => " ゴロアウト語にヒット" }, ); for my $case (@cases) { subtest $case->{note} => sub { my $inning = MyApp::Inning->new(scores => $case->{scores}); is $inning->hitting_streak, $note->{expect}; }; } 46 / 58
  47. Table Driven Test 境界値チェックや一つのメソッドが複数のケースで様々な値を出す時に使える 野球のコードを書いていると違う場面の同じ判定の可否みたいなのがいっぱいでてくる テスト中の重複コードが減る コードではなくデータが主体となる テストで見たいのはテストコードの良し悪しではなく何をテストしたいのか、やり方などが見たい データ主 体で良い

    副作用が少なくなる傾向にある( と思っている) 47 / 58
  48. マスタデータのテストの記述 コード以外にもゲームを決めるマスタデータと呼ばれるものが大量にある これをレベルデザイナーと呼ばれる人たちがGoogle スプレッドシート経由で入力している アプリケーションの挙動を決める -> コードと同じでは? コードと同じということはテストが必要ですね! 48 /

    58
  49. かつては地道に書いていた MyApp::DBImporter->from_csv([qw/item item_effect shop_item/]); my $item_rs = MyApp::DB::Item->search; while (my

    $item = $item_rs->next) { ok $item->item_effect, " リレーション先のitem_effect があるかどうか"; if ($item->is_buyable) { ok $item->shop_item, " 購入可能なアイテムの場合、有効なショップマスタにアイテムが有るか"; } } 49 / 58
  50. 色々問題がある 遅い テーブルごとに読み込みとDB 問い合わせ 自然にN+1 になる なぜ遅いと辛いか レベルデザインはイテレーションが必要 入れてみないと合ってるかどうかわからない(GAS である程度やってるっぽいが)

    50 / 58
  51. よっしゃキャッシュや! 51 / 58

  52. しかし人類にはキャッシュは難しいので工夫が必要 そもそも整合性保たれているかとか、値域におさまっているかとかそういうテストばっかり 同じようなことをコピペでやっているということはリファクタリングチャンス 同じようなことをやっているということは、オレオレDSL にできるのでは? DSL に逃したら内部的にキャッシュしてあげれば誰でも安全かつ速いマスタデータのテストが書けるのでは? 52 / 58

  53. というわけでこんな感じになりました subtest "dungeon" => sub { # dungeon.event_id と同じdungeon_floor.event_id があるかどうか

    has_relation "dungeon", "dungeon_floor", id => "dungeon_id"; # dungeon_floor.battle_times が1 から10 回以内か expect_row "dungeon_floor" => "battle_times", sub { my $battle_times = shift; return 1 <= $battle_times && $battle_times <= 10; }; # dungeon_floor.dungeon_floor_monster_odds に対応する # dungeon_floor_monster.odds があるかどうか has_relation "dungeon_floor" => "dungeon_floor_monster", dungeon_floor_monster_odds => "odds"; }; 53 / 58
  54. DSL にするといいこと 宣言的に書けるようになった プログラマ以外でもどういう制約があるか、ルールを知れば分かるようになった マスタデータのテストを書く負荷が減った DB を使わなくなった DB を使うと素で書くときは便利だが重くなる 宣言的にすることでオンメモリで全部処理するようにした

    54 / 58
  55. Mock/Stub の話 Test::Mock::Guard 使っている 速さの面で言うとstub すると重い部分をスキップできる可能性がある my $g = mock_guard("HeavyModel"

    => { heavy_method => sub { return 1; }, }); ok CallerModel->new(heavy_model => HeavyModel->new)->call_heavy_method; しかし果たしてそれはテストになっているのか? stub はテストされるコードとテストコードを曖昧にする 本来テストしたい挙動が歪められる可能性がある テストを書く側はstub するメソッドなどがコード側で呼ばれていることを知っていないとstub 出来ない ブラックボックステストが出来ない 55 / 58
  56. Mock/Stub の話 速さは正義であるが速さにかまけて正確性を歪めてはならない モック用オブジェクトをDI 出来る構造にしとけばメソッドの単体テストとしては機能するかも テストの意味的には外部から食ったオブジェクトを使って何かをやるやつ Test::Mock::Guard する場合は内部の実装を変えてしまうことになるので意味が違う 切り分ける、刻むというのはテストでもデプロイでも大事で、切り分けてテストすれば関係者が少なくなってテ ストは速くなる

    昔はそうなっていなかったが最近は意識して関係者を少なくするようにしている 56 / 58
  57. N+1 を防ぐためにテストのときだけメソッドモディフ ァイア的なことをしてprefetch されてなければ死ぬ my $orig = MyApp::Model::Player->can("player_info_with_more_info"); my $guard

    = mock_guard "MyApp::Model::Player" => { player_info_with_more_info => sub { my ($self) = @_; if ( !exists $self->player->{_relationship_data}{player_more_info} && $self->player->in_storage ) { confess "method called without prefetching player_more_info"; } $orig->(@_); }, }; 57 / 58
  58. まとめ CI を導入してテストを書く習慣がついたらテストを早くしよう 30 分テストにかかっていたら一日に48 回しかテストできずデプロイ回数はそれ以下である テストは本番ではないので無茶が出来る 本番に突っ込むとヤバゲなテクをテストでは思う存分使うことが出来る 技術的好奇心が満たされる テストを早くすることはフローの改善につながる

    面白法人カヤックでは面白おかしく真面目にテストを早くしてくれる仲間を募集しております 58 / 58