Slide 1

Slide 1 text

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

Slide 2

Slide 2 text

誰 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

Slide 3

Slide 3 text

誰 macopy/ マコピー 所属: 面白法人カヤック ソーシャルゲーム事業部 ぼくらの甲子園ポケット サーバサイド 3 / 58

Slide 4

Slide 4 text

これは何の話 さてみなさんテストを書いていますか? そして継続的にテストを実行していますか? テストを書かない、またはCI を回さない理由としては以下のような物が考えられます。 - めんどくさい(わかる - テストの実行時間が長い(わかる - テストがコケたりコケなかったりする(わかる - テスト回してコケたらデプロイが出来ないルールとかだと デプロイがいつまで経ってもできない(わかる - そもそも人力テスト(QA など) やってるし、要らんやろ(わかる - コード書いた人間が想定する壊れそうな部分しか テスト書かれないんだから意味ないやろ(わかる というなどなど挙げられます。 このトークでは、未来へ継続していくWeb サービスアプリケーションを開発・運用する上で __ あえて__ テストをスキップする話や、テスト 4 / 58

Slide 5

Slide 5 text

要約すると 甲子園ポケットっていうソシャゲータイトルのテストの話 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

Slide 6

Slide 6 text

話すこと 現状あるテストを書き直したり仕組みを変えることで速くする、改善する話 「俺たちはこうしているよ」というドメインに特化したストーリー そのままは適用できないと思うのでヒントにしてもらえれば Perl5 のサーバサイドのWeb アプリケーションのテスト 話さないこと 一般的なテストの理論的な話 TDD とか フロントのテスト 既存のテストを消す話( 聞きたい) 影響範囲を頭いい感じで割り出して頭いい感じで必要なテストしかしない( すごい 6 / 58

Slide 7

Slide 7 text

昨日前夜祭の二次会で得た気付き 既に「テストを書こう」と言っていく時代は過ぎている 昔はテストがないからとにかく書こう、書きやすい環境を用意しよう、習慣をつけようという話になっていた 「良いテストとか、悪いテストがどうこうの前に、書かないとうまくならない」 perl な web application のためのテスト情報 - YAPC::Asia 2013 7 / 58

Slide 8

Slide 8 text

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

Slide 9

Slide 9 text

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

Slide 10

Slide 10 text

前提 10 / 58

Slide 11

Slide 11 text

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

Slide 12

Slide 12 text

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

Slide 13

Slide 13 text

それでもリリース前にフルテストはかけたい フルテストに助けられた場面は何度もある 新機能を入れた -> デグレーション マスタデータのテスト サービスがちょっととまるだけでかなりの損失 ソーシャルゲームは普通のWeb アプリケーションと比べて1API リクエストあたりの価値が高い あとユーザは「安定している/ 安定していない」の観点があって、落ちまくってると信用を失ってユーザが離 れる 実際にでかいトラブルを起こしてそのまま終了したサービスもある 13 / 58

Slide 14

Slide 14 text

現状 is リリース頻度 xor フルテスト実行による安全性担保 14 / 58

Slide 15

Slide 15 text

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

Slide 16

Slide 16 text

現状 リリース頻度 1 日1 回~2 回 ただしデプロイを避けたい時間帯がある GvG の時間帯 1 日4 回 合計10 時間 イベントの定期実行バッチ時間 主に昼間の4 時間が狙い目 フルテスト実行にかかる時間 30 分 リリース初期は7 分ぐらいだったんだ…… 機能追加やらバグってたケースの追加とかで膨らむ デプロイフェーズ直前のテスト失敗は修正も含めて3 度ぐらいしか出来ない 16 / 58

Slide 17

Slide 17 text

現状 ほとんどの機能やマスタデータはtopic ブランチを経由して、master ブランチにマージされ、更にrelease ブラン チに入れている つまりtopic とmaster とrelease で3 回フルテストが必要 master とrelease に入れるときのPullRequest には2 人以上のレビューが必要なルールにしている レビューも時間がかかるんや…… 17 / 58

Slide 18

Slide 18 text

まずフルテスト30 分とかキレるやろ はい はい はい... 18 / 58

Slide 19

Slide 19 text

甲子園ポケットのテスト側の仕組み DB が絡むテストはTest::mysqld による本物のDB でやる しかしDB が絡まないテストなんてありますか??? うまいこと抽象化してたら下を分離できるんだけれどそうもいかないんや テストファイルごととかにポコポコDB が立つと寿命が足りない App::Prove::Plugin::MySQLPool によるワーカーごとのプール化 さらにgo-prove による並列化 image by Concurrency is not Parallelism 19 / 58

Slide 20

Slide 20 text

甲子園ポケットのテスト側の仕組み マスタデータはコード側のテストでは使わない 俺はここではマスタデータのテストをしたいのではなくコードのテストをしたいんや、テストの時に使うマ スタデータはテストに埋め込んでそれをINSERT するぜ 例えば1 人のユーザをデータの整合性を本番同様に保った状態で作成するのに必要なテーブル数は36 個 それを都度!テストケース内で!作る!遅い! 20 / 58

Slide 21

Slide 21 text

甲子園ポケットのテスト側の仕組み DB のスキーマはワーカーごとにDDL を生成して食わせる サブテスト内のDB への変更はサブテスト終了時にクリーンアップする よく見るトランザクション使ってロールバックではなく全テーブルをTRUNCATE している 外側でトランザクションを貼るのはロールバックする場合のテストとかが出来なくて本番と同様の挙動を再 現できないため # apptest は内部でsubtest を実行しつつ前後でDB をクリーンアップしたりlogger のモック化やら時間の固定化などをしている apptest " なんとかのテスト" => sub { # create_user_model はユーザ登録に必要なマスタデータがなければ作ってその上で実際のユーザ登録メソッドを叩いてモデルを作る my $user_model = create_user_model; }; 21 / 58

Slide 22

Slide 22 text

Q: だいたいDB のことしか言ってないやん A: だってDB 使う部分が結局重いんだもん 22 / 58

Slide 23

Slide 23 text

さてどうするか 23 / 58

Slide 24

Slide 24 text

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

Slide 25

Slide 25 text

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

Slide 26

Slide 26 text

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

Slide 27

Slide 27 text

テストコード内ではよく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

Slide 28

Slide 28 text

Q: どうキャッシュする? しかし実際の登録メソッドを使ってほとんど本番と同じユーザが使えるがメリットであって、軽くするために似 て非なる物を作っても、ちゃんとしたテストにならないのではないか もちろんちゃんと抽象化して副作用を抑えればええやんという件はわかります、わかりますが つまり「ユーザ登録メソッドを使い」つつ「何らかの方法で作成を軽くする」 28 / 58

Slide 29

Slide 29 text

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

Slide 30

Slide 30 text

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

Slide 31

Slide 31 text

キャッシュというかスナップショットでは? はい 31 / 58

Slide 32

Slide 32 text

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

Slide 33

Slide 33 text

結果 30 分 -> 19 分 ほどに 他にも過程でテストファイルの分割などもおこなったりしています 33 / 58

Slide 34

Slide 34 text

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

Slide 35

Slide 35 text

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

Slide 36

Slide 36 text

発展 Test::mysqld のcopy_data_from というオプションがありそれを使えばもっと早くなる 参考: Test::mysqld のcopy_data_from でテストが更に捗る話 ただし利用しているApp::Prove::Plugin::MySQLPool とgo-prove は両方ともcopy_data_from 未対応です 前者は僕がメンテナなんだからさぼっているだけですm(_ _)m 36 / 58

Slide 37

Slide 37 text

更に現在は10 分弱でフルテストが回っている 37 / 58

Slide 38

Slide 38 text

どうやったか? 38 / 58

Slide 39

Slide 39 text

インスタンスサイズの拡大と並列度を増やす 過去: r3.8xlarge or cc2.8xlarge 32 CPU prove -j 16 現在: i3.16xlarge やm4.16xlarge など64 CPU のものから安いやつを選ぶ prove -j 80 39 / 58

Slide 40

Slide 40 text

結局は金じゃ photo by 26 歳になりました - 職質アンチパターン 40 / 58

Slide 41

Slide 41 text

次の改善 -> 同じテストを何度もやる問題 topic -> master -> release... topic までは分かる、master とrelease はhotfix がない限りはだいたい一緒 都度10 分かかるのはどうか 41 / 58

Slide 42

Slide 42 text

疑問: 同じコードで同じテストを何度もやるのは意 味があるのか? 時々コケるテストを検知する 確率など 最近あったのはコールド勝ちで早期終了することが想定されずに9 回まで試合が回るかみたいなテストが確 率でコケていました 時々コケるテストがある場合は時々コケないように直す テストコードではなく本体を直さなければ行けないケースも稀にある 別に時々コケるテストは同じコードで回す意味は厳密にはあるけれど、ほとんどのケースでは他のブランチでも 時々コケる( 経験上) ほとんどの場合、同一内容のブランチで何度も走らせる必要はないのでは? 42 / 58

Slide 43

Slide 43 text

何度も回しても意味が無いのでスキップします 43 / 58

Slide 44

Slide 44 text

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

Slide 45

Slide 45 text

その他テストを書くときのTIPS 45 / 58

Slide 46

Slide 46 text

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

Slide 47

Slide 47 text

Table Driven Test 境界値チェックや一つのメソッドが複数のケースで様々な値を出す時に使える 野球のコードを書いていると違う場面の同じ判定の可否みたいなのがいっぱいでてくる テスト中の重複コードが減る コードではなくデータが主体となる テストで見たいのはテストコードの良し悪しではなく何をテストしたいのか、やり方などが見たい データ主 体で良い 副作用が少なくなる傾向にある( と思っている) 47 / 58

Slide 48

Slide 48 text

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

Slide 49

Slide 49 text

かつては地道に書いていた 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

Slide 50

Slide 50 text

色々問題がある 遅い テーブルごとに読み込みとDB 問い合わせ 自然にN+1 になる なぜ遅いと辛いか レベルデザインはイテレーションが必要 入れてみないと合ってるかどうかわからない(GAS である程度やってるっぽいが) 50 / 58

Slide 51

Slide 51 text

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

Slide 52

Slide 52 text

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

Slide 53

Slide 53 text

というわけでこんな感じになりました 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

Slide 54

Slide 54 text

DSL にするといいこと 宣言的に書けるようになった プログラマ以外でもどういう制約があるか、ルールを知れば分かるようになった マスタデータのテストを書く負荷が減った DB を使わなくなった DB を使うと素で書くときは便利だが重くなる 宣言的にすることでオンメモリで全部処理するようにした 54 / 58

Slide 55

Slide 55 text

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

Slide 56

Slide 56 text

Mock/Stub の話 速さは正義であるが速さにかまけて正確性を歪めてはならない モック用オブジェクトをDI 出来る構造にしとけばメソッドの単体テストとしては機能するかも テストの意味的には外部から食ったオブジェクトを使って何かをやるやつ Test::Mock::Guard する場合は内部の実装を変えてしまうことになるので意味が違う 切り分ける、刻むというのはテストでもデプロイでも大事で、切り分けてテストすれば関係者が少なくなってテ ストは速くなる 昔はそうなっていなかったが最近は意識して関係者を少なくするようにしている 56 / 58

Slide 57

Slide 57 text

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

Slide 58

Slide 58 text

まとめ CI を導入してテストを書く習慣がついたらテストを早くしよう 30 分テストにかかっていたら一日に48 回しかテストできずデプロイ回数はそれ以下である テストは本番ではないので無茶が出来る 本番に突っ込むとヤバゲなテクをテストでは思う存分使うことが出来る 技術的好奇心が満たされる テストを早くすることはフローの改善につながる 面白法人カヤックでは面白おかしく真面目にテストを早くしてくれる仲間を募集しております 58 / 58