YAPC::Fukuoka 2017 HAKATA
スキップしていいテスト、スキップしてはいけないテスト速さと信頼を兼ねたテストコードを構築する術YAPC::Fukuoka 2017 2017-07-01 macopy a.k.a @mackee_w1 / 58
View Slide
誰macopy/マコピーgithub: @mackeetwitter: @mackee_wPAUSE: MACOPY App::Prove::Plugin::MySQLPoollanguage:日本語, Perl5, Gokuiperbelt - WebSocketと普通のHTTP/1.1を変換してくれる君興味:リアルタイム通信,ワークフロー, ORM, MySQLYokohama.pm Co-Organizer最近: 3Dプリンタ, altcoin掘削2 / 58
誰macopy/マコピー所属:面白法人カヤックソーシャルゲーム事業部 ぼくらの甲子園ポケット サーバサイド3 / 58
これは何の話さてみなさんテストを書いていますか?そして継続的にテストを実行していますか?テストを書かない、またはCIを回さない理由としては以下のような物が考えられます。-めんどくさい(わかる-テストの実行時間が長い(わかる-テストがコケたりコケなかったりする(わかる-テスト回してコケたらデプロイが出来ないルールとかだとデプロイがいつまで経ってもできない(わかる-そもそも人力テスト(QAなど)やってるし、要らんやろ(わかる-コード書いた人間が想定する壊れそうな部分しかテスト書かれないんだから意味ないやろ(わかるというなどなど挙げられます。このトークでは、未来へ継続していくWebサービスアプリケーションを開発・運用する上で __あえて__テストをスキップする話や、テスト4 / 58
要約すると甲子園ポケットっていうソシャゲータイトルのテストの話3年もやっているとテストがクソ長くなってつらまるリリース当初からカバレッジC0 90%これだけテスト書かれまくっているとちょっとコード追加するだけでもテストコード書きたくなる結果肥大化するフルスペックのソーシャルゲームのサーバ、そもそも複雑/機能多すぎ思考停止して便利関数によるintegration testを書きがち ->遅くなる参考:行数$ find lib -name \*.pm | xargs cat | wc -l244691$ find t -name \*.t | xargs cat | wc -l2695305 / 58
話すこと現状あるテストを書き直したり仕組みを変えることで速くする、改善する話「俺たちはこうしているよ」というドメインに特化したストーリーそのままは適用できないと思うのでヒントにしてもらえればPerl5のサーバサイドのWebアプリケーションのテスト話さないこと一般的なテストの理論的な話TDDとかフロントのテスト既存のテストを消す話(聞きたい)影響範囲を頭いい感じで割り出して頭いい感じで必要なテストしかしない(すごい6 / 58
昨日前夜祭の二次会で得た気付き既に「テストを書こう」と言っていく時代は過ぎている昔はテストがないからとにかく書こう、書きやすい環境を用意しよう、習慣をつけようという話になっていた「良いテストとか、悪いテストがどうこうの前に、書かないとうまくならない」perlな web applicationのためのテスト情報 - YAPC::Asia 20137 / 58
昨日前夜祭の二次会で得た気付き今は「テストをいかにうまくやっていくか」テストはみんな書いているさてみなさんテストを書いていますか? <-愚問であったとにかく書いていたテストというのをうまく動かせるように、良いように変えていく必要がある8 / 58
とはいえテストしやすい環境の話です!サーバサイドのAPI吐くサーバはテストしやすい9 / 58
前提10 / 58
だいたい1日1回ぐらいサーバのアプリケーションはリリースするガチャイベント機能追加バグフィックス調査でログ埋め込みetc...11 / 58
「更新しやすいのはサーバの方」クライアントアプリの更新は難しいので変更されやすい部分や重要な部分はサーバの挙動やマスタデータに依存するように作られているクライアント側開発者曰く「このアプリは独自APIのブラウザ」緊急でバグフィックスとなるとサーバの更新に頼ることになる最近の実績サーバ -> 1日1回の更新クライアント -> 1ヶ月から半月に1度の更新12 / 58
それでもリリース前にフルテストはかけたいフルテストに助けられた場面は何度もある新機能を入れた ->デグレーションマスタデータのテストサービスがちょっととまるだけでかなりの損失ソーシャルゲームは普通のWebアプリケーションと比べて1APIリクエストあたりの価値が高いあとユーザは「安定している/安定していない」の観点があって、落ちまくってると信用を失ってユーザが離れる実際にでかいトラブルを起こしてそのまま終了したサービスもある13 / 58
現状 isリリース頻度xorフルテスト実行による安全性担保14 / 58
両立させるためにはどちらかもしくは両方に改善/妥協が必要15 / 58
現状リリース頻度 1日1回~2回ただしデプロイを避けたい時間帯があるGvGの時間帯 1日4回 合計10時間イベントの定期実行バッチ時間主に昼間の4時間が狙い目フルテスト実行にかかる時間 30分リリース初期は7分ぐらいだったんだ……機能追加やらバグってたケースの追加とかで膨らむデプロイフェーズ直前のテスト失敗は修正も含めて3度ぐらいしか出来ない16 / 58
現状ほとんどの機能やマスタデータはtopicブランチを経由して、masterブランチにマージされ、更にreleaseブランチに入れているつまりtopicとmasterとreleaseで3回フルテストが必要masterとreleaseに入れるときのPullRequestには2人以上のレビューが必要なルールにしているレビューも時間がかかるんや……17 / 58
まずフルテスト30分とかキレるやろはいはいはい...18 / 58
甲子園ポケットのテスト側の仕組みDBが絡むテストはTest::mysqldによる本物のDBでやるしかしDBが絡まないテストなんてありますか???うまいこと抽象化してたら下を分離できるんだけれどそうもいかないんやテストファイルごととかにポコポコDBが立つと寿命が足りないApp::Prove::Plugin::MySQLPoolによるワーカーごとのプール化さらにgo-proveによる並列化image by Concurrency is not Parallelism19 / 58
甲子園ポケットのテスト側の仕組みマスタデータはコード側のテストでは使わない俺はここではマスタデータのテストをしたいのではなくコードのテストをしたいんや、テストの時に使うマスタデータはテストに埋め込んでそれをINSERTするぜ例えば1人のユーザをデータの整合性を本番同様に保った状態で作成するのに必要なテーブル数は36個それを都度!テストケース内で!作る!遅い!20 / 58
甲子園ポケットのテスト側の仕組みDBのスキーマはワーカーごとにDDLを生成して食わせるサブテスト内のDBへの変更はサブテスト終了時にクリーンアップするよく見るトランザクション使ってロールバックではなく全テーブルをTRUNCATEしている外側でトランザクションを貼るのはロールバックする場合のテストとかが出来なくて本番と同様の挙動を再現できないため# apptestは内部でsubtestを実行しつつ前後でDBをクリーンアップしたりloggerのモック化やら時間の固定化などをしているapptest "なんとかのテスト" => sub {# create_user_modelはユーザ登録に必要なマスタデータがなければ作ってその上で実際のユーザ登録メソッドを叩いてモデルを作るmy $user_model = create_user_model;};21 / 58
Q:だいたいDBのことしか言ってないやんA:だってDB使う部分が結局重いんだもん22 / 58
さてどうするか23 / 58
よっしゃキャッシュや!24 / 58
思い出されますね……Webアプリケーションのキャッシュ戦略とそのパターン YAPC::Kansai 201725 / 58
何をどうキャッシュするかおさらい テストでのマスタデータの扱いマスタデータはコード側のテストでは使わない俺はここではマスタデータのテストをしたいのではなくコードのテストをしたいんや、テストの時に使うマスタデータはテストに埋め込んでそれをINSERTするぜ例えば1人のユーザをデータの整合性を本番同様に保った状態で作成するのに必要なテーブル数は36個26 / 58
テストコード内ではよくcreate_user_modelが使われているこういうのが$ find t/ -name \*.t | xargs cat | grep 'create_user_model' | wc -l3647こんな感じ36個のテーブルのINSERTをしていると思うとゾッとしますね、はい# apptestは内部でsubtestを実行しつつ前後でDBをクリーンアップしたりloggerのモック化やら時間の固定化などをしているapptest "なんとかのテスト" => sub {# create_user_modelはユーザ登録に必要なマスタデータがなければ作ってその上で実際のユーザ登録メソッドを叩いてモデルを作るmy $user_model = create_user_model;};27 / 58
Q:どうキャッシュする?しかし実際の登録メソッドを使ってほとんど本番と同じユーザが使えるがメリットであって、軽くするために似て非なる物を作っても、ちゃんとしたテストにならないのではないかもちろんちゃんと抽象化して副作用を抑えればええやんという件はわかります、わかりますがつまり「ユーザ登録メソッドを使い」つつ「何らかの方法で作成を軽くする」28 / 58
A:一気にINSERTするスクリプトでTest::mysqldを立ててそこにつなげてcreate_user_modelを100回繰り返しそれをmysqldumpでsqlファイルに落とすテスト時のapptest時に一気にくわせる29 / 58
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 / 58
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
結果 30分 -> 19分 ほどに他にも過程でテストファイルの分割などもおこなったりしています33 / 58
人はテストが早すぎると不安になる人の手には負えない力を手に入れてしまったのか……俺は(意訳:意味的に導入前と変わらないか怖い34 / 58
同僚が件数を数えてくれました35 / 58
発展Test::mysqldのcopy_data_fromというオプションがありそれを使えばもっと早くなる参考: Test::mysqldのcopy_data_fromでテストが更に捗る話ただし利用しているApp::Prove::Plugin::MySQLPoolとgo-proveは両方ともcopy_data_from未対応です前者は僕がメンテナなんだからさぼっているだけですm(_ _)m36 / 58
更に現在は10分弱でフルテストが回っている37 / 58
どうやったか?38 / 58
インスタンスサイズの拡大と並列度を増やす過去: r3.8xlarge or cc2.8xlarge32 CPUprove -j 16現在: i3.16xlargeやm4.16xlargeなど64 CPUのものから安いやつを選ぶprove -j 8039 / 58
結局は金じゃphoto by 26歳になりました -職質アンチパターン40 / 58
次の改善 ->同じテストを何度もやる問題topic -> master -> release...topicまでは分かる、masterとreleaseはhotfixがない限りはだいたい一緒都度10分かかるのはどうか41 / 58
疑問:同じコードで同じテストを何度もやるのは意味があるのか?時々コケるテストを検知する確率など最近あったのはコールド勝ちで早期終了することが想定されずに9回まで試合が回るかみたいなテストが確率でコケていました時々コケるテストがある場合は時々コケないように直すテストコードではなく本体を直さなければ行けないケースも稀にある別に時々コケるテストは同じコードで回す意味は厳密にはあるけれど、ほとんどのケースでは他のブランチでも時々コケる(経験上)ほとんどの場合、同一内容のブランチで何度も走らせる必要はないのでは?42 / 58
何度も回しても意味が無いのでスキップします43 / 58
何度も回しても意味が無いのでスキップします解説マージされるとgitのコミットハッシュが変わるので同一内容かどうかはわからないなのでmasterやmaster^と比較してgit diffして差分がないようだったら、githubのコミットステータスを見てテストをスキップさせるその他の解法proveのstateを使うとかproveについてのおさらい44 / 58
その他テストを書くときのTIPS45 / 58
Table Driven TestTableDrivenTests - github.com/golang/goGoで知った話だけれど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
Table Driven Test境界値チェックや一つのメソッドが複数のケースで様々な値を出す時に使える野球のコードを書いていると違う場面の同じ判定の可否みたいなのがいっぱいでてくるテスト中の重複コードが減るコードではなくデータが主体となるテストで見たいのはテストコードの良し悪しではなく何をテストしたいのか、やり方などが見たい データ主体で良い副作用が少なくなる傾向にある(と思っている)47 / 58
マスタデータのテストの記述コード以外にもゲームを決めるマスタデータと呼ばれるものが大量にあるこれをレベルデザイナーと呼ばれる人たちがGoogleスプレッドシート経由で入力しているアプリケーションの挙動を決める ->コードと同じでは?コードと同じということはテストが必要ですね!48 / 58
かつては地道に書いていた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
色々問題がある遅いテーブルごとに読み込みとDB問い合わせ自然にN+1になるなぜ遅いと辛いかレベルデザインはイテレーションが必要入れてみないと合ってるかどうかわからない(GASである程度やってるっぽいが)50 / 58
よっしゃキャッシュや!51 / 58
しかし人類にはキャッシュは難しいので工夫が必要そもそも整合性保たれているかとか、値域におさまっているかとかそういうテストばっかり同じようなことをコピペでやっているということはリファクタリングチャンス同じようなことをやっているということは、オレオレDSLにできるのでは?DSLに逃したら内部的にキャッシュしてあげれば誰でも安全かつ速いマスタデータのテストが書けるのでは?52 / 58
というわけでこんな感じになりました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
DSLにするといいこと宣言的に書けるようになったプログラマ以外でもどういう制約があるか、ルールを知れば分かるようになったマスタデータのテストを書く負荷が減ったDBを使わなくなったDBを使うと素で書くときは便利だが重くなる宣言的にすることでオンメモリで全部処理するようにした54 / 58
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
Mock/Stubの話速さは正義であるが速さにかまけて正確性を歪めてはならないモック用オブジェクトをDI出来る構造にしとけばメソッドの単体テストとしては機能するかもテストの意味的には外部から食ったオブジェクトを使って何かをやるやつTest::Mock::Guardする場合は内部の実装を変えてしまうことになるので意味が違う切り分ける、刻むというのはテストでもデプロイでも大事で、切り分けてテストすれば関係者が少なくなってテストは速くなる昔はそうなっていなかったが最近は意識して関係者を少なくするようにしている56 / 58
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
まとめCIを導入してテストを書く習慣がついたらテストを早くしよう30分テストにかかっていたら一日に48回しかテストできずデプロイ回数はそれ以下であるテストは本番ではないので無茶が出来る本番に突っ込むとヤバゲなテクをテストでは思う存分使うことが出来る技術的好奇心が満たされるテストを早くすることはフローの改善につながる面白法人カヤックでは面白おかしく真面目にテストを早くしてくれる仲間を募集しております58 / 58