go testを高速化して開発効率の向上に繋げたい

daisuzu
Eureka Engineering
Published in
Dec 22, 2020

この記事は「Eureka Advent Calendar 2020」の22日目の記事です。

21日目は@emahiroによる「オンライン本人確認機能 (eKYC) に Lambda を採用した振り返り」でした。

初めまして、Backendチームの@daisuzuです。今年の10月に入社し、Observabilityを高めたり、コードを綺麗にしたり、テストの高速化をしたりしてDeveloper Experienceの改善を行っています。

というわけで今回はテスト高速化について、入社後にやったことと、その目的を紹介します。

PairsのバックエンドはGoで書かれているため、普段は

  1. コードを書く
  2. go testする
  3. ローカルでサーバを立ち上げて動作確認する
  4. ステージング環境で動作確認する
  5. 本番環境にリリースする

というサイクルで開発をしています。

ところが、入社時点ではローカルのリポジトリルートでgo test ./...を実行すると1時間半近くかかっていました。CIではテストを分割したり絞っているので10分少々といったところでしたが、それでも非常にストレスです。

そのため、テストキャッシュが効くようにしたり、テストケースごとにDBのTRUNCATEしないようにしました。これで数分ほど短縮できましたが、ローカルに関してはgo-redisをアップデートしただけで約1時間の短縮となり、25分程度で完了するようになりました。

それでもまだ時間がかかりすぎているため、今後もさらに改善していきたいと考えています。

さて、なぜテストを高速化したいのかというと、一番の目的は開発効率を高めるためです。

開発とテストの関係については、よく次のようなことが言われていると思います。

  • 「後工程でバグが見つかると修正コストが高くなるので早期検出できた方が良い」

vs.

  • 「テストを書いているとリリースが遅くなってしまう」

たしかにどちらも真と言えるでしょう。バグは早く見つけるに越したことはないですし、テストを書くために使った時間の分だけリリースは遅くなってしまいます。なのでどちらかに寄りすぎるのではなく、費用対効果を考えながらちょうど良いバランスを取っていくことが大事です。

そのほかに開発効率が落ちる原因として、割り込みがあります。ここでいう割り込みとは、障害などのトラブル対応や利用者からの問い合わせ、他のメンバーからの質問などです。これらは現在の開発と別のものだと影響が見えにくくなってしまうかもしれませんが、時間を消費することは確実なので頻発すると開発効率が落ちてしまいます。コンテキストスイッチもそうですが、フロー状態に入っていたのに割り込まれてしまうと、再度フロー状態に入るのにまた時間がかかってしまいます。

障害に関しては利用しているクラウドサービスの障害など、中には避けることが難しいものもあります。それでも自分たちで開発したコードによって問題が発生してしまうことが絶対に無いわけではありません。ものによってはリリースから数ヶ月以上経ってから問題が発生してしまう可能性もあるでしょう。そういったものの中にはテストがあれば防げるものがあるはずです。

また、テストコードにはドキュメンテーションとしての側面もあります。そのため、テストコードが充実していると新規メンバーのキャッチアップもスムーズになります。あと自分の場合は安心感を得るためにテストを書くことが多いです。実装した機能がどういう挙動をするのかわからないと不安になりますが、正常は時も、そうじゃない時も、どうなるのかが把握できていれば安心です。

例えば以下のコード。errが返ってきたら即リターンしていますが、txは適切にRollbackする必要がありますし、http.PostFormが成功したかどうかはHTTPレスポンスを確認しなければいけません。

func do(id int64) error {
tx, err := db.Begin()
if err != nil {
return err
}
var v string
if err := tx.QueryRow(getValue, id).Scan(&v); err != nil {
return err
}
if _, err := http.PostForm(endpoint, url.Values{"value": {v}}); err != nil {
return err
}
if _, err := tx.Exec(updateStatus, true, id); err != nil {
return err
}
if _, err := tx.Exec(insertRecord, id); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
return nil
}

それ以外のケースは大丈夫でしょうか?いいえ、実はまだ不十分です。HTTPのPOSTが成功した後にトランザクションのコミットが失敗した場合のことを考えなければいけません。これは要件次第なので必ずしも正解があるわけではありませんが、外部システムとDBで不整合が発生してしまうのであれば何らかの対応が必要になるでしょう。

上のコードのように、Goはしょっちゅうerrorが返ってくるので面倒だと感じる人もいるかもしれません。しかし、どの処理で失敗する可能性があって、その時にどうするべきなのか考えるきっかけにできるので個人的にはとても気に入っています。ここで考えたことはきちんとテストコードとして残しておくと、どういう時にどうなるのかが整理されるだけでなく、いつでもその動作が確認できるようになります。

本来はコードを書いている時、できれば書く前に問題になりそうなところに気付けるということが理想かもしれません。ただテストを書く習慣があれば単純に気付く機会が増えることになります。

こういった気付きの機会のためにも、テストが快適に書けて回せることはとても重要です。テストが書きにくいのはもちろん、実行に時間がかかりすぎてしまうと、そのうちテストを書く習慣自体がなくなってしまうかもしれないからです。

ということで、これからも継続的にテストの高速化をしていきます。

テストが遅くなる原因としては、DBなどの外部アクセスもそうですが、Pairsのバックエンドに関してはパッケージや依存の多さによるビルド時間が目立つようになってきました。これはGo ProverbsA little copying is better than a little dependency.と同じような状況なので、次は構成をシンプルに作り替えていこうと考えています。

23日目は@evalphobiaによる「Cookpad Martで在宅作業効率を高めながらサービスをローンチする話」です。

--

--