Findyの爆速開発を支えるテクニック

こんにちは。

Findy で Tech Lead をやらせてもらってる戸田です。

早速ですが、これは弊社のとあるチームの1ヶ月のサイクルタイムです。

最初のコミットからマージされるまで平均3.6時間程度と、開発に着手したらその日のうちにリリースされるのがデフォルトとなっています。

今回はこの開発スピードを継続し、更に速くするために弊社で実践しているテクニックを紹介していきます。

それでは見ていきましょう!

タスク分解

開発タスクをアサインされた時、まず最初にタスク分解をします。

タスク分解をすることによるメリットとしては、

  • 工数見積もりの精度が上がる
  • 対応方針の認識を他メンバーと合わせやすくなる
  • 対応漏れに気づきやすくなり、手戻りの発生が少なくなる
  • Pull requestの粒度を適切に保つことができる
  • 他メンバーへの引き継ぎをしやすくなる

などが挙げられます。

分解したタスク毎にPull requestを作成することで、Pull requestを小さく作り続けることが可能になります。

マークダウン記法のチェックボックスを使ってIssueにタスクリストを洗い出します。

終わったタスクから順にチェックボックスにチェックを入れ、タスク管理をします。

良いタスクリストを作成するためのコツは、最初から完璧なタスクリストを作ろうとしないことです。

まず最初に大枠のタスクを考え、そこから分解していくように意識してタスクリストを作成すると、結果的に詳細なタスクリストが完成しています。

完成したタスクリストを元にPull requestを作成していくと、結果的に適切な粒度でPull requestを作成できるようになるはずです。

例えば、何かしらのデータの一覧を返すREST APIを追加する場合、次のようなタスクに分解できます。

- [ ] APIの仮実装を行う
    - [ ] APIのエンドポイントを決める
    - [ ] APIのresponseの形を決める
    - [ ] モックデータを返す
- [ ] データベースからデータを取得して返す
- [ ] 検索条件に対応する
    - [ ] id
    - [ ] nameの部分一致
- [ ] ソートに対応する

小さくコツコツと修正を上乗せしていき、最終的に完成形に近づけていくような分解の仕方が良いでしょう。

大きな機能の実装担当になった際に、一度に全ての機能を実装するのではなく、小さい機能追加を何回も繰り返して結果的に大きな機能を完成させるようなイメージを持つと良いです。

まずタスク分解をして作成したタスクリストを他のメンバーにレビューしてもらいます。そこで認識を合わせることで、開発の進行がスムーズになります。

分解したタスクに沿って開発を進めていくと、そのタスクも更に分解したほうが良いことに気づくこともあります。

その際はどんどん分解していきます。結果的に分解されたタスクそのものが知見となり、今後の開発の参考になるからです。

Pull requestの粒度

Pull requestを小さくすることで開発生産性が改善することは既に知られている事実ですが、小さすぎても問題が出ます。

じゃあどうするのか?という話になりがちですが、これはPull requestのサイズを気にしすぎていて粒度を考えていないために起こる問題です。

Pull requestを 小さくするのではなく、粒度を考える ことが、小さいPull requestを作り続けるための秘訣です。

では適切な粒度とはどういったものなのでしょうか?それは、一つのことだけに注力している Pull requestです。

具体的に幾つかの例を挙げましょう。

例えば、「関数名を変更したので、それを利用してる1万行を一括置換したPull request」があるとします。

これは適切な粒度だと言えます。変更行数は1万行でサイズは大きいかもしれませんが、「特定の関数名を一括置換する」という1つのことしかしていないため、粒度としては適切です。

Pull requestの概要欄に一斉置換した旨を書いておき、CI通れば即mergeでOKです。 ※後述するように自動化テストが充実しており、守れるテストになっている前提です

では「画面の開発中に別の画面のリファクタを入れ、変更行数は20行程度だったPull request」はどうでしょうか?

これは適切な粒度とは言えません。変更行数は20行でサイズは小さいかもしれませんが、「画面の開発」と「別の画面のリファクタ」という2つの事を同時にしているため、粒度としては不適切です。

仮に画面の開発の部分で不具合が発生しrevertが必要になった場合、画面の開発部分だけではなく別の画面のリファクタの部分も同時にrevertされてしまいます。

適切な粒度を考える上で、Pull requestの存在意義が多岐に渡っていないかどうか? ということを考えると良いです。

粒度が大きすぎると出てくる問題は色々とありますが、

  • レビューに時間が掛かり、レビュワー目線で考えるとレビューに対して精神的に負荷がかかる
  • どこをどうレビューしたら良いのか分かりづらいため、レビューの質が下がり、結果的に不具合の発生率が高くなる
  • Pull request上でのコミュニケーションが必要以上に増えてしまう
  • 不具合発生時の影響範囲が広くなり、原因の特定に時間がかかる
  • etc

などが挙げられます。

Pull requestが大きすぎること自体の原因は組織によって異なりますが、Pull requestの粒度が大きすぎることは、システム開発において何のメリットも生み出さないのです。

適切な粒度を維持し続けることにより、Pull requestのレビューに対する負担が減るためレビュー自体の質が上がることに加え、レビューの優先度が上がることに繋がり、結果的に開発スピードと品質の両方を担保できます。

テスト

実装コードに加えて、それの動作を保証するテストコードを同じPull request内で用意することをマストとしています。

実装コードよりもテストケースやテストの内容に対するレビューの方が多いこともあります。

弊社ではテストのカバレッジに対しての大きな拘りは特にありませんが、カバレッジよりも重要視していることがあります。

それは、そのテストが「守る」テストになっているかどうかです。通るべき時に通り、コケるべき時にコケるテストになっているかどうかが重要なのです。

例えば正常系の次のようなテストコードがあるとします。

const mockOnSuccessCallback = jest.fn();
const mockOnErrorCallback = jest.fn();
const { result } = renderHook(() => useHoge({ onSuccessCallback: mockOnSuccessCallback, onErrorCallback: mockOnErrorCallback }));

act(() => {
  result.current.handleSubmit();
});

expect(mockOnSuccessCallback).toHaveBeenCalledWith('test');

hooksの関数を実行し、成功時に引数で渡したコールバック関数が実行されることを確認しています。

一見何の変哲もないテストコードですが、このテストコードには次の視点が抜けており、守るテストとしては不十分なのです。

  • 成功時の関数が複数回実行されたとしても通ってしまう
  • エラー時のコールバック関数が実行されたとしても通ってしまう

こういったケースでは、弊社では次のようなテストコードが良いとされています。

const mockOnSuccessCallback = jest.fn();
const mockOnErrorCallback = jest.fn();
const { result } = renderHook(() => useHoge({ onSuccessCallback: mockOnSuccessCallback, onErrorCallback: mockOnErrorCallback }));

act(() => {
  result.current.handleSubmit();
});

expect(mockOnSuccessCallback).toHaveBeenCalledTimes(1);
expect(mockOnSuccessCallback).toHaveBeenCalledWith('test');
expect(mockOnErrorCallback).not.toHaveBeenCalled();

このテストコードにより、「成功時に1回だけコールバック関数がパラメータ付きで実行され、エラーのコールバック関数が実行されない」ことを守ることができます。

この2つのテストコードのカバレッジは変わりませんが、テストとして守ることができる内容が異なります。

弊社ではカバレッジよりもテストが守る内容の方に重きを置いており、結果としてカバレッジが上がっているだけなのです。

弊社のリポジトリではテストカバレッジが90%を超えているものも珍しくないですが、それはカバレッジを意識してテストを書いたのではなく、守るテストを書き続けることによって勝手に上がったものなのです。

このような一定の品質以上のテストコードが存在することにより、ライブラリのバージョンアップや既存処理のリファクタなどは「CI通れば即mergeでOK」という文化が根付いています。

全ての修正に対して動作確認を行うことは無く、テストコードのお陰でスピードと品質の両方を担保する状況を維持し続けることを実現しているのです。

CI/CD

弊社のCIのほとんどはGitHub Actionsで統一しています。

弊社ではPull requestが作成されるたびにテストやLinterなどを実行し、レビューの効率化やコードの品質を一定に保つようにしています。

高速化

CIの速度には非常に拘っており、様々な高速化の工夫をしています。CIの実行速度が遅いと、Pull requestをmergeするまでの時間が長くなってしまい、結果的にクリティカルパスになってしまうからです。

1つ目はキャッシュの活用です。GitHub Actionsが提供している各種パッケージをキャッシュする仕組みを利用することで、CIのセットアップに必要な時間を削減することが出来ます。

弊社ではいくつかのプロジェクトに Nx を導入しており、Nxが持つ 変更検知機能リモートキャッシュ機能 によってCIを大幅に高速化しました。

2つ目はテストコードの実行を並列化することです。GitHub Actionsのmatrixと呼ばれる機能 を利用し、CIのワークフローそのものを並列実行できるようにしています。

テストファイルをいくつかのグループに分類し、それぞれのワークフローにグループごとのテストファイルを割り当てる独自のスクリプトを用意しています。

1つのワークフロー内で全てのテストファイルを実行してしまうと、テストファイルの数に比例してCIの実行時間が長くなっていきます。

しかしグループ分けをしてワークフローを並列実行することで、CIのトータルでの実行時間はほとんど変わりませんが、CIが完了するまでの時間を一定に保つことが出来るようになります。

詳細は 別記事 の方でも紹介しているので、興味がある方はそちらもご覧ください。

3つ目はGitHub Actionsのワークフローが実行される runnerのスペックを上げる ことです。

runnerのCPUのコア数やメモリを増やしたマシンを利用できるように設定し、テストコードの実行コマンドを見直して、同じワークフロー内でもテストコードを並列実行できるようにしています。

つまりワークフローそのものと、ワークフロー内の両方の並列化を実現しています。これにより、弊社では最大で40個のテストファイルを並列実行しているリポジトリもあります。

runnerのスペックを上げることにより利用料金の大幅な増額が予想されますが、GitHub Actionsの課金はrunnerの総実行時間に対して行われており、ワークフロー自体の実行が高速化される分、結果的に総実行時間が短くなり、課金額が必要以上に膨らんでしまうことを防いでいます。

自動化

CI高速化以外にも業務の効率化のため、ほぼ全てのプロジェクトにリリース作業を自動化する仕組みを取り入れています。GitHub Actionsのワークフローを手動実行するだけで、リリース用のPull requestが自動生成され、それをmergeするだけで自動的にstaging/production環境へのデプロイが実行されるようになっています。

更にPull requestのLabelsやAssigneesを自動で設定するようにもしています。

このように手間とコストを掛けてでもCI/CDの高速化と自動化の両方を実現させています。

通知

何かしらの異変、変化、依頼があった際に、そのタイミングでSlackに通知が飛ぶようになっています。

まずエラーや障害の検知です。本番環境でエラーや障害、各種負荷の増加が発生した際に、SentryやDatadogを通じてSlackに通知を飛ばすようになっています。この仕組みによって不具合や障害に最初に気づくまでの時間が短縮され、修正コードを本番環境にデプロイされるまでの時間が短縮されます。

次に開発時の通知です。GitHubのWebhookを通じて、Issueが作られた時やコメントが追加された際にSlackにメンション付きでリアルタイムで通知するようにしました。

特に効果が大きかったのはPull requestのレビュー依頼をメンション付きで通知することです。この仕組みによりファーストレビューまでの時間が短縮され、結果的にPull requestがマージされるまでの時間が短縮されました。

本来であればGitHubの公式APPを利用して通知を送信すればよいのですが、弊社では独自に開発したスクリプトを利用しています。

公式APPは通知内容の情報も入ってくるので一見すると便利ですが、弊社の開発スピードだと通知が多すぎて、逆に通知の内容が流れすぎてしまうことがありました。

そのため、もっとシンプルな内容を通知してくれる独自スクリプトを用いて通知を送信しています。

不具合や障害、レビュー依頼もコメントも、気づかないとそこから何も動きがありません。気づかないことがクリティカルパスになるのです。

まとめ

いかがでしたでしょうか?

弊社では開発生産性や開発スピードに重きを置いており、そのためには手間とコストを惜しまず日々改善を続けています。

現在、ファインディでは一緒に働くメンバーを募集中です。

興味がある方はこちらから ↓ herp.careers

また、6月28日(金)・29日(土)に『LeanとDevOpsの科学』の著者であるNicole Forsgrenの来日、テスラ共同創業者元CTOの登壇など、国内外の開発生産性に関する最新の知見が集まるConferenceを開催します。

開発生産性に関する他の企業の取り組みや海外の事例に興味がある方は、ぜひお申し込みください!

dev-productivity-con.findy-code.io