2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

初心者がReactのTodoアプリでCI/CDを体験してみた【Vitest・Firebase・GitHub Actions】

2
Last updated at Posted at 2026-05-06

1. はじめに

今回はYouTubeの動画「【初心者完全版】0からReactでCI/CD構築までできるチュートリアル【GitHub Actions/Firebase/Vitest/TypeScript】」を参考に、React + TypeScript + ViteでシンプルなTodoアプリを作りながら、CI/CDパイプラインを構築するまでの流れを学びました。
「CI/CDって上級者向けでしょ」と思っていたのですが、個人開発レベルなら思ってたより全然シンプルに始められるみたいです。

具体的には以下の技術スタックを使いました。
9954d9b3-259d-4e3d-af97-40958410bf31_r1_c2.png

  • フロントエンド:React + TypeScript + Vite
  • スタイリング:Tailwind CSS
  • 自動テスト:Vitest + Testing Library
  • デプロイ先:Firebase Hosting
  • CI/CDパイプライン:GitHub Actions

2. CI/CDとは

9954d9b3-259d-4e3d-af97-40958410b1f31_r1_c2.png

2.1 CIとCDの違い

**CI(継続的インテグレーション)**とは、コードをGitHubにプッシュした瞬間に自動でビルドとテストが走る仕組みのようです。
**CD(継続的デリバリー)**とは、テストが全て通ったら自動でデプロイまで行う仕組みのようです。

この2つが組み合わさることで、「コードを書く→プッシュ→自動テスト→自動デプロイ→世界に公開」という流れが全て自動化されるみたいです。

2.2 CI/CDがない場合の問題

CI/CDがない現場だと、半年間コードを書き続けてまとめてリリースする、いわゆるビッグバンリリースになりがちなようです。
その結果、手動テストでバグが大量に見つかってリリースが延期になるといった問題が起きやすいと理解しました。
CI/CDを導入すると細かい単位でデプロイするので、バグが見つかっても影響範囲が狭く済むのが大きなメリットみたいです。

3. Todoアプリの実装

9954d9b3-259d-4e13d-af97-40958410bf31_r1_c2.png

今回作ったTodoアプリのコード全体を確認します。

import { useState } from 'react';
import './index.css';

// Todoオブジェクトの型定義(TypeScriptで型安全にするため)
type Todo = {
  id: number;
  title: string;
  completed: boolean;
};

function App() {
  // TodoリストをStateで管理(変更があると画面が自動で再レンダリングされる)
  const [todos, setTodos] = useState<Todo[]>([]);
  // 入力フォームの文字列をStateで管理
  const [title, setTitle] = useState('');

  // 追加ボタンを押した時にTodoを追加する処理
  const handleAddTodo = () => {
    // titleが空の場合は何もしない(空のTodoを追加させない)
    if (!title) return;
    // スプレッド構文で新しい配列を作ってStateを更新する
    // pushで直接変更するとReactが変化を検知できないため必ずこの形にする
    setTodos([
      ...todos,
      { id: todos.length + 1, title, completed: false },
    ]);
    // 追加後はフォームをリセット
    setTitle('');
  };

  // チェックボックスを押した時にcompletedを反転させる処理
  const handleToggleTodo = (id: number) => {
    // mapは新しい配列を返すのでスプレッド構文なしでStateの更新ができる
    setTodos(
      todos.map((todo) =>
        // IDが一致するTodoだけcompletedを反転し、それ以外はそのまま返す
        todo.id === id
          ? { ...todo, completed: !todo.completed }
          : todo
      )
    );
  };

  // filterでcompletedがtrueのものだけ取り出して件数を数える
  const completedCount = todos.filter((todo) => todo.completed).length;

  return (
    <div>
      <h1>Todoアプリ</h1>
      <input
        type="text"
        placeholder="新しいタスクを入力"
        // valueにStateを渡すことでフォームとStateを同期させる
        value={title}
        // 文字が入力されるたびにtitleのStateを更新する
        onChange={(e) => setTitle(e.target.value)}
      />
      <button onClick={handleAddTodo}>追加</button>

      {/* todosが1件以上あればリストを表示、なければメッセージを表示 */}
      {todos.length > 0 ? (
        <ul>
          {todos.map((todo) => (
            <li key={todo.id}>
              <input
                type="checkbox"
                // completedの値でチェック状態を制御する
                checked={todo.completed}
                onChange={() => handleToggleTodo(todo.id)}
              />
              {todo.title}
            </li>
          ))}
        </ul>
      ) : (
        <p>タスクがありません。新しいタスクを追加してください。</p>
      )}

      {/* todosが1件以上ある時だけ完了件数を表示する */}
      {todos.length > 0 && (
        <p>完了済み {completedCount} / {todos.length}</p>
      )}
    </div>
  );
}

export default App;

4. 自動テスト(Vitest + Testing Library)

image.png

CI/CDを構築するうえで自動テストは欠かせない要素です。
テストが通ったことを条件にデプロイするので、テストがなければCI/CDは成立しないと理解しました。

4.1 パッケージのインストールと役割

// 開発用パッケージとしてインストール(本番ビルドには含まれない)
npm install -D vitest @testing-library/react @testing-library/dom @testing-library/jest-dom jsdom

インストールするパッケージの役割はそれぞれ以下のようです。

vitest

vitestはViteと相性の良いテストフレームワークで、テスト全体を動かすエンジン部分になります。
testdescribeexpectといったテストの骨格を作る関数が使えるようになります。

import { test, describe, expect } from 'vitest';

// describe:テストケースをグループにまとめる(どのコンポーネントのテストかを示す)
describe('Appコンポーネント', () => {
  // test:1つのテストケースを定義する(第1引数がテストの仕様説明になる)
  test('タイトルが表示されている', () => {
    // expect:実際の値と期待値が一致するかを検証する
    expect(1 + 1).toBe(2);
  });
});

@testing-library/react

@testing-library/reactはReactコンポーネントを仮想的にレンダリングするためのライブラリです。
renderでコンポーネントを描画し、screenで描画されたHTML要素を取得できます。

import { render, screen } from '@testing-library/react';
import App from '../App';

test('タイトルが表示されている', () => {
  // render:テスト内でAppコンポーネントを仮想的に描画する
  // ブラウザを開かなくてもHTMLが生成される
  render(<App />);

  // screen.getByRole:描画されたHTMLからロールと名前で要素を取得する
  // ユーザーが画面を見る目線で要素を特定するのがポイント
  const heading = screen.getByRole('heading', { name: 'Todoアプリ' });
});

@testing-library/dom

@testing-library/domは描画されたHTMLに対してユーザー操作を模倣するための関数を提供するライブラリです。
fireEventでクリックや入力といったブラウザイベントをテスト内で再現できます。

import { fireEvent } from '@testing-library/react';

// fireEvent.change:inputに文字が入力されたイベントを発火する
// 実際にキーボードで入力した時と同じ状態を作り出す
fireEvent.change(input, { target: { value: 'テストタスク' } });

// fireEvent.click:ボタンがクリックされたイベントを発火する
// 実際にマウスでクリックした時と同じ状態を作り出す
fireEvent.click(button);

@testing-library/jest-dom

@testing-library/jest-domはDOMに特化したマッチャーを追加するライブラリです。
このライブラリを入れることでtoBeInTheDocument()toBeChecked()といった直感的な検証メソッドが使えるようになるみたいです。

// toBeInTheDocument:その要素が画面上に存在するかを確認する
expect(heading).toBeInTheDocument();

// toBeChecked:チェックボックスがチェック済み状態かを確認する
expect(checkbox).toBeChecked();

jsdom

jsdomはNode.js環境上に仮想のブラウザ環境を作るためのライブラリです。
テストはブラウザではなくNode.jsで実行されるので、DOMを扱うためにこの仮想環境が必要になるようです。
vitest.config.tsenvironment: 'jsdom'と設定することで有効になります。

4.2 設定ファイルの準備

vitest.config.tsに以下の設定を書きます。

import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    // jsdomで仮想ブラウザ環境を作りDOMを扱えるようにする
    environment: 'jsdom',
    // test・describe・expectをimportなしで使えるようにする
    globals: true,
    // 全テストの実行前に必ず読み込まれるセットアップファイルを指定する
    setupFiles: './vitest.setup.ts',
  },
});

vitest.setup.tsに以下を書きます。
全テストの実行前に自動でインポートされるので、各テストファイルに個別に書く必要がなくなるようです。

// toBeInTheDocumentなどのDOM用マッチャーを全テストで使えるようにする
import '@testing-library/jest-dom';

4.3 テストコードの全体像

以下がアプリ全体をカバーするテストコードです。

import { render, screen, fireEvent, within } from '@testing-library/react';
import { describe, test, expect } from 'vitest';
import App from '../App';

// describe:Appコンポーネントに関するテストをひとまとめにする
describe('App', () => {

  // test第1引数はアプリの「仕様」を書く
  // CIでこのテストが落ちた時に「どの仕様が壊れたか」が一目でわかる
  test('アプリタイトルが表示されている', () => {
    // render:仮想ブラウザ上にAppコンポーネントを描画する
    render(<App />);
    // screen.getByRole:headingロールを持つ「Todoアプリ」という名前の要素を取得する
    const heading = screen.getByRole('heading', { name: 'Todoアプリ' });
    // toBeInTheDocument:取得した要素が画面上に存在することを確認する
    expect(heading).toBeInTheDocument();
  });

  test('Todoを追加することができる', () => {
    render(<App />);
    // screen.getByRole:ユーザーが画面を見る目線で要素を特定する
    // textboxロールのinputとbuttonロールの追加ボタンを取得する
    const input = screen.getByRole('textbox', { name: /新しいタスクを入力/ });
    const addButton = screen.getByRole('button', { name: '追加' });

    // fireEvent.change:inputに「テストタスク」と入力したイベントを発火する
    fireEvent.change(input, { target: { value: 'テストタスク' } });
    // fireEvent.click:追加ボタンをクリックしたイベントを発火する
    fireEvent.click(addButton);

    // screen.getByRole:listロールの要素(ul)を取得する
    const list = screen.getByRole('list');
    // within:listの中だけを対象に絞り込んで「テストタスク」を探す
    // withinを使うことでリスト外の同名テキストに誤反応しなくなる
    expect(within(list).getByText('テストタスク')).toBeInTheDocument();
  });

  test('Todoを完了することができる', () => {
    render(<App />);
    const input = screen.getByRole('textbox', { name: /新しいタスクを入力/ });
    const addButton = screen.getByRole('button', { name: '追加' });

    // まずTodoを1件追加してからチェックボックスを操作する
    fireEvent.change(input, { target: { value: 'テストタスク' } });
    fireEvent.click(addButton);

    // screen.getAllByRole:checkboxロールを持つ要素を全件配列で取得する
    const checkboxes = screen.getAllByRole('checkbox');
    // fireEvent.click:最初のチェックボックスをクリックして完了状態にする
    fireEvent.click(checkboxes[0]);

    // toBeChecked:チェックボックスがチェック済みになっているかを確認する
    expect(checkboxes[0]).toBeChecked();
  });

  test('完了したTodoのカウントが表示されている', () => {
    render(<App />);
    const input = screen.getByRole('textbox', { name: /新しいタスクを入力/ });
    const addButton = screen.getByRole('button', { name: '追加' });

    // 2件追加して1件だけチェックする
    fireEvent.change(input, { target: { value: 'テストタスク1' } });
    fireEvent.click(addButton);
    fireEvent.change(input, { target: { value: 'テストタスク2' } });
    fireEvent.click(addButton);

    const checkboxes = screen.getAllByRole('checkbox');
    // 最初の1件だけチェックして「完了済み 1 / 2」になることを確認する
    fireEvent.click(checkboxes[0]);

    // screen.getByText:画面上に「完了済み 1 / 2」というテキストが存在するか確認する
    expect(screen.getByText('完了済み 1 / 2')).toBeInTheDocument();
  });

  test('Todoがない場合はメッセージが表示されている', () => {
    // Todoを追加しない状態でレンダリングして初期メッセージを確認する
    render(<App />);
    expect(
      screen.getByText('タスクがありません。新しいタスクを追加してください。')
    ).toBeInTheDocument();
  });

  test('空のタイトルでTodoは追加されない', () => {
    render(<App />);
    const addButton = screen.getByRole('button', { name: '追加' });

    // 何も入力せずに追加ボタンをクリックする
    fireEvent.click(addButton);

    // 空追加後もメッセージが残ったままであることを確認する
    // もしtitleの空チェック処理が消えたらここでテストが落ちてCIが止まる
    expect(
      screen.getByText('タスクがありません。新しいタスクを追加してください。')
    ).toBeInTheDocument();
  });
});

4.4 テストのポイント

getByRoleでユーザーが実際に画面を見た時の目線でHTML要素を取得するのがポイントのようです。
「追加」という文字が書かれたボタンをbuttonロールで取得することで、ユーザーの操作をそのままテストに落とし込めると理解しました。

withinを使うと特定の要素の中だけを対象に絞り込んで検索できます。
リスト全体を対象にするより範囲が狭まるので、より確実なテストが書けるようです。

テストの説明文(第1引数の文字列)は「仕様書」として書くとよいみたいです。
このテストを読んだだけでアプリの仕様が分かるようにしておくと、CI/CDのパイプラインが落ちた時にどの仕様が壊れたかが一目で分かるようです。

5. Firebase Hostingへのデプロイ

9954d9b3-259d-4e3d-af97-409584101bf31_r2_c2.png

Firebase Hostingとは、Googleが提供する静的サイトのホスティングサービスです。
ReactアプリをビルドしてできたファイルをFirebaseのサーバーに置くことで、インターネット上に公開できるみたいです。
今回はまず手動でデプロイの流れを確認してから、CI/CDパイプラインに組み込む流れで進めました。

5.1 Firebaseプロジェクトの作成とCLI設定

まずFirebase CLIをグローバルにインストールします。

// グローバルインストール(どのディレクトリからでもfirebaseコマンドが使えるようになる)
npm install -g firebase-tools

その後、Firebase CLIでログインし、プロジェクトに設定を追加します。

// Googleアカウントでログインする
firebase login

// Hostingの初期化(firebase.jsonと.firebasercが自動生成される)
firebase init hosting

自分の環境ではfirebase.json"public"の値が最初から"dist"になっていました。
動画内では異なる値になっていたので、もし"dist"以外になっていた場合は手動で修正してください。

5.2 手動デプロイの確認

設定が完了したら以下のコマンドだけでデプロイできます。

// ビルドとデプロイをまとめて実行する
firebase deploy

URLが発行され、インターネット上に公開されるのが確認できました。
思ったよりシンプルにデプロイできるみたいです。

5.3 サービスアカウント鍵のエンコード

CI/CD環境からFirebaseにデプロイするには、パイプラインにFirebaseの認証情報を渡す必要があります。
GCPでサービスアカウントを作成してダウンロードした鍵ファイルをそのまま使うのは危険なので、Base64でエンコードしてからGitHub ActionsのSecretsに登録するみたいです。

エンコードは以下のコマンドで行いました。

// ダウンロードした鍵ファイルをBase64でエンコードしてテキストファイルに保存する
base64 -w 0 ~/ダウンロード/サービスアカウントキー.json > encoded_file.txt

エンコードした文字列をGitHub RepositoryのSettings→Secrets→ActionsからSecretsとして登録します。
パイプライン内ではsecrets.GOOGLE_APPLICATION_CREDENTIALSとして参照できるようになります。

サービスアカウントの鍵ファイルはGitにプッシュしてはいけないようです。
encoded_file.txtも同様にプッシュしないよう.gitignoreに追加しておく必要があります。

6. GitHub ActionsでCI/CDパイプラインを構築する

99154d9b3-259d-4e3d-af97-40958410bf31_r2_c2.png

image.png

いよいよCI/CDパイプラインの構築です。
以下のワークフローファイル全体を先に確認してから、各ジョブのポイントを見ていきます。

.github/workflows/pipeline.ymlに以下を記述します。

name: todo-app-cicd-pipe

# メインブランチへのプッシュ時にパイプラインを起動する
on:
  push:
    branches:
      - main

jobs:
  build:
    name: ビルドフェーズ
    # GitHubが用意するUbuntu環境の仮想マシン上で実行する
    runs-on: ubuntu-latest
    steps:
      - name: コードをチェックアウト
        # GitHubにプッシュしたコードをこの仮想マシン上にクローンする
        uses: actions/checkout@v4

      - name: Node.jsをセットアップ
        # 仮想マシンにNode.jsをインストールする
        uses: actions/setup-node@v4
        with:
          node-version: 20
          # 2回目以降はキャッシュを使ってインストールを高速化する
          cache: 'npm'

      - name: 依存関係をインストール
        # package.jsonを読み取って必要なライブラリを全てインストールする
        run: npm install

      - name: アプリケーションをビルド
        # ReactコードをブラウザやFirebaseが読める静的ファイルに変換する
        # 変換後のファイルはdistフォルダに出力される
        run: npm run build

      - name: ビルドアーティファクトをアップロード
        # distフォルダを保存して次のジョブ(別の仮想マシン)に渡せるようにする
        # ジョブをまたいでファイルを受け渡すにはこのアーティファクト機能が必要
        uses: actions/upload-artifact@v4
        with:
          name: build-files
          path: dist
          # 1日後に自動削除する
          retention-days: 1

  test:
    name: テストフェーズ
    # buildジョブが成功してからテストを実行する(失敗したら止まる)
    needs: build
    runs-on: ubuntu-latest
    steps:
      - name: コードをチェックアウト
        uses: actions/checkout@v4

      - name: Node.jsをセットアップ
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - name: 依存関係をインストール
        run: npm install

      - name: テストを実行
        # vitestが全テストを実行する(1つでも失敗するとここで止まりデプロイされない)
        run: npm run test

  deploy:
    name: デプロイフェーズ
    # buildとtestの両方が成功した時だけデプロイを実行する
    needs: [build, test]
    runs-on: ubuntu-latest
    steps:
      - name: コードをチェックアウト
        uses: actions/checkout@v4

      - name: Node.jsをセットアップ
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - name: ビルドアーティファクトをダウンロード
        # buildジョブで保存したdistフォルダをこの仮想マシンに取得する
        uses: actions/download-artifact@v4
        with:
          name: build-files
          path: dist

      - name: 依存関係をインストール
        run: npm install

      - name: Firebase CLIをインストール
        # firebaseコマンドをこの仮想マシンでも使えるようにする
        run: npm install -g firebase-tools

      - name: Googleクレデンシャルを準備
        run: |
          # Secretsに登録したBase64エンコード済みの鍵をデコードしてファイルに保存する
          echo ${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }} | base64 -d > $HOME/private-key.json

      - name: Firebaseにデプロイ
        run: |
          # 鍵ファイルの場所をFirebaseに伝える環境変数を設定する
          export GOOGLE_APPLICATION_CREDENTIALS=$HOME/private-key.json
          export FIREBASE_EXPERIMENTS_ENABLED=true
          # Hostingだけをデプロイする(他のFirebase機能には触らない)
          firebase deploy --only hosting

      - name: 秘密鍵ファイルを削除
        # if: always()でデプロイが失敗しても必ずこのステップを実行する
        # 鍵ファイルを仮想マシン上に残さないようにする
        if: always()
        run: rm $HOME/private-key.json

6.1 ジョブを分ける理由

ビルド・テスト・デプロイを別々のjobsに分けているのがポイントです。
各ジョブは別々の仮想マシン上で動くので、1つにまとめてしまうとテストだけが偶発的に失敗した場合でもビルドからやり直しになってしまうようです。
ジョブを分けておけば失敗したジョブからだけリトライできるので、時間の節約になるみたいです。

jobs:
  test:
    # buildジョブが成功した後にだけ実行する
    needs: build

  deploy:
    # buildとtestの両方が成功した後にだけ実行する
    needs: [build, test]

6.2 アーティファクトで成果物を受け渡す

各ジョブは別々の仮想マシン上で動くため、ビルドで作ったdistフォルダをそのまま次のジョブに渡すことはできないようです。
actions/upload-artifactで保存してactions/download-artifactで受け取ることで、ジョブをまたいで成果物を渡せるみたいです。

まとめ

今回はReact + TypeScript + VitestとGitHub Actions・Firebaseを使ったCI/CDパイプラインを構築する流れを学びました。
プッシュするだけでテストが走り自動でデプロイされる仕組みが作れるとわかり、「CI/CDは難しい」という思い込みが崩れた感覚がありました。

今回の気づき

一番印象的だったのは、テストコードが「仕様書」として機能するという考え方です。
テストの説明文を読むだけでアプリの仕様が把握できるように書いておくことで、誰かが誤ってコードを変更した場合も自動テストがそれを検知してCIが止まるので、デプロイ前にバグに気づける仕組みが作れるみたいです。
また各ジョブを別々に分けることで、失敗した箇所だけリトライできるという設計の考え方も、パイプラインを実際に組んで初めて実感できました。

ハマりやすいポイント

  • テストファイルでJSXを使う場合は拡張子を.tsxにしないとエラーになります。
  • サービスアカウントの鍵ファイルは必ずBase64でエンコードしてからSecretsに登録する必要があります。
  • firebase deploy --only hostingを使わないとデプロイジョブ内で再ビルドが走ってしまい、アーティファクトが無駄になるようです。
2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?