$30 off During Our Annual Pro Sale. View Details »

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

mackee
July 01, 2017

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

YAPC::Fukuoka 2017 HAKATA

mackee

July 01, 2017
Tweet

More Decks by mackee

Other Decks in Programming

Transcript

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

    View Slide


  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

    View Slide


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

    View Slide

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

    View Slide

  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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  10. 前提
    10 / 58

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  15. 両立させるためにはどちらかもしくは両方に改善/

    協が必要
    15 / 58

    View Slide

  16. 現状
    リリース頻度 1
    日1
    回~2

    ただしデプロイを避けたい時間帯がある
    GvG
    の時間帯 1
    日4
    回 合計10
    時間
    イベントの定期実行バッチ時間
    主に昼間の4
    時間が狙い目
    フルテスト実行にかかる時間 30

    リリース初期は7
    分ぐらいだったんだ……
    機能追加やらバグってたケースの追加とかで膨らむ
    デプロイフェーズ直前のテスト失敗は修正も含めて3
    度ぐらいしか出来ない
    16 / 58

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    それを都度!テストケース内で!作る!遅い!
    20 / 58

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    26 / 58

    View Slide

  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

    View Slide

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

    View Slide

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

    View Slide

  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

    View Slide

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

    View Slide

  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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  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

    View Slide

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

    View Slide

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

    View Slide

  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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  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

    View Slide

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

    View Slide

  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

    View Slide

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

    View Slide

  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

    View Slide

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

    View Slide