1. はじめに
今回は以下のQiitaロードマップの課題2に取り組みました。
課題1で作ったReactの学習記録アプリは、リロードするとデータが消えてしまう問題がありました。
そこで今回はSupabaseを使ったデータ永続化を実装し、さらにFirebase Hostingへのデプロイ・GitHub ActionsによるCI/CDパイプラインの構築・Vitestを使った自動テストまでを順番に実装してみました。
課題として与えられた実装項目は以下のとおりです。
- Supabaseのプロジェクト作成とテーブル設計
- テストデータの作成と一覧表示・ローディング表示
- 登録ボタンでSupabaseにデータを追加する
- 削除ボタンでSupabaseからデータを削除する
- FirebaseのアカウントとプロジェクトをつくってHostingにデプロイする
- GitHub ActionsでHello Worldしてからパイプラインを構築する
- VitestとReact Testing Libraryを導入して自動テストを書く
- パイプラインにCIを追加する
それぞれ実装した内容を順番にまとめていきます。
2. Supabaseでデータを永続化する
ここではSupabaseのテーブル作成から、ReactコンポーネントへのCRUD実装までの流れをまとめます。
Supabase JavaScript クライアントのAPIリファレンスは以下で確認できます。
2.1 テーブルの設計
Supabaseのコンソール上でTable Editorを使い、以下の構成でテーブルを作成しました。
| column | type | option |
|---|---|---|
| id | uuid | |
| title | varchar | non null |
| time | int4 | non null |
2.2 Recordコンポーネントの全体像
実装したコンポーネント全体は以下のとおりです。
import "./App.css";
import { useEffect, useState } from "react";
import { createClient } from "@supabase/supabase-js";
export const Record = () => {
const [text, setText] = useState("");
// timeは数値で管理する(初期値0にすることで未入力チェックをtime === 0で行える)
const [time, setTime] = useState(0);
// trueのときエラーメッセージを表示する
const [decision, setDecision] = useState(false);
// データ取得中はtrueにしてLoading...を表示する
const [isLoading, setIsLoading] = useState(true);
const [records, setRecord] = useState([]);
// .envに書いた接続情報を読み込んでSupabaseクライアントを生成する
const supabase = createClient(
import.meta.env.VITE_SUPABASE_URL,
import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY,
);
const addRecord = async () => {
// 学習内容または学習時間が未入力の場合はエラーを表示して処理を中断する
if (text === "" || time === 0) {
setDecision(true);
return;
} else {
setDecision(false);
}
// Supabaseのstudy-recordテーブルにデータを追加する
// .select()をチェーンすることで追加後のデータをdataとして受け取れる
const { data, error } = await supabase
.from("study-record")
.insert([{ title: text, time: time }])
.select();
if (error) {
console.error("データ追加エラー:", error);
return;
}
// prevで現在のStateを受け取り、追加したデータを末尾に結合する
setRecord((prev) => [...prev, data[0]]);
// 登録後にフォームを空にリセットする
setText("");
setTime(0);
};
const onCLickDelete = async (id) => {
// idが一致するレコードだけを削除する(フィルターなしだと全件削除されるので注意)
await supabase.from("study-record").delete().eq("id", id);
// 削除したidを除外してStateを更新する
setRecord((prev) => prev.filter((record) => record.id !== id));
};
// recordsの合計時間をreduceで計算する(第2引数の0は初期値)
const calcTime = records.reduce((a, b) => {
return a + b.time;
}, 0);
const getAllrecords = async () => {
// study-recordテーブルの全データを取得する
const { data } = await supabase.from("study-record").select();
setRecord(data);
// 取得完了後にローディングを終了する
setIsLoading(false);
};
// 第2引数が空配列[]のときはコンポーネントのマウント時に一度だけ実行される
useEffect(() => {
getAllrecords();
}, []);
return (
<>
<div>
<h1>学習記録一覧</h1>
<label htmlFor="content">学習内容</label>
<input
value={text}
onChange={(e) => setText(e.target.value)}
id="content"
/>
<br />
<label htmlFor="time">学習時間</label>
{/* input[type="number"]の値は文字列で渡されるのでNumber()で数値に変換してから保存する */}
<input
type="number"
value={time}
onChange={(e) => setTime(Number(e.target.value))}
id="time"
/>
時間
<p>入力されている学習内容:{text}</p>
<p>入力されている学習時間:{time}時間</p>
{/* isLoadingがtrueの間はLoading...を表示し、falseになったらリストを描画する */}
{isLoading ? (
<p>Loading...</p>
) : (
<ul>
{/* recordsが空のときは「データがありません」を表示する */}
{records.length ? (
records.map((record) => (
<li key={record.id}>
{record.title}:{record.time}時間{" "}
<button onClick={() => onCLickDelete(record.id)}>削除</button>
</li>
))
) : (
<p>データがありません</p>
)}
</ul>
)}
<button onClick={addRecord}>登録</button>
{/* decisionがtrueのときだけエラーメッセージを表示する */}
{decision && <p>入力されていない項目があります</p>}
<p>合計時間:{calcTime}/1000(h)</p>
</div>
</>
);
};
2.3 Supabaseクライアントの作成
Supabaseへのアクセスはコンポーネントのトップレベルでクライアントを生成することで行います。
// .envに書いた接続情報を読み込んでSupabaseクライアントを生成する
// Viteではprocess.envではなくimport.meta.envを使う
const supabase = createClient(
import.meta.env.VITE_SUPABASE_URL,
import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY,
);
接続情報は .env ファイルに VITE_SUPABASE_URL と VITE_SUPABASE_PUBLISHABLE_KEY として定義しておくみたいです。
ViteプロジェクトではViteが環境変数を import.meta.env 経由で提供するため、process.env ではなくこちらを使う点が重要だと理解しました。
VITE_プレフィックスを付けた環境変数のみがViteによってクライアントサイドに公開されます。
APIキーは.gitignoreで管理対象外にした.envファイルに書いておきましょう。
2.4 データの取得(初期表示)
マウント時に一度だけ全データを取得するために useEffect を使っています。
const getAllrecords = async () => {
// study-recordテーブルの全データを取得する
const { data } = await supabase.from("study-record").select();
setRecord(data);
// 取得完了後にローディングを終了する
setIsLoading(false);
};
// 第2引数が空配列[]のときはマウント時に一度だけ実行される
useEffect(() => {
getAllrecords();
}, []);
isLoading が true の間は「Loading...」を表示し、取得完了後にリストを描画する構成にしました。
2.5 データの追加
「登録」ボタンを押したときに insert でSupabaseにデータを保存します。
const addRecord = async () => {
// 学習内容または学習時間が未入力の場合はエラーを表示して処理を中断する
if (text === "" || time === 0) {
setDecision(true);
return;
} else {
setDecision(false);
}
// Supabaseのstudy-recordテーブルにデータを追加する
// .select()をチェーンすることで追加後のデータをdataとして受け取れる
const { data, error } = await supabase
.from("study-record")
.insert([{ title: text, time: time }])
.select();
if (error) {
console.error("データ追加エラー:", error);
return;
}
// prevで現在のStateを受け取り、追加したデータを末尾に結合する
setRecord((prev) => [...prev, data[0]]);
// 登録後にフォームを空にリセットする
setText("");
setTime(0);
};
time の初期値を数値の 0 にしているため、未入力チェックが time === 0 で正しく機能するみたいです。
input[type="number"] の値は文字列で渡されるので、onChange で Number() に変換してから保存しているのがポイントです。
insert()はデフォルトでは追加したデータを返しません。
.select()をチェーンすることで追加後のデータをdataとして受け取れるみたいです。
2.6 データの削除
「削除」ボタンを押すと、対象の id でSupabaseから該当レコードを削除します。
const onCLickDelete = async (id) => {
// idが一致するレコードだけを削除する(フィルターなしだと全件削除されるので注意)
await supabase.from("study-record").delete().eq("id", id);
// 削除したidを除外してStateを更新する
setRecord((prev) => prev.filter((record) => record.id !== id));
};
.eq("id", id) で削除対象を絞り込んでいます。
Supabase側の削除が完了したあと、setRecord でローカルのStateも同期させる構成にしました。
delete()にフィルターを指定しないと、テーブルの全レコードが削除されてしまいます。
必ず.eq()などのフィルターとセットで使いましょう。
2.7 合計時間の計算
登録されている学習記録の合計時間は reduce で計算しています。
// recordsの合計時間をreduceで計算する(第2引数の0は初期値)
const calcTime = records.reduce((a, b) => {
return a + b.time;
}, 0);
reduce の第2引数に 0 を渡すことで、records が空配列のときも 0 が返るようになっています。
3. Vitestで自動テストを書く
ここでは、VitestとReact Testing Libraryを使ったコンポーネントテストの実装をまとめます。
公式ドキュメントは以下から確認できます。
3.1 セットアップファイルの構成
vitest.config.js はVitestの動作環境を定義するファイルです。
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
// jsdomを使うことでNode.js上でもDOMを操作できるようになる
environment: "jsdom",
// describe / test / expect をimportなしで使えるようになる
globals: true,
// テスト実行前に読み込むファイルを指定する
setupFiles: ["./vitest-setup.js"],
},
});
vitest-setup.js では @testing-library/jest-dom を読み込むことで、toBeInTheDocument() などのカスタムマッチャーを全テストで使えるようにしています。
// toBeInTheDocument()などのDOM用マッチャーを使えるようにする
import "@testing-library/jest-dom";
3.2 テストファイルの全体像
テストファイルは以下の構成になっています。
import { describe, expect, test, vi } from "vitest";
import { fireEvent, render, screen, within } from "@testing-library/react";
import "@testing-library/jest-dom";
import { Record } from "../Record";
import { createClient } from "@supabase/supabase-js";
// Supabaseモジュール全体をモックに差し替える(テスト中は実際のDBに接続しない)
vi.mock("@supabase/supabase-js", () => ({
createClient: vi.fn(),
}));
describe("Record", () => {
test("アプリタイトルが表示されている", () => {
// テストごとにSupabaseクライアントの返り値を定義する
createClient.mockReturnValue({
// mockReturnThis()でメソッドチェーンを再現する
from: vi.fn().mockReturnThis(),
insert: vi.fn().mockReturnThis(),
// mockResolvedValue()で非同期の返り値を定義する
select: vi.fn().mockResolvedValue({ data: [], error: null }),
});
render(<Record />);
// headingロールでh1要素を取得して、DOMに存在するか確認する
expect(
screen.getByRole("heading", { name: "学習記録一覧" }),
).toBeInTheDocument();
});
test("学習内容を登録できてリストに表示されている", async () => {
createClient.mockReturnValue({
from: vi.fn().mockReturnThis(),
insert: vi.fn().mockReturnThis(),
// mockResolvedValueOnce()で呼び出しごとに異なる値を返す
// 1回目:初期表示時の空データ、2回目:登録後のデータ
select: vi
.fn()
.mockResolvedValueOnce({ data: [], error: null })
.mockResolvedValueOnce({
data: [{ title: "学習内容", time: 1 }],
error: null,
}),
});
render(<Record />);
// labelと紐づいた入力欄をロールで取得する
const inputContent = screen.getByRole("textbox", { name: "学習内容" });
// type="number"の入力欄はspinbuttonロールになる
const inputTime = screen.getByRole("spinbutton", { name: "学習時間" });
const addButton = screen.getByRole("button", { name: "登録" });
// フォームに値を入力して登録ボタンをクリックする
fireEvent.change(inputContent, { target: { value: "学習内容" } });
fireEvent.change(inputTime, { target: { value: 1 } });
fireEvent.click(addButton);
// findByRole()は要素が現れるまで非同期で待ってくれる(awaitが必要)
const list = await screen.findByRole("list");
// within()でリスト内だけを対象にテキストを確認する
expect(within(list).getByText("学習内容:1時間")).toBeInTheDocument();
});
test("登録した学習内容を消すことができる", async () => {
createClient.mockReturnValue({
from: vi.fn().mockReturnThis(),
insert: vi.fn().mockReturnThis(),
select: vi
.fn()
.mockResolvedValueOnce({ data: [], error: null })
.mockResolvedValueOnce({
data: [{ id: 1, title: "学習内容", time: 1 }],
error: null,
}),
// deleteとeqもチェーンできるようにモックで定義する
delete: vi.fn().mockReturnThis(),
eq: vi.fn().mockResolvedValue([]),
});
render(<Record />);
const inputContent = screen.getByRole("textbox", { name: "学習内容" });
const inputTime = screen.getByRole("spinbutton", { name: "学習時間" });
const addButton = screen.getByRole("button", { name: "登録" });
fireEvent.change(inputContent, { target: { value: "学習内容" } });
fireEvent.change(inputTime, { target: { value: 1 } });
fireEvent.click(addButton);
// 削除ボタンが表示されるまで非同期で待つ
const removeButton = await screen.findByRole("button", { name: "削除" });
fireEvent.click(removeButton);
// 削除後にリストが空になったことを確認する
expect(await screen.findByText("データがありません")).toBeInTheDocument();
});
test("入力しないで登録を押すとエラーが表示される", () => {
createClient.mockReturnValue({
from: vi.fn().mockReturnThis(),
insert: vi.fn().mockReturnThis(),
select: vi.fn().mockResolvedValue({ data: [], error: null }),
});
render(<Record />);
const addButton = screen.getByRole("button", { name: "登録" });
// 何も入力せずに登録ボタンをクリックする
fireEvent.click(addButton);
// エラーメッセージがDOMに存在するか確認する
expect(
screen.getByText("入力されていない項目があります"),
).toBeInTheDocument();
});
});
3.3 Supabaseのモック化
テスト環境では実際のSupabaseに接続しないよう、vi.mock() でモジュールごと差し替えます。
// Supabaseモジュール全体をモックに差し替える(テスト中は実際のDBに接続しない)
vi.mock("@supabase/supabase-js", () => ({
createClient: vi.fn(),
}));
各テストの先頭で createClient.mockReturnValue() を使い、from や select などのメソッドチェーンをモックで再現しています。
createClient.mockReturnValue({
// mockReturnThis()でメソッドチェーンを再現する(これがないとundefinedになる)
from: vi.fn().mockReturnThis(),
insert: vi.fn().mockReturnThis(),
// mockResolvedValue()で非同期の返り値を定義する
select: vi.fn().mockResolvedValue({ data: [], error: null }),
});
.mockReturnThis() を使うことで .from().insert().select() のようなチェーンが再現できます。
これがないとチェーン先が undefined になってテストがエラーになるみたいです。
mockResolvedValueは何度呼んでも同じ値を返しますが、mockResolvedValueOnceを使うと呼び出しごとに異なる値を返せます。
初回のselect()で既存データを取得し、登録後の2回目で新しいデータを返すようなケースではこちらを使うみたいです。
3.4 テストクエリのポイント
React Testing Libraryでは、DOMのロールを使った要素取得が推奨されているみたいです。
// h1などの見出し要素はheadingロールで取得する
screen.getByRole("heading", { name: "学習記録一覧" });
// labelと紐づいたテキスト入力はtextboxロールで取得する
screen.getByRole("textbox", { name: "学習内容" });
// type="number"の入力欄はspinbuttonロールになる(textboxではないので注意)
screen.getByRole("spinbutton", { name: "学習時間" });
type="number" の入力欄のロールが spinbutton になるのは最初気づきにくいポイントでした。
非同期で要素が表示されるケースでは findByRole や findByText を使います。
// findByRole()は要素が現れるまで非同期で待ってくれる(awaitが必要)
const list = await screen.findByRole("list");
// within()でリスト内だけを対象にテキストを確認する
expect(within(list).getByText("学習内容:1時間")).toBeInTheDocument();
getByRole は同期的に要素を探しますが、findByRole は要素が表示されるまで非同期で待ってくれるみたいです。
4. GitHub ActionsでCI/CDパイプラインを構築する
ここでは、GitHub ActionsのYAMLファイルを使ったCI/CDの構成をまとめます。
GitHub ActionsのワークフローのYAML構文については以下が参考になります。
Firebase Hostingの公式ドキュメントはこちらです。
4.1 Hello Worldワークフロー
まず最小構成のワークフローを作成してGitHub Actionsの動作を確認しました。
課題でも「最初にHello Worldできること」が指定されていたので、この手順で環境の疎通確認をしました。
# ワークフローの名前(GitHubのActionsタブに表示される)
name: Hello World
on:
# GitHubのActionsタブから手動でワークフローを実行できるようにする
workflow_dispatch:
jobs:
say_hello:
# GitHubが用意したUbuntu環境で実行する
runs-on: ubuntu-latest
steps:
# echoコマンドでHello Worldを出力するだけのシンプルな確認用ステップ
- run: echo "Hello World"
workflow_dispatch を指定すると、GitHubのActionsタブから手動でワークフローを実行できます。
push前に動作確認できるので、最初の疎通確認に便利でした。
4.2 CI/CDパイプラインの全体構成
本番向けのパイプラインは以下の3フェーズで構成しています。
課題では「mainへのpush時にFirebaseへ自動デプロイ」と「パイプラインへのCI追加」が求められていたため、build → test → deployの順で依存関係を持たせた構成にしました。
# ワークフローの名前(GitHubのActionsタブに表示される)
name: record App CI/CD Pipeline
on:
push:
# mainブランチにpushされたときだけパイプラインを実行する
branches: [main]
jobs:
# フェーズ1:アプリをビルドする
build:
name: Build Phase
# GitHubが用意したUbuntu環境で実行する
runs-on: ubuntu-latest
steps:
# リポジトリのコードをランナー(実行環境)にチェックアウトする
- name: Checkout code
uses: actions/checkout@v4
# Node.jsのセットアップ(バージョン指定とnpmキャッシュの有効化)
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
# package.jsonをもとに依存パッケージをインストールする
- name: Install dependencies
run: npm install
# npm run buildでdistフォルダにビルド成果物を生成する
- name: Build application
run: npm run build
# ビルドしたdistフォルダをartifactとして保存する
# deployフェーズで再ビルドせずにこのファイルをそのまま使う
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: build-files
path: dist/
# artifactの保持期間(1日後に自動削除される)
retention-days: 1
# フェーズ2:自動テストを実行する
test:
name: Test Phase
runs-on: ubuntu-latest
# buildが成功してからtestを実行する(失敗したら実行しない)
needs: build
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- name: Install dependencies
run: npm install
# npm run testでVitestのテストを実行する
- name: Run tests
run: npm run test
# フェーズ3:Firebase Hostingにデプロイする
deploy:
name: Deploy Phase
runs-on: ubuntu-latest
# buildとtestの両方が成功してからdeployを実行する
needs: [build, test]
steps:
- name: Checkout code
uses: actions/checkout@v4
# buildフェーズでアップロードしたartifactをダウンロードする
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
name: build-files
path: dist/
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
# Firebase CLIをグローバルインストールしてfirebaseコマンドを使えるようにする
- name: Install Firebase CLI
run: npm install -g firebase-tools
- name: Install dependencies
run: npm install
# GitHubのSecretsに登録したBase64の秘密鍵をデコードしてJSONファイルに書き出す
- name: Prepare Google Application Credentials
run: |
echo ${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }} | base64 --decode > $HOME/private-key.json
- name: Deploy Firebase
run: |
# 書き出したJSONのパスを環境変数にセットしてFirebase CLIに認証させる
export GOOGLE_APPLICATION_CREDENTIALS=$HOME/private-key.json
# webframeworksの実験的機能を有効にする(Viteを使う場合に必要)
firebase experiments:enable webframeworks
# Firebase Hostingだけをデプロイする
firebase deploy --only hosting
# if: always()で成功・失敗に関わらず秘密鍵ファイルを必ず削除する
- name: Remove private key
if: always()
run: rm $HOME/private-key.json
4.3 フェーズの依存関係
needs キーワードで実行順序を制御しています。
test:
# buildが成功してからtestを実行する(失敗したら実行しない)
needs: build
deploy:
# buildとtestの両方が成功してからdeployを実行する
needs: [build, test]
ビルドが失敗したらテストとデプロイは実行されず、テストが失敗したらデプロイも止まる安全な構成になっているみたいです。
4.4 Firebase Hostingへのデプロイ
課題でも「ひと工夫が必要なのでコンソールのエラーを見ながら対処しましょう」と書かれていた部分で、サービスアカウントのJSONをそのままSecretsに貼るとパースエラーが起きることがわかりました。
Base64でエンコードしてから渡す方法で対処しました。
- name: Prepare Google Application Credentials
- # GitHubのSecretsに登録したBase64文字列をデコードしてJSONファイルに書き出す
# そのままSecretsに貼るとパースエラーになるためBase64経由で渡す
run: |
echo ${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }} | base64 --decode > $HOME/private-key.json
- name: Deploy Firebase
run: |
# 書き出したJSONのパスを環境変数にセットしてFirebase CLIに認証させる
export GOOGLE_APPLICATION_CREDENTIALS=$HOME/private-key.json
# webframeworksの実験的機能を有効にする(Viteを使う場合に必要)
firebase experiments:enable webframeworks
# Firebase Hostingだけをデプロイする(他のFirebase機能には影響しない)
firebase deploy --only hosting
# デプロイが成功しても失敗しても必ずこのステップを実行する
- name: Remove private key
if: always()
# ランナー上に秘密鍵ファイルが残らないように削除する
run: rm $HOME/private-key.json
if: always()を付けることで、デプロイが失敗してもランナー上に秘密鍵ファイルが残らないようにしています。
4.5 Firebase Hostingの設定
firebase.json ではSPAのルーティングに対応するため、全リクエストを index.html に向けるリライトを設定しています。
{
"hosting": {
"public": "dist",
"ignore": [
"firebase.json",
"**/.*",
"**/node_modules/**"
],
"rewrites": [
{
"source": "**",
"destination": "/index.html"
}
]
}
}
rewritesを設定しないと、/aboutなどのURLに直接アクセスしたとき404エラーになります。
SPAをFirebase Hostingにデプロイするときは必須の設定みたいです。
まとめ
今回はQiitaロードマップの課題2として、Supabaseによるデータ永続化・Vitestによる自動テスト・GitHub ActionsによるCI/CDパイプラインの構築を一連の流れで実装しました。
課題の項目を一つずつ順番にこなしていく形だったので、それぞれの技術がどのようにつながっているかを体感しながら進められたと思います。
今回の気づき
一番の学びは、テストのためにSupabaseをモック化する設計でした。
vi.mock() でモジュールごと差し替えることで、外部サービスに依存せずにコンポーネントの振る舞いだけをテストできるみたいです。
また、needs による依存制御も、ビルドが通らなければデプロイしないという安全な流れを作るうえで重要な概念だと感じました。
ハマりやすいポイント
-
input[type="number"]の値は文字列で渡されるため、Number()で変換しないと未入力チェックが意図どおりに動かない -
mockReturnThis()を使わないとSupabaseのメソッドチェーンをモックで再現できず、テストがエラーになる - Firebaseの秘密鍵をそのままSecretsに入れるとパースエラーになるため、Base64エンコードして渡す必要がある