1. はじめに
今回はYouTubeの動画「【初心者完全版】0からReactでCI/CD構築までできるチュートリアル【GitHub Actions/Firebase/Vitest/TypeScript】」を参考に、React + TypeScript + ViteでシンプルなTodoアプリを作りながら、CI/CDパイプラインを構築するまでの流れを学びました。
「CI/CDって上級者向けでしょ」と思っていたのですが、個人開発レベルなら思ってたより全然シンプルに始められるみたいです。
- フロントエンド:React + TypeScript + Vite
- スタイリング:Tailwind CSS
- 自動テスト:Vitest + Testing Library
- デプロイ先:Firebase Hosting
- CI/CDパイプライン:GitHub Actions
2. CI/CDとは
2.1 CIとCDの違い
**CI(継続的インテグレーション)**とは、コードをGitHubにプッシュした瞬間に自動でビルドとテストが走る仕組みのようです。
**CD(継続的デリバリー)**とは、テストが全て通ったら自動でデプロイまで行う仕組みのようです。
この2つが組み合わさることで、「コードを書く→プッシュ→自動テスト→自動デプロイ→世界に公開」という流れが全て自動化されるみたいです。
2.2 CI/CDがない場合の問題
CI/CDがない現場だと、半年間コードを書き続けてまとめてリリースする、いわゆるビッグバンリリースになりがちなようです。
その結果、手動テストでバグが大量に見つかってリリースが延期になるといった問題が起きやすいと理解しました。
CI/CDを導入すると細かい単位でデプロイするので、バグが見つかっても影響範囲が狭く済むのが大きなメリットみたいです。
3. Todoアプリの実装
今回作った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)
CI/CDを構築するうえで自動テストは欠かせない要素です。
テストが通ったことを条件にデプロイするので、テストがなければCI/CDは成立しないと理解しました。
4.1 パッケージのインストールと役割
// 開発用パッケージとしてインストール(本番ビルドには含まれない)
npm install -D vitest @testing-library/react @testing-library/dom @testing-library/jest-dom jsdom
インストールするパッケージの役割はそれぞれ以下のようです。
vitest
vitestはViteと相性の良いテストフレームワークで、テスト全体を動かすエンジン部分になります。
test・describe・expectといったテストの骨格を作る関数が使えるようになります。
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.tsでenvironment: '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へのデプロイ
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パイプラインを構築する
いよいよ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を使わないとデプロイジョブ内で再ビルドが走ってしまい、アーティファクトが無駄になるようです。






