会員事業部サービス開発グループ長の村田です。
私は2015年1月から会員事業部でサービス開発エンジニアをやっていますが、2014年4月までは技術部開発基盤グループで Web サービス開発を加速させる様々な取り組みを実施していました。本稿では、開発基盤グループ時代に私が取り組んだ開発者テストの失敗を追跡しやすくする取り組みについて説明します。
クックパッドの Web サービス開発と CI
クックパッドのサービス開発は、大きくても5名くらいの小さなチームが一つの機能を担当します。しかし、多数のチームが1つの大きな Rails アプリケーションを同時に変更するのが特徴です *1。
Web サービス開発を加速する工夫には様々な方向性が考えられますが、ここでは、クックパッドのようなスタイルでの Web サービス開発を加速するために開発者テストを何如に円滑にするかを考えます。
図: オムキンス
クックパッドではオムキンスと呼ばれる CI システムがあり、CI でのテストをパスしたリビジョンだけがデプロイを許されます。
サービス開発はデプロイしてからが本番です。開発中のサービスをユーザに出して、使われ方を分析して改善していくサイクルを何度も回すには、何度もデプロイする必要があります。 CI でのテストが円滑に成功し続けることが高速なサービス開発の肝です。
コミットと CI の監視
クックパッドでは、開発者は基本的に自分のコミットを自分で master ブランチにマージし、デプロイも自分でやります。そのため、開発者は自分のコミットがマージされた後に CI で走るテストの結果には十分気を付けています。
CI でのテスト結果はチャットに通知されます。そのような環境では、開発者は、自分のコミットが CI でテストされているときは、いつもよりチャットに注目して失敗にすぐ反応できるよう準備しています。その最中はいつもより開発に集中できません。開発に集中してしまうと、テストの失敗にすぐ反応できないからです。
そのような状況を解消し開発者が開発に集中できるようにするため、2012年に jenkins-hipchat-publisher プラグインをベースに、CI でテストが失敗したときにコミットした人をチャット通知で自動メンションするプラグインが、当時開発基盤グループに所属していた id:sora_hによって開発されました*2。そのプラグインによる通知の様子を以下に示します。
図: テスト失敗通知でのメンション
このようなメンション通知があるおかげで、チャットに張り付いていない開発者でもテストの失敗に気付きやすくなります。
チャットでの失敗通知をリッチにする
テストの失敗通知がチャットに流れたときの開発者の動きを見てみましょう。通知でメンションされた開発者は CI のテスト実行ログを確認します。どのテストが失敗したかを把握して次の行動に移るためです。このとき、下図で示す4つの場合に分かれます。
図: テスト失敗時の行動4パターン
失敗が自分の変更に関係ある場合は、チャットで修正中である旨を報告し、テストがきちんと通るように修正します (図の左上 Case 1) 。
失敗が自分の変更と無関係である場合は2つに分かれます。図の右上 Case 2 は、自分のコミットが原因で他人のテストを失敗させてしまった場合です。この場合は、原因を調査するために、失敗したテストの関係者を git blame で調べてチャットで質問したり、修正を移譲したりします。この作業は、CI の実行ログと手元のターミナルとを行き来する必要があり地味で面倒な作業です。
他人のコミットによって自分が書いたテストが失敗する場合もあります (図の左下 Case 3)。 この場合は、自分が書いたテストの内容が間違っていたり不完全だったりするので、テストを自分で修正する必要があります。しかし、自分ではすぐに失敗に気付けません。
このように、CI でテストが失敗した後に起きる行動には、CI の失敗に注目していない他人を巻き込む必要がある場合の方が多く、たいていその対象者は git blame で調べる必要があります。この工程は、サービス開発を遅延させる大きな要因です。
これを改善するため、以下に示す新しい通知を導入しました。
図: テスト失敗の通知完全版
1行目が新しい通知です。下の2行は先ほどお見せした jenkins-hipchat-publisher プラグインによる通知です。
この通知の内容は、rspec がログの最後に出力してくれる、失敗した examples を再実行するコマンドラインとほとんど同じです。違いは、以下の要素が加わっていることです。
- 「ファイル名:行番号」の部分が GitHub Enterprise へのリンクになっている (もちろん、該当行への直リンク)
- その行の最終更新リビジョン (git blame の結果で、もちろん GHE へのリンクになってる)
- その行を最後に変更した人と時期 (これも git blame の結果)
これらの情報がチャットに流れてくるだけで、失敗した example をすぐに調べられます。失敗が自分の変更と直接関係なさそうなときでも、git blame をしないで関係者をすぐ呼べます。開発者は、自分が必要なときだけテストの失敗ログを見に行けば良いし、手元で再実行したい場合もチャットで通知された rspec のコマンドラインを端末にコピペするだけです。
まとめ
本稿では、CI で失敗したテストについの情報をチャットに通知することで、開発者テストの失敗を追跡しやすくする方法について説明しました。
最後に、この通知内容を生成するスクリプトを紹介します。このスクリプトは、標準入力に rspec がログの最後に出力する rspec コマンドのリストが与えられる事を前提に書かれています。コマンドライン引数で、欲しいフォーマット (html, json, plain-text) と git のブランチ名を与えます。
#! /usr/bin/env rubyrequire'pathname'require'time'require'rubygems'require'bundler/setup'require'action_view'includeActionView::Helpers::DateHelperGHE_REPOSITORY_ROOT = ENV["GHE_REPOSITORY_ROOT"] defshort_ref(ref) `git show --oneline #{ref}`.each_line.first.split(//)[0] end format = ARGV[0] branch = ARGV[1] root_dir = Pathname.pwd app = root_dir.basename entries = $stdin.read.lines.map { |line| rspec, filename, lineno, description = line.chomp.sub(/\s*#\s*?(.*)$/, "\\1").split(/[ :]/, 4) nextnilunless rspec && filename && lineno description ||= '' spec_real_path = Dir.chdir(File.dirname filename) { Pathname.pwd.join(File.basename filename).relative_path_from(root_dir) } [ filename, lineno ].tap do |ary| blame = `git blame -w -l #{spec_real_path}#{additional_argument}` hash, author, timestamp = blame.match(/^([0-9a-fA-F]+)\s+(?:\S+\s+)?\(([-+=^:;<>_@\.0-9A-Za-z ]+?)\s+(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\s+[-+\d]+)\s+#{lineno}\)/m)[1,3] relative_timestamp = time_ago_in_words(Time.parse(timestamp)) ary << hash << "#{author}, #{relative_timestamp} ago"<< spec_real_path << description end }.compact case format when'html'if branch entries.each do |filename, lineno, hash, info, spec_real_path, description| message = "rspec " path = root_dir.join(spec_real_path).relative_path_from(root_dir.parent) message << %Q[<a href="#{GHE_REPOSITORY_ROOT}/blob/#{branch}/#{path}\#L#{lineno}">#{filename}:#{lineno}</a>] message << %Q[ \# (<a href="#{GHE_REPOSITORY_ROOT}/commit/#{hash}">#{short_ref(hash)}</a>) #{info}<br />] puts message endendwhen'json'require'json' failures = entries.map do |filename, lineno, hash, info, spec_real_path, description| path = root_dir.join(spec_real_path).relative_path_from(root_dir.parent) { :file => filename, :line => lineno, :commit => hash, :description => description, :real_path => path, } end payload = {:failures => failures} payload.merge!(:build_url => ENV["BUILD_URL"]) ifENV["BUILD_URL"] payload.merge!(:build => ENV["BUILD_NUMBER"]) ifENV["BUILD_NUMBER"] puts payload.to_json else entries.each do |filename, lineno, hash, info, spec_real_path, description| puts "rspec #{filename}:#{lineno} \# (#{hash}) #{info}"endend
*1:Akira Matsuda. The recipe for the worlds largest rails monolith. Ruby on Ales 2015
*2:このプラグインは社内サービスから情報を取得する必要があるためオープンソースにしてません