70
59

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【初心者完全版】0からReactを始めてCI/CD構築までできるチュートリアル【TypeScript/GitHubActions/Vitest/Firebase】

Last updated at Posted at 2025-09-15

thumbnails.png

はじめに

こんにちは、Watanabe Jin(@Sicut_study)です。

これまでReactで色々なアプリを開発したり、JISOUを通して多くのエンジニアに教えてきました。今ではある程度技術的にも詳しくなってきましたが、最初からある程度できたわけではありません。

特に「Reactに詳しくなれたな」「技術力が身についたな」「可能性を感じられた」と思った瞬間は間違いなくCI/CDパイプラインが構築できるようになったことでした。
未経験からエンジニアになるためにはCI/CDの理解はほぼ必須の時代です。

しかしCI/CDの経験がないどころか、CI/CDをよく理解できないという人も多いです。
実際に私のコミュニティでもこのような投稿をしていたメンバーがいました。

image.png

私も駆け出しの頃にCI/CDを挑戦するときに全く同じことを考えましたが、CI/CDについて基礎からしっかりと解説した上で CI (テスト)CD (デプロイ) について具体的なチュートリアルまで解説しているサイトはおそらく今もありません。

技術も違えば、使っているパイプライン環境も違います。
なので大抵の記事はCI/CDについてなんとなくイメージができる程度なのです。

今回はそんなCI/CDを初心者でも理解しながら、そして実際に0から構築しながらその素晴らしさを理解できるようなチュートリアルを作成しました。
もし自分で構築した経験がないのであれば、このチュートリアルはあなたのエンジニア人生を大きく変えるきっかけになるはずです。

ぜひ楽しみながらモダン開発(DevOps)を学んでいただければと思います。
最後までチュートリアルを行うとこのようなTodoアプリがスマホから触れるようになります。

名称未設定のデザイン (2).gif

動画教材も用意しています

こちらの教材にはより詳しく解説した動画もセットでご用意しています。
テキスト教材でわからない細かい箇所があれば動画も活用ください。

対象者

  • Reactを始めたい人
  • 自分の作ったサイトを公開してみたい人
  • モダンな開発方式を知りたい
  • JavaScriptの基本を理解している
  • 自動テストのプラクティスを知りたい

このチュートリアルはHTML/CSS/JavaScriptがわかる方であれば2時間程度でできます。

CI/CDとは?

CI/CDとは、 

CI(Continuous Integration:継続的インテグレーション)
CD(Continuous Deployment/Delivery:継続的デプロイメント/デリバリー)

の略称でソフトウェア開発におけるビルド、テスト、デプロイのプロセスを自動化する手法です。

image.png

開発者はコードを書いたらある程度タスクが終わった単位でGitHubなどのリポジトリにコードをコミット/プッシュします。するとGitHub上でパイプライン(ワークフロー)が起動して以下の手順を行います。

CI (継続的インテグレーション)

コードをビルドします。つまりReactのコードを機械が読める0,1の値に翻訳をします。
ここで文法的におかしい記述などがあるとビルドに失敗してパイプラインは止まってしまいます。

ビルドしたコードに対して私たちが事前に書いておいたテストを実行します。
テストではアプリの正しい動き(TODOを追加したら1つ増えて表示されるなど)をテストしています。

テストではアプリの要件がすべて書かれているので仮にテストが失敗した場合は要件を満たせていないためバグのあるアプリと判断できます。

ビルドと自動テストが正しく通ったことでコミットしたコードは本番に反映させてもよいコードと判断ができます。
これでCD(デプロイ)する準備が整ったことになります。

テストは網羅的に行っていることが前提となります。
もしテストしていない箇所がある場合はコードの変更が影響してバグが発生する可能性があります。
網羅的に自動テストが書いてあるなら自動テストが通った=すべての要件を満たせているバグのないコードと判断ができます

CD (継続的デリバリー)

CIで準備が整ったコードを自動的でデプロイするのが継続的デリバリーです。
デプロイは手動で行うことも多いですが、手動だとマニュアルのようなものがあったり時間がかかったりします。

CDを用意することで誰もがコードをリポジトリにコミットするだけでデプロイまですることが可能です。

CI/CDがない辛さ

このような現場見たことないですか?聞いたことないですか?

image.png

この会社では半年に一回、全社を巻き込んだ大規模なリリースを行います。
半年に一度しかリリースをしないため、前回のリリース手順書を引っ張り出してきて手動でリリース作業を進めます。しかし、この半年間でインフラが変わっていたり、新しいツールが導入されていたりで、手順書の7割は既に古い情報になっています。

「あれ?このサーバー、もう使ってないよね?」
「このコマンド、エラーが出るんだけど...」
「えーっと、ここの設定は確か...」

リリース当日は深夜2時から開始。10人以上のエンジニアが会議室に集まり、手順書と現実のギャップに四苦八苦しながら作業を進めます。途中で「あ、この設定ファイル、開発環境のパスになってる」という致命的な発見があり、一からやり直し。

なんとか朝6時にリリースを完了したものの、今度はテスターチームによる手動テストが始まります。半年分の機能追加と修正が一気にリリースされるため、テスト項目は膨大。5人のテスターが手分けして、Excel表に手動でテスト結果を記録していきます。

そして運命の午前10時。テストチームから「基本的なログイン機能でエラーが発生してます」「商品検索が動きません」「決済処理が途中で止まります」という報告が次々と入ります。
これらは致命的なバグのため、緊急でリリース停止を決定。しかし、ロールバック手順書も半年前のもので、現在の環境に合わない部分があり、元に戻すのにも一苦労。

結果的に、サービスは丸一日停止。顧客からの問い合わせが殺到し、営業チームは謝罪対応に追われます。開発チームは徹夜でバグ修正を行い、3日後にようやく再リリース。

半年かけて開発した機能も、結局安定稼働するまでに1週間を要しました。
「次こそは順調にいくはず...」
誰もがそう思いながら、また次の半年間の開発が始まります。

CI/CDのメリット

image.png

CI/CDを導入することで先程の会社はこのようなことにならなかったかもしれません

手順書からの解放

Before: 半年前の手順書が7割古くて使えない
After: 全ての手順がコード化され、毎回自動実行されるため常に最新

手動手順書は人間が書いて人間が実行するため、環境変更の度に更新が必要でした。CI/CDでは、デプロイ手順自体がコードとして管理され、環境変更があれば自動的に検知・反映されます。

深夜作業からの解放

Before: 深夜2時から10人で6時間の作業
After: ボタン一つで15分程度の自動デプロイ

手動リリースでは「何かあった時に対応できる人員」を確保するため大人数が必要でした。自動化により、日中の業務時間内に1人がボタンを押すだけでリリースが完了します。

早期バグ発見による品質向上

Before: 半年分の機能を一気にテストして致命的バグを発見
After: 小さな変更ごとに自動テストが実行され、問題を即座に発見

この会社では半年間開発してからテストするため、バグの原因特定が困難でした。CI/CDでは、コードを書く度に自動テストが走り、どの変更が問題を引き起こしたかが明確になります。

リスクの大幅軽減

Before: 全社を巻き込んだ大博打リリース
After: 小さなリリースを頻繁に行うため、1回あたりのリスクが激減

半年分の変更を一度にリリースすることは、大きな爆弾を抱えるようなものです。CI/CDでは週単位や日単位でリリースするため、問題が起きても影響範囲が限定的です。

ロールバックの容易さ

Before: ロールバック手順書も古くて戻すのに一苦労
After: ワンクリックで前のバージョンに即座に復旧

手動環境では「戻す作業」も複雑な手順が必要でした。CI/CDでは、過去のバージョンがすべて管理されており、問題発生時には数分で元の状態に戻せます。

顧客への影響最小化

Before: 丸一日のサービス停止で顧客からクレーム殺到
After: 問題があっても数分で復旧、顧客はほとんど気づかない

この会社のように長時間サービスが止まることで、顧客離れや信頼失墜のリスクがありました。CI/CDでは迅速な復旧により、ビジネスインパクトを最小限に抑えられます。

開発者の精神的負担軽減

Before: 「次こそは順調に...」という不安を半年間抱える
After: 毎回安心してリリースできる心の平穏

半年に一度の大リリースは、開発者にとって大きなストレスでした。CI/CDにより、リリースが日常的な作業になり、精神的負担が大幅に軽減されます。

ビジネス競争力の向上

Before: 新機能を半年間ため込んでからリリース
After: 開発完了後すぐにユーザーに価値を提供

この会社では半年間ユーザーは新機能を使えませんでした。CI/CDにより、競合他社より早く新機能を市場に投入でき、ビジネス優位性を確保できます。

このようにCI/CDを導入することで多くのメリットを受けることが可能です。

今回使用するスタック

image.png

ReactでTODOアプリをまずは開発します。
そのあとに開発したTODOアプリをVitestを利用して自動テストを書きます。

そのあとFirebase Hostingに手動でデプロイをしてデプロイができることを確認したあとに、GitHub Actionsを使ってビルド->テスト->デプロイを自動的に行うパイプラインを構築します。

1. 環境構築

まずはNode.jsの環境を用意しましょう。
Node.jsとはJavaScriptを実行するための開発環境です。JavaScriptをウェブブラウザだけでなく、パソコン上で直接動かせるようにするプログラムのことです。
ReactはJavaScriptのライブラリなので、JavaScriptを実行できる環境が必要です。

インストールは以下のサイトから行えます。

もしわからない方がいたらQiitaでインストール方法を調べるとたくさん記事が出てきますので、ここでは省略します。

インストールが終わったらインストールできているかを確認しましょう

node -v
v22.04

ここでエラーがでていなければNode.jsが正しくインストールされて使える状態です。

次にViteを使ってReact環境を構築します。
Viteは最新のフロントエンド開発ツールで、特に高速な開発環境を提供するビルドツールです。Viteを使ってReactを開発する理由は以下の3つです。

image.png

ReactはJavaScriptを誰にも書きやすくしたライブラリなので最後は純粋なJavaScriptに変換しないといけません。そこでViteを利用することでReactを純粋なJavaScriptに翻訳するために利用しています。

環境構築は公式サイトをみれば簡単にできます。

npm create vite@latest
Ok to proceed? (y) y


> npx
> "create-vite"

│
◇  Project name:
│  todo-cicd
│
◇  Select a framework:
│  React
│
◇  Select a variant:
│  TypeScript
│

cd todo-cicd
npm i
npm run dev

サーバーが起動したらhttp://localhost:5173にアクセスしてください

image.png

この画面が表示されたらReactの環境構築ができました

次にスタイリングのためにTailwindCSSを導入します。

TailwindCSSを使うことでクラスを当てるだけでCSSを利用することができます。
ドキュメント通りにインストールしましょう

npm install tailwindcss @tailwindcss/vite

vite.config.tsを修正します

vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";

// https://vite.dev/config/
export default defineConfig({
  plugins: [react(), tailwindcss()],
});

src/index.cssに読み込みのインポートを書きます

src/index.css
@import "tailwindcss";

これで設定は完了です。3のスタイリングで実際に設定完了しているかを確認するのでもしスタイルが効いていない場合は見直してみてください。

2. TODOアプリを開発する

まずはTODOアプリを開発していきます。
画面の開発はsrc/App.tsxを修正することで行えます。
まずは「Vite + React」の部分を変更してみましょう

App.tsx
import { useState } from "react";
import reactLogo from "./assets/react.svg";
import viteLogo from "/vite.svg";
import "./App.css";

function App() {
  const [count, setCount] = useState(0);

  return (
    <>
      <div>
        <a href="https://vite.dev" target="_blank">
          <img src={viteLogo} className="logo" alt="Vite logo" />
        </a>
        <a href="https://react.dev" target="_blank">
          <img src={reactLogo} className="logo react" alt="React logo" />
        </a>
      </div>
      {/* 修正 */}
      <h1>こんにちは、世界</h1>
      <div className="card">
        <button onClick={() => setCount((count) => count + 1)}>
          count is {count}
        </button>
        <p>
          Edit <code>src/App.tsx</code> and save to test HMR
        </p>
      </div>
      <p className="read-the-docs">
        Click on the Vite and React logos to learn more
      </p>
    </>
  );
}

export default App;

image.png

文字を変更するとViteが変更を検知して画面に反映してくれます。
それでは最初にTODOをテストデータで用意して一覧で表示してみましょう。
ReactではJavaScriptとHTMLを同時に書くことが可能です。いま書いてあるコードをすべて消して以下に変更します。

src:App.tsx
import "./App.css";

function App() {

  // ここがJavaScriptの部分
  const todos = [
    {
      id: 1,
      title: "Todo 1",
      completed: false,
    },
    {
      id: 2,
      title: "Todo 2",
      completed: false,
    },
    {
      id: 3,
      title: "Todo 3",
      completed: false,
    },
  ];

  console.log(todos);

  return (
    <>
      <div>ここがHTMLの部分</div>
    </>
  );
}

export default App;

画面を開いて「右クリック」「検証」からデベロッパーツールを開いて「Console」を見るとTodoが表示されるはずです。

image.png

それではTodoをすべて画面に表示してみましょう
ReactではHTML部分にJavaScriptを簡単に書くことができるのでmapを使って表示します。

src/App.tsx
import "./App.css";

function App() {
  const todos = [
    {
      id: 1,
      title: "Todo 1",
      completed: false,
    },
    {
      id: 2,
      title: "Todo 2",
      completed: false,
    },
    {
      id: 3,
      title: "Todo 3",
      completed: false,
    },
  ];

  return (
    <>
      <div>
        {todos.map((todo) => (
          <div key={todo.id}>{todo.title}</div>
        ))}
      </div>
    </>
  );
}

export default App;

image.png

HTMLの中に{ }を書くことでJavaScriptを使うことができます。

        {todos.map((todo) => (
          ここにHTMLを書く
        ))}

todos.mapをすることでそれぞれのtodoに対してHTMLを作ります
最後はHTML(JSX)の配列が描画されます。

        {todos.map((todo) => (
          <div key={todo.id}>{todo.title}</div>
        ))}

        // [<div key={1}>Todo1</div>, <div key={2}>Todo2</div>, <div key={3}>Todo3</div>]

Reactには「配列の中にJSX要素があったら、それらを順番に並べて表示する」という仕組みがあるのですべてのTodoが表示されるようになります。

次にTodoを追加できるような実装を行います。
まずはインプットフォームとボタンを追加しましょう

src/App.tsx
import "./App.css";

function App() {
  const todos = [
    {
      id: 1,
      title: "Todo 1",
      completed: false,
    },
    {
      id: 2,
      title: "Todo 2",
      completed: false,
    },
    {
      id: 3,
      title: "Todo 3",
      completed: false,
    },
  ];

  console.log(todos);

  return (
    <>
      <div>
        {todos.map((todo) => (
          <div key={todo.id}>{todo.title}</div>
        ))}
        <input type="text" name="title" placeholder="Add a todo" />
        <button type="submit">Add</button>
      </div>
    </>
  );
}

export default App;

HTMLを書いただけなので難しくはないと思います。

image.png

これではTODOの追加はできません。
TODOをステートで管理しましょう

ステート(state)とは、画面の状態を管理するものです。
例えば「現在何個のTODOがあるか」「インプットに何が入力されているか」といった変化する情報を記録しておく場所のことです。普通のJavaScript変数と違って、ステートが変更されるとReactが自動的に画面を更新してくれます。

image.png

image.png

ステートを使って書き換えてみましょう

src/App.tsx
import { useState } from "react";
import "./App.css";

function App() {
  const [title, setTitle] = useState("");
  const [todos, setTodos] = useState([
    {
      id: 1,
      title: "Todo 1",
      completed: false,
    },
    {
      id: 2,
      title: "Todo 2",
      completed: false,
    },
    {
      id: 3,
      title: "Todo 3",
      completed: false,
    },
  ]);

  return (
    <>
      <div>
        {todos.map((todo) => (
          <div key={todo.id}>{todo.title}</div>
        ))}
        <input
          type="text"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          placeholder="Add a todo"
        />
        <button>Add</button>
        <div>{title}</div>
      </div>
    </>
  );
}

export default App;

ここではステートを使うためにuseStateという関数を利用しています。

image.png

Todo以外にも今回はtitleというステートも追加しました

src/App.tsx
  const [title, setTitle] = useState("");

titleの値を画面にも表示するようにHTMLの中に変数titleを{}で書きました

        <div>{title}</div>

実際の画面でインプットフォームに入力をするとインプットフォームの属性であるonChangeイベントハンドラが実行されます。

image.png

          <input
            type="text"
            value={title}
            onChange={(e) => setTitle(e.target.value)}
            placeholder="Add a todo"
          />

onChangeはフォームの内容が変更されたときに以下の関数を実行します。

(e) => setTitle(e.target.value)

setTilteにe.target.valueつまりインプットフォームに入力された値でステートを更新しています。
実際に画面でタイピングをするとその値が画面に表示されるはずです。これがステートを更新したら画面が更新されるということです。

image.png

同じくTodoも追加したら画面に反映したいのでステートで用意しました。

  const [todos, setTodos] = useState([
    {
      id: 1,
      title: "Todo 1",
      completed: false,
    },
    {
      id: 2,
      title: "Todo 2",
      completed: false,
    },
    {
      id: 3,
      title: "Todo 3",
      completed: false,
    },
  ]);

useState()の中にある配列がステートの初期値となります。
それでは実際に値を追加できるようにボタンをクリックしたら追加する実装をしましょう

import { useState } from "react";
import "./App.css";

function App() {
  const [title, setTitle] = useState("");
  const [todos, setTodos] = useState([
    {
      id: 1,
      title: "Todo 1",
      completed: false,
    },
    {
      id: 2,
      title: "Todo 2",
      completed: false,
    },
    {
      id: 3,
      title: "Todo 3",
      completed: false,
    },
  ]);

  // 追加
  const handleAddTodo = () => {
    setTodos([
      ...todos,
      {
        id: todos.length + 1,
        title: title,
        completed: false,
      },
    ]);
    setTitle("");
  };

  return (
    <>
      <div>
        {todos.map((todo) => (
          <div key={todo.id}>{todo.title}</div>
        ))}
        <input
          type="text"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          placeholder="Add a todo"
        />
        {/* 修正 */}
        <button onClick={handleAddTodo}>Add</button>
        <div>{title}</div>
      </div>
    </>
  );
}

export default App;

ボタンが押されるとonClickというイベントハンドラに書かれた関数(handleAddTodo)が実行されます

        <button onClick={handleAddTodo}>Add</button>
  const handleAddTodo = (e: React.FormEvent) => {
    e.preventDefault();
    setTodos([
      ...todos,
      {
        id: todos.length + 1,
        title: title,
        completed: false,
      },
    ]);
    setTitle("");
  };

handleAddTodoではすこし特殊な書き方をしていますので細かく解説します。

setTodos([
  ...todos,
  新しいTODO
]);

この...todosの部分でスプレッド演算子を使って配列を展開しています。
例えば

const todos = [
  { id: 1, title: "買い物" },
  { id: 2, title: "掃除" }
];

// スプレッド演算子を使った場合
const newTodos = [...todos, { id: 3, title: "洗濯" }];

// 結果: 
// [
//   { id: 1, title: "買い物" },
//   { id: 2, title: "掃除" },
//   { id: 3, title: "洗濯" }
// ]

このようにすると既存の配列に対して新しい値を追加することができるようになります。
JavaScriptをやったことある人であれば、「なんでこんな書き方するの?」「pushでいいじゃん」そうおもうかもしれません

Reactではステートを直接変更してはいけないというルールがあります。

// 悪い例:元の配列を直接変更
todos.push(新しいTODO);
setTodos(todos);

// 良い例:新しい配列を作成
setTodos([...todos, 新しいTODO]);

直接変更すると、Reactが「配列が変わった」ことに気づかず、画面が更新されないのです。
なのでスプレッド演算子を利用して新しいTodoの配列を作成してその配列でステートを更新しています。

また最初のe.preventDefault()はブラウザのデフォルトの動作を止めるメソッドです。フォームが送信されると、通常ブラウザは以下の動作を行います

  1. ページをリロード(再読み込み)する
  2. フォームデータをサーバーに送信する

しかし、ReactのようなSPA(Single Page Application)では、ページをリロードするとTodoが初期値の配列でレンダリングされてしまうので再読込しないように防ぐことをしています。

実際に画面から確認すると新しいTodoが追加できるようになっています!

image.png

3. スタイルをあてる

今回はTaliwndCSSを用いてスタイルを当てていきます。
TailwindCSSについては解説をおこなわないので詳しいことを知りたい方は各自調べてみてください

src/App.tsx
import { useState } from "react";

type Todo = {
  id: number;
  title: string;
  completed: boolean;
};

function App() {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [title, setTitle] = useState("");

  const handleAddTodo = () => {
    if (title.trim()) {
      setTodos([...todos, { id: todos.length + 1, title, completed: false }]);
      setTitle("");
    }
  };

  const handleToggleTodo = (id: number) => {
    setTodos(
      todos.map((todo) =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    );
  };

  return (
    <div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 py-8 px-4">
      <div className="max-w-md mx-auto">
        <div className="bg-white rounded-lg shadow-lg p-6">
          <h1 className="text-3xl font-bold text-center text-gray-800 mb-6">
            📝 Todoアプリ!
          </h1>

          <div className="flex gap-2 mb-6">
            <input
              type="text"
              name="title"
              value={title}
              onChange={(e) => setTitle(e.target.value)}
              placeholder="新しいタスクを入力..."
              aria-label="新しいタスクを入力"
              className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
            />
            <button
              onClick={handleAddTodo}
              className="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors duration-200"
            >
              追加
            </button>
          </div>

          {todos.length === 0 ? (
            <div className="text-center text-gray-500 py-8">
              <p className="text-lg">タスクがありません</p>
              <p className="text-sm">新しいタスクを追加してください</p>
            </div>
          ) : (
            <ul className="space-y-3">
              {todos.map((todo) => (
                <li
                  key={todo.id}
                  className={`flex items-center gap-3 p-3 rounded-lg border transition-all duration-200 ${
                    todo.completed
                      ? "bg-gray-50 border-gray-200"
                      : "bg-white border-gray-300 hover:border-blue-300"
                  }`}
                >
                  <input
                    type="checkbox"
                    checked={todo.completed}
                    onChange={() => handleToggleTodo(todo.id)}
                    className="w-5 h-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500 focus:ring-2"
                  />
                  <span
                    className={`flex-1 ${
                      todo.completed
                        ? "line-through text-gray-500"
                        : "text-gray-800"
                    }`}
                  >
                    {todo.title}
                  </span>
                </li>
              ))}
            </ul>
          )}

          {todos.length > 0 && (
            <div className="mt-6 pt-4 border-t border-gray-200">
              <p className="text-sm text-gray-600 text-center">
                完了済み: {todos.filter((todo) => todo.completed).length} /{" "}
                {todos.length}
              </p>
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

export default App;

image.png

デザインのためにすこし実装を追加したので確認していきましょう。
まずはTODOの完了を記録しておくためのチェックボックスです。

                  <input 
                    type="checkbox" 
                    checked={todo.completed} 
                    onChange={() => handleToggleTodo(todo.id)}
                    className="w-5 h-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500 focus:ring-2"
                  />

チェックするかしないかはTodoのステートのcompletedがtrue/falseで判断しています。
またチェックをするとonChangeで関数を実行してチェックしたボタンのidを渡しています。

  const handleToggleTodo = (id: number) => {
    setTodos(todos.map((todo) => todo.id === id ? { ...todo, completed: !todo.completed } : todo))
  }

ここではsetTodosの中で、todosをmapしてidが一致しているtodoのcompletedを反転(trueならfalse)しています。idが一致しなかったらそのまま同じtodoを返しています。
mapすることで新しい配列が作成されるため、先程スプレッド演算子のところで話したルールをパスすることができるので更新ができます。

最後に完了したTodoを表示するようにしました。

          {todos.length > 0 && (
            <div className="mt-6 pt-4 border-t border-gray-200">
              <p className="text-sm text-gray-600 text-center">
                完了済み: {todos.filter(todo => todo.completed).length} / {todos.length}
              </p>
            </div>
          )}

完了済みのtodoの数はfilterを利用して配列からcompletedがtrueの値のtodoだけをフィルタリングして配列の要素数を表示することで実現しています。また&&を利用することでtodosがあるときだけ完了済みの部分を表示することができます

このままだとインプットフォームに入力をしていなくてもTodoの登録ができてしまうため、入力されていない場合は追加の処理をしないようにしました。(item.trim)

  const handleAddTodo = () => {
    if (title.trim()) {
      setTodos([...todos, { id: todos.length + 1, title, completed: false }]);
      setTitle("");
    }
  };

4. Vitestで自動テストを行う

先程作成したアプリに対して自動テストを書いてみましょう
まずはドキュメント通りにVitestを設定していきます。

npm install -D vitest

次にテストを実行するコマンドをpackage.jsonに追加しましょう

package.json
{
  "name": "todo-cicd",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "lint": "eslint .",
    "preview": "vite preview",
    "test": "vitest" // 追加
  },
  "dependencies": {
    "@tailwindcss/vite": "^4.1.11",
    "react": "^19.1.1",
    "react-dom": "^19.1.1",
    "tailwindcss": "^4.1.11"
  },
  "devDependencies": {
    "@eslint/js": "^9.32.0",
    "@types/react": "^19.1.9",
    "@types/react-dom": "^19.1.7",
    "@vitejs/plugin-react": "^4.7.0",
    "eslint": "^9.32.0",
    "eslint-plugin-react-hooks": "^5.2.0",
    "eslint-plugin-react-refresh": "^0.4.20",
    "globals": "^16.3.0",
    "typescript": "~5.8.3",
    "typescript-eslint": "^8.39.0",
    "vite": "^7.1.0",
    "vitest": "^3.2.4"
  }
}

これでnpm run testと実行するとvitestというコマンドが実行できるようになります。
それではVitestが正しく導入できているか簡単なテストを書いてみましょう

mkdir src/__tests__
touch src/__tests__/sample.test.ts
sample.test.ts
import { expect, test } from "vitest";

function sum(a: number, b: number): number {
  return a + b;
}

test("sum", () => {
  expect(sum(1, 2)).toBe(3);
});

Vitestを試すためにsumという関数を作ってテストを書きました。
このコードは「sum関数が正しく動作するかどうかを確認するテスト」です。

test()という関数でテストを作成しています。最初の"sum"という部分はテストの名前で、「このテストは何をチェックするものなのか」を表しています。テスト結果が表示される時に、この名前が使われるので分かりやすい名前をつけることが大切です。

次に、実際のテスト内容が書かれています。expect(sum(1, 2))の部分では、sum関数に1と2という引数を渡して実行し、その結果を「検証したい値」として設定しています。expectは「期待する」という意味で、「この結果がどうなるべきか期待している」ということを表現しています。

そして.toBe(3)の部分で、「期待する結果は3である」ということを指定しています。toBeは「〜であるべき」という意味のマッチャーと呼ばれる機能です。

つまり、このテスト全体では「sum関数に1と2を渡したら、結果は3になるはずだ」ということを確認しています。

もしsum関数が正しく足し算を行う関数なら、1+2=3なのでテストは成功します。しかし、もし関数にバグがあって違う結果が返されたら、テストは失敗してそのことを教えてくれます。

それではテストを実行してみましょう

npm run test

> vitest


 DEV  v3.2.4 /home/jinwatanabe/workspace/qiit/todo-cicd

 ✓ src/__tests__/sample.test.ts (1 test) 7ms
   ✓ sum 4ms

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  17:42:58
   Duration  661ms (transform 92ms, setup 0ms, collect 72ms, tests 7ms, environment 0ms, prepare 170ms)

 PASS  Waiting for file changes...
       press h to show help, press q to quit

テストが無事通りました。Vitestは正しく設定されていそうなのでqを押してテストを終了します。

ここでVitestだけでもJavaScriptの関数やロジックのテストは十分にできます。
しかし、Reactなどの画面をテストしようとするといくつかの課題に直面します。

「画面に正しく表示されているか」
「ボタンを押したら期待通りの動作をするか」
「ユーザーが入力した内容が反映されているか」

このようなテストはVitestだけではできません。
そこでReact Testing Libraryをインストールします。React Testing Libraryは「ユーザーの視点でテストを書く」というコンセプトで作られていて、実際のユーザーが画面を操作するのと同じようにテストを書けるようになります。

npm install --save-dev @testing-library/react @testing-library/dom @types/react @types/react-dom @testing-library/jest-dom jsdom
touch vitest.config.ts
touch vitest-setup.ts
vite.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./vitest-setup.ts'],
  },
}) 
vitest-setup.ts
import '@testing-library/jest-dom';
tsconfig.app.json
{
  "compilerOptions": {
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
    "target": "ES2022",
    "useDefineForClassFields": true,
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,

    /* Bundler mode */
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "verbatimModuleSyntax": true,
    "moduleDetection": "force",
    "noEmit": true,
    "jsx": "react-jsx",

    /* Linting */
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "erasableSyntaxOnly": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedSideEffectImports": true,
    "types": ["@testing-library/jest-dom"] // 追加
  },
  "include": ["src"]
}

それでは画面のテストを書いてみましょう
まずは「タイトル(Todoアプリ!)が表示されているか」をチェックしていきます。

image.png

touch src/__tests__/App.test.tsx

今回はJSXで書くので拡張子はtsxとしています。
先程はJavaScriptの関数だけだったのでtsにしていました。

src/__tests__/App.test.tsx
import { describe, expect, test } from "vitest";
import { render, screen } from "@testing-library/react";
import "@testing-library/jest-dom";
import App from "../App";

describe("App", () => {
  test("アプリタイトルが表示されている", () => {
    render(<App />);
    expect(
      screen.getByRole("heading", { name: "📝 Todoアプリ!" })
    ).toBeInTheDocument();
  });
});

まず、describe()という関数が使われています。

describe("App", () => {
  test("アプリタイトルが表示されている", () => {

  });
});

これは複数のテストをグループ化するための機能で、「Appコンポーネントに関するテスト群」という意味になります。ここにAppに関するテストを書いてまとめていきます

render(<App />)では、Appコンポーネントを仮想的に画面に表示しています。実際のブラウザではなく、テスト環境内でコンポーネントをレンダリングして、その結果を確認できる状態にしています

    render(<App />);

次に、screen.getByRole("heading", { name: "📝 Todoアプリ!" })の部分では、画面から特定の要素を探しています。

screen.getByRole("heading", { name: "📝 Todoアプリ!" })

getByRole("heading")は「見出し要素を探す」という意味で、nameオプションで「📝 Todoアプリ!」というテキストを持つ見出しを具体的に指定しています。

今回は実際のコードで以下のようにh属性にタイトルを設定しているのでこの要素を取得しています。

src/App.tsx
          <h1 className="text-3xl font-bold text-center text-gray-800 mb-6">
            📝 Todoアプリ!
          </h1>

最後の.toBeInTheDocument()は、見つけた要素が実際に画面に存在することを確認するマッチャーです。

npm run test

 ✓ src/__tests__/App.test.tsx (1 test) 198ms
   ✓ App > アプリタイトルが表示されている 192ms

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  18:01:10
   Duration  452ms

テストが無事通りました。それでは次にTodoを追加するテストを書いていきましょう

src/__tests__/App.test.tsx
import { describe, expect, test } from "vitest";
import { render, screen, fireEvent, within } from "@testing-library/react";
import "@testing-library/jest-dom";
import App from "../App";

describe("App", () => {
  test("アプリタイトルが表示されている", () => {
    render(<App />);
    expect(
      screen.getByRole("heading", { name: "📝 Todoアプリ!" })
    ).toBeInTheDocument();
  });

  test("TODOを追加することができる", () => {
    render(<App />);

    const input = screen.getByRole("textbox", { name: "新しいタスクを入力" });
    const addButton = screen.getByRole("button", { name: "追加" });

    fireEvent.change(input, { target: { value: "テストタスク" } });
    fireEvent.click(addButton);

    const list = screen.getByRole("list");
    expect(within(list).getByText("テストタスク")).toBeInTheDocument();
  });
});

まずは画面からインプットフォームとボタンを取得しています。

    const input = screen.getByRole("textbox", { name: "新しいタスクを入力" });
    const addButton = screen.getByRole("button", { name: "追加" });

そのあとにインプットフォームにTodoのタイトルを入力するためにfireEventを利用します。
これは実際のユーザーがキーボードで文字を入力するのと同じ動作です。

    fireEvent.change(input, { target: { value: "テストタスク" } });

そして入力したらボタンをクリックします。

   fireEvent.click(addButton);

リストに追加されたはずなのでTodoのリストをすべて取得して、その中に「テストタスク」が含まれているかをチェックします。

    const list = screen.getByRole("list");
    expect(within(list).getByText("テストタスク")).toBeInTheDocument();

within(list)という新しい機能が使われています。これは「指定した要素の中だけを検索範囲にする」という意味です。つまり、画面全体ではなく、先ほど取得したリスト要素の内部だけを対象にして要素を探すということです。

そしてwithin(list).getByText("テストタスク")で、リストの中から「テストタスク」というテキストを持つ要素を探しています。

npm run test


 ✓ src/__tests__/App.test.tsx (2 tests) 228ms
   ✓ App > アプリタイトルが表示されている 159ms
   ✓ App > TODOを追加することができる 66ms

 Test Files  1 passed (1)
      Tests  2 passed (2)
   Start at  18:13:13
   Duration  527ms

次にチェックボックスで完了ができるかのテストを書きます。

src/__tests__/App.test.tsx
import { describe, expect, test } from "vitest";
import { render, screen, fireEvent, within } from "@testing-library/react";
import "@testing-library/jest-dom";
import App from "../App";

describe("App", () => {
  test("アプリタイトルが表示されている", () => {
    render(<App />);
    expect(
      screen.getByRole("heading", { name: "📝 Todoアプリ!" })
    ).toBeInTheDocument();
  });

  test("TODOを追加することができる", () => {
    render(<App />);

    const input = screen.getByRole("textbox", { name: "新しいタスクを入力" });
    const addButton = screen.getByRole("button", { name: "追加" });

    fireEvent.change(input, { target: { value: "テストタスク" } });
    fireEvent.click(addButton);

    const list = screen.getByRole("list");
    expect(within(list).getByText("テストタスク")).toBeInTheDocument();
  });

  test("TODOを完了にすることができる", () => {
    render(<App />);

    const input = screen.getByRole("textbox", { name: "新しいタスクを入力" });
    const addButton = screen.getByRole("button", { name: "追加" });

    fireEvent.change(input, { target: { value: "完了テストタスク" } });
    fireEvent.click(addButton);

    const checkboxes = screen.getAllByRole("checkbox");
    const lastCheckbox = checkboxes[checkboxes.length - 1];
    fireEvent.click(lastCheckbox);

    expect(lastCheckbox).toBeChecked();
  });
});

Todoを1つ追加して追加した最後のTodoに対してクリックをしました。

    const lastCheckbox = checkboxes[checkboxes.length - 1];
    fireEvent.click(lastCheckbox);

チェックボックスがチェックされているかどうかはこのように判断できます

    expect(lastCheckbox).toBeChecked();

完了したTodoの数が表示されているかをテストします。

src/__tests__/App.test.tsx
import { describe, expect, test } from "vitest";
import { render, screen, fireEvent, within } from "@testing-library/react";
import "@testing-library/jest-dom";
import App from "../App";

describe("App", () => {
  test("アプリタイトルが表示されている", () => {
    render(<App />);
    expect(
      screen.getByRole("heading", { name: "📝 Todoアプリ!" })
    ).toBeInTheDocument();
  });

  test("TODOを追加することができる", () => {
    render(<App />);

    const input = screen.getByRole("textbox", { name: "新しいタスクを入力" });
    const addButton = screen.getByRole("button", { name: "追加" });

    fireEvent.change(input, { target: { value: "テストタスク" } });
    fireEvent.click(addButton);

    const list = screen.getByRole("list");
    expect(within(list).getByText("テストタスク")).toBeInTheDocument();
  });

  test("TODOを完了にすることができる", () => {
    render(<App />);

    const input = screen.getByRole("textbox", { name: "新しいタスクを入力" });
    const addButton = screen.getByRole("button", { name: "追加" });

    fireEvent.change(input, { target: { value: "完了テストタスク" } });
    fireEvent.click(addButton);

    const checkboxes = screen.getAllByRole("checkbox");
    const lastCheckbox = checkboxes[checkboxes.length - 1];
    fireEvent.click(lastCheckbox);

    expect(lastCheckbox).toBeChecked();
  });

  test("完了したTODOの数が表示されている", () => {
    render(<App />);

    const input = screen.getByRole("textbox", { name: "新しいタスクを入力" });
    const addButton = screen.getByRole("button", { name: "追加" });

    fireEvent.change(input, { target: { value: "タスク1" } });
    fireEvent.click(addButton);

    fireEvent.change(input, { target: { value: "タスク2" } });
    fireEvent.click(addButton);

    const checkboxes = screen.getAllByRole("checkbox");
    fireEvent.click(checkboxes[0]);

    expect(screen.getByText("完了済み: 1 / 5")).toBeInTheDocument();
  });
});

実は先程の実装でTodoがないときのバリデーションも実装しています。
これはテストデータを追加していると0個にできないので少しコードを修正します。

src/Appp.tsx
import { useState } from "react";

type Todo = {
  id: number;
  title: string;
  completed: boolean;
};

function App() {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [title, setTitle] = useState("");

  const handleAddTodo = () => {
    if (title.trim()) {
      setTodos([...todos, { id: todos.length + 1, title, completed: false }]);
      setTitle("");
    }
  };

  const handleToggleTodo = (id: number) => {
    setTodos(
      todos.map((todo) =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    );
  };

  const handleKeyPress = (e: React.KeyboardEvent) => {
    if (e.key === "Enter") {
      handleAddTodo();
    }
  };

  return (
    <div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 py-8 px-4">
      <div className="max-w-md mx-auto">
        <div className="bg-white rounded-lg shadow-lg p-6">
          <h1 className="text-3xl font-bold text-center text-gray-800 mb-6">
            📝 Todoアプリ!
          </h1>

          <div className="flex gap-2 mb-6">
            <input
              type="text"
              name="title"
              value={title}
              onChange={(e) => setTitle(e.target.value)}
              onKeyPress={handleKeyPress}
              placeholder="新しいタスクを入力..."
              aria-label="新しいタスクを入力"
              className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
            />
            <button
              onClick={handleAddTodo}
              className="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors duration-200"
            >
              追加
            </button>
          </div>

          {todos.length === 0 ? (
            <div className="text-center text-gray-500 py-8">
              <p className="text-lg">タスクがありません</p>
              <p className="text-sm">新しいタスクを追加してください</p>
            </div>
          ) : (
            <ul className="space-y-3">
              {todos.map((todo) => (
                <li
                  key={todo.id}
                  className={`flex items-center gap-3 p-3 rounded-lg border transition-all duration-200 ${
                    todo.completed
                      ? "bg-gray-50 border-gray-200"
                      : "bg-white border-gray-300 hover:border-blue-300"
                  }`}
                >
                  <input
                    type="checkbox"
                    checked={todo.completed}
                    onChange={() => handleToggleTodo(todo.id)}
                    className="w-5 h-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500 focus:ring-2"
                  />
                  <span
                    className={`flex-1 ${
                      todo.completed
                        ? "line-through text-gray-500"
                        : "text-gray-800"
                    }`}
                  >
                    {todo.title}
                  </span>
                </li>
              ))}
            </ul>
          )}

          {todos.length > 0 && (
            <div className="mt-6 pt-4 border-t border-gray-200">
              <p className="text-sm text-gray-600 text-center">
                完了済み: {todos.filter((todo) => todo.completed).length} /{" "}
                {todos.length}
              </p>
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

export default App;

まずはステートから初期値を消しました。

  const [todos, setTodos] = useState<Todo[]>([]);

初期値を消すとTypeScriptでは配列にどんな形のオブジェクトが入るのかわからなくなります。
例えば今回はtodo.titleという名前で呼び出していますが人によってはtodo.nameと呼び出してしまい画面が表示されなくなる可能性もあります。

そこでTypeScriptでは型というのを用意してuseStateに渡すことでtodoにはtitleがあり、nameを使おうとするとビルドのタイミングでエラーにすることができます。こうすることでバグのあるコードをデプロイするのを防ぐことが可能です。

type Todo = {
  id: number;
  title: string;
  completed: boolean;
}

改めてテストを実行すると以下のエラーが出ました

npm run test

 FAIL  src/__tests__/App.test.tsx > App > 完了したTODOの数が表示されている
TestingLibraryElementError: Unable to find an element with the text: 完了済み: 1 / 5. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.

Ignored nodes: comments, script, style

さきほどまでテストデータ3件が入っていましたが、それがなくなったのでトータルの件数の部分が変わったのです。
テストを直します。

src/___tests__/App.test.tsx
import { describe, expect, test } from "vitest";
import { render, screen, fireEvent, within } from "@testing-library/react";
import "@testing-library/jest-dom";
import App from "../App";

describe("App", () => {
  test("アプリタイトルが表示されている", () => {
    render(<App />);
    expect(
      screen.getByRole("heading", { name: "📝 Todoアプリ!" })
    ).toBeInTheDocument();
  });

  test("TODOを追加することができる", () => {
    render(<App />);

    const input = screen.getByRole("textbox", { name: "新しいタスクを入力" });
    const addButton = screen.getByRole("button", { name: "追加" });

    fireEvent.change(input, { target: { value: "テストタスク" } });
    fireEvent.click(addButton);

    const list = screen.getByRole("list");
    expect(within(list).getByText("テストタスク")).toBeInTheDocument();
  });

  test("TODOを完了にすることができる", () => {
    render(<App />);

    const input = screen.getByRole("textbox", { name: "新しいタスクを入力" });
    const addButton = screen.getByRole("button", { name: "追加" });

    fireEvent.change(input, { target: { value: "完了テストタスク" } });
    fireEvent.click(addButton);

    const checkboxes = screen.getAllByRole("checkbox");
    const lastCheckbox = checkboxes[checkboxes.length - 1];
    fireEvent.click(lastCheckbox);

    expect(lastCheckbox).toBeChecked();
  });

  test("完了したTODOの数が表示されている", () => {
    render(<App />);

    const input = screen.getByRole("textbox", { name: "新しいタスクを入力" });
    const addButton = screen.getByRole("button", { name: "追加" });

    fireEvent.change(input, { target: { value: "タスク1" } });
    fireEvent.click(addButton);

    fireEvent.change(input, { target: { value: "タスク2" } });
    fireEvent.click(addButton);

    const checkboxes = screen.getAllByRole("checkbox");
    fireEvent.click(checkboxes[0]);

    expect(screen.getByText("完了済み: 1 / 2")).toBeInTheDocument(); // 修正
  });
});

テストが通ったので画面を確認してみます

image.png

最初は0件なので「タスクがありません」という表示が出ています。
この実装はこちらで行っていました

src/App.tsx
          {todos.length === 0 ? (
            <div className="text-center text-gray-500 py-8">
              <p className="text-lg">タスクがありません</p>
              <p className="text-sm">新しいタスクを追加してください</p>
            </div>
          ) : (
          // 1件以上あるならこっち
          )

ここではもしtodosが0件ならタスクがない表示、1件以上あるならTodoを表示する実装をしています
それではテストを書いていきます

src/___tests__/App.test.tsx
import { describe, expect, test } from "vitest";
import { render, screen, fireEvent, within } from "@testing-library/react";
import "@testing-library/jest-dom";
import App from "../App";

describe("App", () => {
  test("アプリタイトルが表示されている", () => {
    render(<App />);
    expect(
      screen.getByRole("heading", { name: "📝 Todoアプリ!" })
    ).toBeInTheDocument();
  });

  test("TODOを追加することができる", () => {
    render(<App />);

    const input = screen.getByRole("textbox", { name: "新しいタスクを入力" });
    const addButton = screen.getByRole("button", { name: "追加" });

    fireEvent.change(input, { target: { value: "テストタスク" } });
    fireEvent.click(addButton);

    const list = screen.getByRole("list");
    expect(within(list).getByText("テストタスク")).toBeInTheDocument();
  });

  test("TODOを完了にすることができる", () => {
    render(<App />);

    const input = screen.getByRole("textbox", { name: "新しいタスクを入力" });
    const addButton = screen.getByRole("button", { name: "追加" });

    fireEvent.change(input, { target: { value: "完了テストタスク" } });
    fireEvent.click(addButton);

    const checkboxes = screen.getAllByRole("checkbox");
    const lastCheckbox = checkboxes[checkboxes.length - 1];
    fireEvent.click(lastCheckbox);

    expect(lastCheckbox).toBeChecked();
  });

  test("完了したTODOの数が表示されている", () => {
    render(<App />);

    const input = screen.getByRole("textbox", { name: "新しいタスクを入力" });
    const addButton = screen.getByRole("button", { name: "追加" });

    fireEvent.change(input, { target: { value: "タスク1" } });
    fireEvent.click(addButton);

    fireEvent.change(input, { target: { value: "タスク2" } });
    fireEvent.click(addButton);

    const checkboxes = screen.getAllByRole("checkbox");
    fireEvent.click(checkboxes[0]);

    expect(screen.getByText("完了済み: 1 / 2")).toBeInTheDocument();
  });

  test("TODOがない場合は空状態メッセージが表示される", () => {
    render(<App />);

    expect(screen.getByText("タスクがありません")).toBeInTheDocument();
    expect(
      screen.getByText("新しいタスクを追加してください")
    ).toBeInTheDocument();
  });

  test("空のTODOは追加されない", () => {
    render(<App />);

    const input = screen.getByRole("textbox", { name: "新しいタスクを入力" });
    const addButton = screen.getByRole("button", { name: "追加" });

    fireEvent.change(input, { target: { value: "" } });
    fireEvent.click(addButton);

    expect(screen.getByText("タスクがありません")).toBeInTheDocument();
  });
});

「初期表示でタスクがないのでその旨が表示されていること」
「タイトルがインプットフォームに入力されていない状態でボタンを押しても追加されないこと」

を行いました。インプットフォームが空のときにボタンを押すテストはバリデーションを確かめるテストで異常系のときに正しく動くかを確認しています。

5. Firebase Hostingにデプロイする

テストがすべて通って私たちの作ってきたTodoアプリをリリースする準備が整いました
Firebase Hostingを使ってまずは手動でデプロイをしてみましょう

Firebaseを開きます

image.png

「Get started in console」をクリック

image.png

「Firebase プロジェクトを作成する」をクリック

image.png

ここで名前は全世界で重複してはいけないのでかぶらないような名前を好きにつけてください

Geminiの設定とGoogle Analyticsの設定を聞かれるのですべてオフにして続行します

image.png

「プロジェクト作成」をクリックします

image.png

「続行」をクリック

image.png

左のマス目のようなマークをクリックして「Hosting」をクリック

image.png

「始める」をクリック

image.png

コマンドをインストールしていきます

npm install -g firebase-tools

インストールしたら「次へ」をクリック

image.png

プロジェクトをのディレクトリ(todo-cicd直下)でコマンドを実行します

firebase login

ログインの画面が開くので認証をしてください。

firebase init hosting

✔ Please select an option: Use an existing project
✔ Select a default Firebase project for this directory: cicd-todo-app 
(cicd-todo-app)
i  Using project cicd-todo-app (作成したプロジェクトを選択)
=== Hosting Setup
✔ Detected an existing Vite codebase in the current directory, should we use 
this? Yes
✔ In which region would you like to host server-side content, if applicable?
asia-east1 (Taiwan)
✔ Set up automatic builds and deploys with GitHub? No

ここではhostingをあとにつけくわえることで簡単にfirebaseのセットアップしています
するとfirebase.jsonという設定ファイルが作成されました

それでは手動でデプロイをしてみます

firebase deploy

✔  Deploy complete!

Project Console: https://console.firebase.google.com/project/cicd-todo-app/overview
Hosting URL: https://cicd-todo-app.web.app

完了するとHosting URLが発行されるのでアクセスします

image.png

手動でアプリをデプロイできました!
Hosting URLをスマホで打ち込めばTodoアプリをスマホからも利用することが可能です!

最後に.gitignoreに.firebaseは含めたくないので設定しておきましょう

.gitignore
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

node_modules
dist
dist-ssr
*.local

# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.firebase // 追加

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

ここからは4,5で行ったテスト->デプロイをCI/CDパイプラインで自動化します。
今回はGitHubにPushしたらパイプラインが実行されるように実装します。

GitHub Actionsではyamlというファイルにパイプラインの設定を書きます。

mkdir .github
mkdir .github/workflows
touch .github/workflows/pipeline.yaml

まずはビルドから自動テストまでを実行するパイプラインを構築します。

.github/workflows/pipeline.yaml
name: Todo App CI/CD Pipeline

on:
  push:
    branches: [main]

jobs:
  build:
    name: Build Phase
    runs-on: ubuntu-latest

    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

      - name: Build application
        run: npm run build

      - name: Upload build artifacts
        uses: actions/upload-artifact@v4
        with:
          name: build-files
          path: dist/
          retention-days: 1

  test:
    name: Test Phase
    runs-on: ubuntu-latest
    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

      - name: Run tests
        run: npm run test

GithubActionsはデフォルトで.github/workflowsの中にあるyamlファイルをパイプラインとして認識します。なのでフォルダ名は間違えないようにしてください

設定ファイルの書き方を説明します。
まずはパイプラインの名前とパイプラインが起動するタイミング、パイプラインの環境を設定しています

name: Todo App CI/CD Pipeline

on:
  push:
    branches: [main]

mainブランチにpushされたらパイプラインが起動します。
ここでそもそもパイプラインが起動するということがどういうことなのか解説します。

image.png

GitHub ActionsはmainブランチにpushされるとGitHubのサーバー上に仮想のマシン(パソコン)を用意します。そのパソコンにはGitだけインストールされた状態です。

まずは今回利用しているリポジトリからコードをクローンしてきます。
そしてNode.jsをインストールしてから、npm install -> npm run build -> npm run testとコマンドを実行していきます。

まるで私たちがこのチュートリアルで環境構築したことと同じことをしています。
別のパソコンで今私たちが開発したコードを動かそうとするイメージを持ってもらえるとよいです。

jobs:
  build:
    name: Build Phase
    runs-on: ubuntu-latest

    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

      - name: Build application
        run: npm run build

      - name: Upload build artifacts
        uses: actions/upload-artifact@v4
        with:
          name: build-files
          path: dist/

まずはjobsでが複数の作業(job)を定義する場所です。
今回はbuildとtestそして最後にdeployという3つのジョブを設定します。

最初にbuildジョブでやっていることを見ていきます

  build:
    name: Build Phase
    runs-on: ubuntu-latest

ジョブの名前とUbuntu(Linux)が搭載された新しいPCを準備しています。

    steps

stepsにかかれたステップが順番に実行されます。

      - name: Checkout code
        uses: actions/checkout@v4

GitHubリポジトリからソースコードをダウンロードしています。
@v4は使用するActionのバージョンでこのusesを使うことでこちらは意識しなくてもコードを取得することができます。
ツールのようなものだと思って大丈夫です。

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "npm"

次に同じ要領でNode.jsの20系をインストールします。
cacheを設定しておくことでこのパイプラインを再起動したときに長いNode.jsのインストールをキャッシュを利用して時間短縮が可能です。

      - name: Install dependencies
        run: npm install

      - name: Build application
        run: npm run build

依存関係をインストールしてビルドをしています。
もしここで構文的なエラーが発生したらパイプラインは停止します。

      - name: Upload build artifacts
        uses: actions/upload-artifact@v4
        with:
          name: build-files
          path: dist

最後にビルドした結果を保存します。
これをartifactと呼んでおり、後のジョブ(test, deploy)でダウンロードして利用することができます
build-filesという名前でdist/フォルダの内容を保存しています。

jobごとにパソコンを用意するため、次のtestジョブではまたリポジトリからコードをクローンしてビルドないといけません
しかしartifactをやり取りするだけでその時間を削減することができます。
retention-daysで保存期間を1日に設定しています

テストもここまで理解できれば同じことをしています。

  test:
    name: Test Phase
    runs-on: ubuntu-latest
    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

      - name: Run tests
        run: npm run test

それではGitHubにここまでの内容をpushしてみましょう
GitHubがわからない方は以下のチュートリアルを行ってから続きをしてください

するとGitHubの作成したリポジトリの「Actions」でパイプラインが回ります。
最後まで通って緑色になれば成功です

image.png

それでは次にFirebase Hostingへの自動デプロイをパイプラインで設定しましょう

.github/workflows/pipeline.yaml
name: Todo App CI/CD Pipeline

on:
  push:
    branches: [main]

jobs:
  build:
    name: Build Phase
    runs-on: ubuntu-latest

    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

      - name: Build application
        run: npm run build

      - name: Upload build artifacts
        uses: actions/upload-artifact@v4
        with:
          name: build-files
          path: dist/
          retention-days: 1

  test:
    name: Test Phase
    runs-on: ubuntu-latest
    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

      - name: Run tests
        run: npm run test

  deploy:
    name: Deploy Phase
    runs-on: ubuntu-latest
    needs: [build, test]

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - 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"

      - name: Install Firebase CLI
        run: npm install -g firebase-tools

      - name: Install dependencies
        run: npm install

      - name: Prepeare Google Application Credentials
        run: |
          echo ${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }} | base64 --decode > $HOME/private-key.json

      - name: Deploy Firebase
        run: |
          export GOOGLE_APPLICATION_CREDENTIALS=$HOME/private-key.json
          firebase experiments:enable webframeworks
          firebase deploy --only hosting
      - name: Remove private key
        if: always()
        run: rm $HOME/private-key.json

今回はneedsが設定されています。これはbuildとtestが終わってからdeployが実行されるということです。
buildまたはtestが通っていないということはアプリにバグがあるのでそのときには実行しないようにしています。

    needs: [build, test]

先程アップロードしたartifactをダウンロードします。

      
    - name: Download build artifacts
      uses: actions/download-artifact@v4
      with:
        name: build-files
        path: dist

そして私たちがfirebase Hostingにデプロイしたときと同じようにfirebase-toolsをインストールします

    - name: Setup Node.js
      uses: actions/setup-node@v4
      with:
        node-version: 20
        cache: 'npm'
        
    - name: Install Firebase CLI
      run: npm install -g firebase-tools

今回はViteを利用してfirebase deployを行っています。
本来であればビルド済みのファイルをそのままデプロイすればよいのですが、firebase.jsonの設定

firebase.json
{
  "hosting": {
    "source": ".",

このsource: .でデプロイをするとfirebase deployコマンドでビルドとデプロイをどちらもやってくれます。なので実際にはビルドファイルをartifactsにしなくてもかったのです。
しかし、ビルドは時間がかかるので本来はdeployフェーズではビルドは避けたいです。
ここはfirebase.jsonを書き換えるとできるので、各自やってみてください。今回はViteを使って無駄ではありますがビルドを行います。

Viteを使うためにnpmインストールを行います。

      - name: Install dependencies
        run: npm install

そして私たちは先程firebase loginをしてfirebase deployを行いましたが、パイプラインの中では認証トークンを使ってログインと同じようなことをします。
そのための認証トークンを作ることを行います

    - name: Prepeare Google Application Credentials
      run: |
        echo ${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }} | base64 --decode > $HOME/private-key.json

ここでsecrets.GOOGLE_APPLICATION_CREDENTIALSというのがでてきました。
これはGitHub上に設定する環境変数のことでsecrets.環境変数名で設定した値を利用することができます。

私たちはまだ設定をしていないので設定をしましょう
ここでGoogle Cloud Platformを開きます

右上のコンソールをクリック

image.png

左上のプロジェクト選択をクリックして作成したプロジェクトを選択

image.png

左メニューから「IAMと管理」→「サービスアカウント」をクリック

image.png

「サービスアカウント作成」をクリック

image.png

サービスアカウント名に「cicd-todos-service」と入力して「作成して続行」をクリック

image.png

「ロールを選択」から「Firebase Hosting管理者」を選択

image.png

「別のロールを追加」で「iam.serviceAccountUser」と検索して「サービスアカウントユーザー」を追加して「完了」をクリック

image.png

作成したサービスアカウントの右にある︙をクリックして「鍵を管理」をクリック

image.png

「キーを追加」「新しい鍵を作成」をクリック

image.png

「JSON」で「作成」クリック

image.png

すると鍵がダウンロードされます。私はtodo-cicd-bc8ce-34ba799f267b.jsonという名前で保存されました
この鍵の中にはユーザー情報などもすべて入っているので公開などはしないでください

このままだと公開されてしまっては危ないので暗号化をしようと思います。

base64 -w 0 あなたの鍵のファイルのパス.json > encoded_file.txt

encoded_file.txtの中身をGitHub Actionsのシークレットに設定します
Githubを開いてリポジトリの「Setting」を開きます

「Secrets and Variables」→「Actions」をクリック

image.png

「New Repository Secret」をクリック

image.png

パイプラインで利用する2つの環境変数を設定します。

Name: GOOGLE_APPLICATION_CREDENTIALS
Secret: エンコードしたファイルの値

image.png

「Add Secret」をクリック

設定は完了したのでパイプラインの解説に戻ります。

    - name: Prepeare Google Application Credentials
      run: |
        echo ${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }} | base64 --decode > $HOME/private-key.json

先程は暗号化した鍵を設定しましたがパイプラインでは鍵をbase64で複合してprivate-key.jsonという名前で保存しています。

    - name: Deploy Firebase
      run: |
        export GOOGLE_APPLICATION_CREDENTIALS=$HOME/private-key.json
        firebase deploy --only hosting
    - name: Remove private key
      if: always()
      run: rm $HOME/private-key.json

最後に作成したキーをFirebaseの認証に用いる特別な環境変数であるGOOGLE_APPLICATION_CREDENTIALSに設定してfirebase deployをします。

デプロイが終わったらprivate-keyは削除をしています。

それではGitHubにコードをpushして動くかチェックしてみましょう

image.png

パイプラインが最後まで通りました。
試しにUIを変更してみましょう。ここでタイトルを変えてしまうとCIが通らなくなるのでボタンの色を変えてます。

src/App.tsx
import { useState } from "react";

type Todo = {
  id: number;
  title: string;
  completed: boolean;
};

function App() {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [title, setTitle] = useState("");

  const handleAddTodo = () => {
    if (title.trim()) {
      setTodos([...todos, { id: todos.length + 1, title, completed: false }]);
      setTitle("");
    }
  };

  const handleToggleTodo = (id: number) => {
    setTodos(
      todos.map((todo) =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    );
  };

  const handleKeyPress = (e: React.KeyboardEvent) => {
    if (e.key === "Enter") {
      handleAddTodo();
    }
  };

  return (
    <div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 py-8 px-4">
      <div className="max-w-md mx-auto">
        <div className="bg-white rounded-lg shadow-lg p-6">
          <h1 className="text-3xl font-bold text-center text-gray-800 mb-6">
            📝 Todoアプリ!
          </h1>

          <div className="flex gap-2 mb-6">
            <input
              type="text"
              name="title"
              value={title}
              onChange={(e) => setTitle(e.target.value)}
              onKeyPress={handleKeyPress}
              placeholder="新しいタスクを入力..."
              aria-label="新しいタスクを入力"
              className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
            />
            {/* CSSをbg-blue-500からbg-red-500に変えた */}
            <button
              onClick={handleAddTodo}
              className="px-6 py-2 bg-red-500 text-white rounded-lg hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors duration-200"
            >
              追加
            </button>
          </div>

          {todos.length === 0 ? (
            <div className="text-center text-gray-500 py-8">
              <p className="text-lg">タスクがありません</p>
              <p className="text-sm">新しいタスクを追加してください</p>
            </div>
          ) : (
            <ul className="space-y-3">
              {todos.map((todo) => (
                <li
                  key={todo.id}
                  className={`flex items-center gap-3 p-3 rounded-lg border transition-all duration-200 ${
                    todo.completed
                      ? "bg-gray-50 border-gray-200"
                      : "bg-white border-gray-300 hover:border-blue-300"
                  }`}
                >
                  <input
                    type="checkbox"
                    checked={todo.completed}
                    onChange={() => handleToggleTodo(todo.id)}
                    className="w-5 h-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500 focus:ring-2"
                  />
                  <span
                    className={`flex-1 ${
                      todo.completed
                        ? "line-through text-gray-500"
                        : "text-gray-800"
                    }`}
                  >
                    {todo.title}
                  </span>
                </li>
              ))}
            </ul>
          )}

          {todos.length > 0 && (
            <div className="mt-6 pt-4 border-t border-gray-200">
              <p className="text-sm text-gray-600 text-center">
                完了済み: {todos.filter((todo) => todo.completed).length} /{" "}
                {todos.length}
              </p>
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

export default App;

GitHubにpushするとCI/CDが動いて画面が更新されるはずです。

image.png

ここで初心者がよくやるのが1つのジョブにビルド/テスト/デプロイをまとめて書くということです。
できれば細かい単位でジョブは分けるほうが良いです。

image.png

もし1つのジョブにしてしまうと再実行はジョブ単位でしかできないので、もしステップAが失敗するとビルドからやり直しになってしまいます。テストは開発につれて時間が増えていくので非効率的です。

ジョブに分けることで失敗したところから再実行ができるので既に通っている他のジョブを実行しなくても良くなります。

発展課題

よりCI/CDについてくわしくなるための発展課題を用意しましたのでぜひチャレンジしてください

1. パイプラインの時間短縮

今回はデプロイフェーズでビルドも行っており、せっかくのartifactsが無駄になっています。
firebase.jsonを修正してビルドファイルをそのままデプロイするように修正してください
するとnpm installの時間も削減することが可能です

2. テスト駆動開発の実践

今回は実装を行ったあとにテストを書きましたが、テスト駆動開発を行うと
テストから先に書いて、そのテストを通すための実装をするという流れで開発を進められます。

こうすることでテストを網羅的に書くことが可能で、不要な実装をすることがありません。
CI/CDは網羅的にテストをすることが重要なので(テストが通ったらリリースできる状態と信じる)、ぜひ今回作ったアプリをテストから1つずつかいて、実装するということをしてみてください

おわりに

いかがでしたでしょうか?
CI/CDを行うことで高速にアプリをリリースすることが可能になります。
AI時代になってテストの価値やリリーススピードはより重用になってきています。
ぜひともご自身でもパイプライン構築をしてみてください

詳しく解説した動画を投稿しているのでよかったらみてみてください!

JISOUのメンバー募集中!

プログラミングコーチングJISOUでは、新たなメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか?
興味のある方は、ぜひホームページからお気軽にカウンセリングをお申し込みください!
▼▼▼

図解ハンズオンたくさん投稿しています!

本チュートリアルのレビュアーの皆様

次回のハンズオンのレビュアーはXにて募集します。

  • 中嶋様
  • 山本様
  • ナツキ様
  • tokec様
  • 山本様
70
59
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
70
59

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?