Next.js 製アプリケーションの CI の実行時間削減や安定性向上のために取り組んだこと

こんにちは!DOGO プロジェクトでソフトウェアエンジニアとして活動している @nissy_dev です。

DOGO プロジェクトでは、画面刷新を進めていく中で CI の実行時間が長く不安定になってしまい、開発生産性に大きな影響が出ていました。今回の記事では、CI の課題改善のために取り組んだことを紹介します。


目次


DOGO について

DOGO は、サイボウズ Office のフロントエンドを刷新するプロジェクトです。独自のスクリプト言語で書かれた MPA(Multi-Page Application)を、Next.js の App Router を利用して画面単位で置き換えています。詳細は以下のブログや JSConf での登壇スライドを参考にしていただければと思います。

blog.cybozu.io

speakerdeck.com

CI を改善することになった背景

DOGO の CI パイプラインは GitHub Actions で実装されています。

改善前のパイプライン

改善の前は次のような課題が生じており、日々の開発生産性を悪化させていました。

  • PR での CI が完了するまでに 12 〜 14 分程度かかる
    • 特に Test の実行にかかっている時間が長く、Test は今後も増えていく予定
  • Test が flaky になっており、安心してマージできない

DOGO では Shape Up という手法をベースにして刷新を進めており、開発チームは Building と呼ばれる 6 週間と Cool-down と呼ばれる 2 週間を繰り返します。

  • Building: 提供したい価値に集中して実装する開発期間
  • Cool-down: 探求などの自由に使って良い休息期間

このとき、Building 期間中に生じた問題などは基本的に Cool-down 期間に対応する予定でした。 一方で、CI の課題については Cool-down 期間でうまく改善できていない状態が続いていました。 そこで、今回は開発チームの中から自分が集中して CI の課題改善に取り組むことにしました。

CI の改善のために取り組んだこと

ビルド時に tsc を実行しない

DOGO では、Next.js で実装したアプリケーションを Custom Server の構成で動かしています。 このため、ビルド時には Next.js のビルド (next build) だけではなく、TypeScript で実装されたサーバー側 1 のコードもビルドする必要があります。 改善する前は、サーバー側のコードをビルドする際に tsc を利用しており、ビルド全体で 1 分程度の時間がかかっていました。

tsc によるビルドは型チェックが実行されるため遅いので、今回の改善ではトランスパイルだけを行う tsup に置き換えました。 tsup はデフォルトの設定がよくできており、設定ファイルを新たに書くことなく移行することができました。

また、next build 中にもデフォルトで tsc による型チェックが実行されるようなので、次のように無効化することにしました。 無効化した型チェックは、lint などと一緒にビルドとは別の job として並列で実行させています。

module.exports = {
  typescript: {
    // build 時の tsc による型チェックを無効にする
    ignoreBuildErrors: true,
  },
};

この改善によって、ビルド時間を 30 秒程度短縮することができました。

.next/cache を除いて、artifacts にアップデートする

ビルド成果物を複数の job に共有するために、GitHub Actions の artifacts を利用しています。 改善前は、next build で生成された .next ディレクトリをそのまま artifacts にアップロードしており、アップロードとダウンロードにそれぞれ 30 秒程度の時間がかかっていました。

一方で、.next ディレクトリのサイズを確認してみると、ほとんどが .next/cache によって占められていることがわかりました。

🐧 ❯ du -h -d 1 .next
 60K  .next/types
205M  .next/cache
2.9M  .next/server
2.5M  .next/static
213M  .next

こちらの cache ディレクトリはビルドキャッシュなので、アプリケーションを起動をするには不要です。2 そこで、キャッシュを除いて .next ディレクトリをアップロードするように変更しました。

- name: Next build
  run: npm run build
- name: Upload
  uses: actions/upload-artifact@v4
  with:
    name: upload-next-build
    path: |
      !.next/cache # cache ディレクトリを対象外にする
      .next

この改善によって、アップロードとダウンロードの時間をどちらも 20 秒程度短縮することができました。 また、このビルド成果物を元に docker image を作成していたので、image サイズの削減にもつながりました。

E2E テストをより多くの shard 数で分割する

Next.js のアプリケーションにおいて React Server Component (RSC) を含めたテストを行いたい場合は、基本的に E2E で行う必要があります3。 DOGO では、CI 上でフロントエンドとバックエンドを localhost で起動し、Playwright を利用した E2E テストを実行しています。

改善前は約 300 件の E2E テストを 5 個の shard に分割して実行していましたが、それでも CI のボトルネックになっていました。 今回の改善ではさらに shard 数を増やし、 10 個の shard に分割するようにしました。 これによって、テストの実行時間を 2 分程度短縮できました。

shard 数を増やすと GitHub Actions の billable time が増加するので、なるべく無駄なワークフローが実行されないように concurrency を設定しました。連続で push したときに複数のワークフローが実行されないようにしています。

on:
  pull_request:
    branches:
      - main

# ブランチごとに1つのワークフローしか実行されないようにする
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

Playwright のブラウザのインストールをキャッシュする

Playwright のテストを実行するにはブラウザをインストールする必要があり、改善前はそれぞれの shard で 30 秒程度の時間がかかっていました。 この時間を削減する方法としてまず思いつくのはキャッシュを利用することなのですが、公式サイトではブラウザのキャッシュを推奨していません

今回の改善ではテスト実行時の shard 数を増やしており、GitHub Actions の billable time への影響を減らすためにも、各 shard で共通に必要な処理の実行時間はできるかぎり短縮したいです。 そこで、issue のコメントを参考に次のようにキャッシュすることにしました。

- name: Store Playwright's Version
  shell: bash
  run: |
    PLAYWRIGHT_VERSION=$(npm ls @playwright/test | grep @playwright | sed 's/.*@//')
    echo "PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" >> $GITHUB_ENV
- name: Cache Playwright Browsers for Playwright's Version
  id: cache-playwright-browsers
  uses: actions/cache@v4
  with:
    path: ~/.cache/ms-playwright
    key: playwright-browsers-${{ env.PLAYWRIGHT_VERSION }}
- name: Setup Playwright
  shell: bash
  if: steps.cache-playwright-browsers.outputs.cache-hit != 'true'
  run: npx playwright install --with-deps chromium

この改善によって、ブラウザのインストールの時間を 20 秒程度短縮することができました。

PR ではコード差分に関連するテストのみを実行する

DOGO には約 300 件の E2E テストがありますが、PR のコード差分に関連するテストケースは多くの場合でその一部になっています。 そこで、次の図に示すような方針で PR の差分に関連する E2E テストのみを実行する仕組みを実装しました。

E2Eテストの差分実行の実装方針

DOGO では src/apptests/e2e 直下のディレクトリ構成を同じにしています。 つまり図のディレクトリ構成において、https://example.com/page-b/... の URL に関するテストはすべて test/e2e/page-b に含めるようにしています。

このとき、URL の第一階層ごとに依存するファイルを madge と呼ばれるツールを使って取得します。 そして、それらのファイルが Pull Request の差分に含まれているかを確認し、含まれていれば対応する E2E テストを実行するようにします。

この仕組みによって、不要な E2E テストの実行を防くことに成功し、多くの PR でテストの実行時間を 1 〜 2 分程度短縮することができました。

Hydration の挙動によってテストが flaky になっていた問題の解消

E2E テストでは、フォームのサブミットなどのボタンをクリックするような操作の一部が flaky になる現象が起きていました。 原因を調査すると、hydration が完了する前に要素に対して操作を行っているためテストが落ちていることがわかりました。4

この結果を踏まえて、画面に遷移したときに hydration の完了を待つようにする処理を実装しました。 まず、テストでうまく操作ができなかった要素に、useEffect が実行されるタイミングで data-hydrated というデータ属性が追加されるようにします。

function useHydrationState() {
  const [hydrated, setHydrated] = useState(false);

  useEffect(() => {
    setHydrated(true);
  }, []);

  return hydrated;
}

export const Button = () => {
  const hydrated = useHydrationState();
  return <button onClick={....} data-hydrated={hydrated}>ボタン</button>
};

そして、テストコードでは次のようにデータ属性が追加されるまで待つようにします。

const waitForHydration = async (locator: Locator) => {
  await expect(async () => {
    expect(await locator.getAttribute("data-hydrated")).toBe("true");
  }).toPass();
};

test("ボタンのクリック", async ({ page }) => {
  await page.goto(".......");

  // データ属性を付与したコンポーネントへの locator を取得し、hydration を待つ
  const button = page.getByRole("button", { name: "ボタン" });
  await waitForHydration(button);

  // hydration が完了後、操作を行う
  await button.click();
  ...
});

こうすることで、flaky だったテストのほとんどが安定して通るようになりました。 ちなみに、似たような対策は sveltekit のテストでも行われています

CI の改善の結果

以上に加えて、E2E テストの一部削減やジョブの並列化も行い、最終的な CI のパイプラインと実行時間は次のようになりました。

改善後のパイプライン

CI を改善している間にもテストは増えていたのですが、それでも多くの PR で 5 分以上の高速化が実現できました。

今回取り組まなかったこと

今回の改善では優先度が上がらず取り組まなかった施策がいくつかありました。 これらの施策は、再び課題が生じたときや余裕ができたときに取り組むかもしれません。 他のプロダクトの改善の際にも使える施策があると思うので紹介します。

  • ワークフローのボトルネックの可視化に actions-timeline を使う
    • 今回は複雑なワークフローではなかったのでツールを入れないで対応できた
  • Flaky test の分析基盤として Allure Report を導入する
    • 今回は flaky test の原因にあたりがついていたので不要だった
    • あたりがつくことは稀なので、時間を見つけて分析基盤は用意したい
  • Microsoft Playwright Testing を導入する
    • サービスができてから間もなく、安定性や導入コストなどを考えて見送った
    • テストの実行時間の改善を期待している

終わりに

今回の記事では、DOGO プロジェクトの CI の改善について紹介しました。 RSC のテストの方法として E2E テストを選択しましたが、このままのペースで実装し続けると再び CI の実行時間が長くなってしまいそうです。 現在はチーム内でテスト戦略の見直しやよりよいテスト手法がないかを模索している状況です。 Testing Library の RSC サポートなどにも期待しています。

DOGO では、Next.js の App Router を利用して多くのページで刷新が進んでいます!もしこの記事を読んでサイボウズや DOGO に興味を持った方がいれば、次のリンクからご応募お待ちしています!


  1. サーバーは fastify を使って実装しています。
  2. ローカル環境では、キャッシュによる next build の高速化は数秒しか確認できませんでした。そのため、CI ではビルドキャッシュを利用していません。
  3. Next.js の test modeStorybook の RSC 対応などもありますが、どれも実験的なものになっており RSC の挙動を正確にテストできる状態ではないというのが現状です。
  4. 調査にあたっては、Playwright の trace viewer が非常に便利でした。