4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Github Actions✕Jest】ReactアプリのテストコードをGithubへのプッシュ時に自動で実行させよう!

Last updated at Posted at 2024-08-04

はじめに

タイトルの通り、Jestを用いたテストコードをブランチへのプッシュ時に自動で実行させるようにしてみましたので、その方法をご紹介いたします!
(Jestは今回初めて触れたのですが、以前業務でJunitでテストコードをゴリゴリ書く時期があったのでイメージは大体つかめてました。)

テストコード

まず、作成したアプリについては↓を参照ください。

何のことはない最低限のCRUD機能を備えた簡易アプリです。
作成したテストコードはこちら↓。

App.test.jsx(全体)
App.test.jsx
import React from "react";
import "@testing-library/jest-dom";
import { render, screen, waitFor, act, fireEvent } from "@testing-library/react";
import App from "../App";
import userEvent from "@testing-library/user-event";
import { DB } from "../supabase";

const initialRecords = [
  { id: 1, title: "Reactの勉強", time: 1 },
  { id: 2, title: "Vueの勉強", time: 2 },
  { id: 3, title: "TypeScriptの勉強", time: 3 },
];

// モック定義
jest.mock("../supabase", () => ({
  DB: {
    fetchAllRecords: jest.fn(),
    insertRecord: jest.fn().mockResolvedValue([]),
    deleteRecord: jest.fn().mockResolvedValue([]),
  },
}));

/*
 * [学習記録一覧テスト]
 * 初期表示、登録、削除のテスト
 */
describe("初期表示のテスト", () => {
  test("[正常系]タイトルが画面上に表示されていること", async () => {
    // モック化
    DB.fetchAllRecords.mockResolvedValue({ data: [] });

    // 実行
    render(<App />);
    // 検証
    const headElement = screen.getByRole("heading", { name: "学習記録一覧" });
    await waitFor(() => {
      expect(headElement).toBeInTheDocument();
    });
  });

  test("[正常系]ローディング中であることを示す文言が表示されていること", async () => {
    // モック化
    DB.fetchAllRecords.mockResolvedValue({ data: [] });

    // 実行
    render(<App />);
    // 検証
    const loadingWord = screen.getByText("Loading...");
    await waitFor(() => {
      expect(loadingWord).toBeInTheDocument();
    });
  });

  test("[正常系]学習記録が画面に表示されていること", async () => {
    // モック化
    DB.fetchAllRecords.mockResolvedValue({ data: initialRecords });

    // 実行
    await act(async () => {
      render(<App />);
    });
    
    // 検証
    await waitFor(() => {
      const initialRecord1 = screen.getByText("Reactの勉強 1時間");
      const initialRecord2 = screen.getByText("Vueの勉強 2時間");
      const initialRecord3 = screen.getByText("TypeScriptの勉強 3時間");
      const sumTimeText = screen.getByText("合計時間:6/1000(h)");
      expect(initialRecord1).toBeInTheDocument();
      expect(initialRecord2).toBeInTheDocument();
      expect(initialRecord3).toBeInTheDocument();
      expect(sumTimeText).toBeInTheDocument();
    });
  });
});

describe("学習記録登録のテスト", () => {
  test("[正常系]登録ボタン押下後、学習記録が画面上に表示されること", async () => {
    // モック化
    DB.fetchAllRecords
      .mockResolvedValueOnce({ data: initialRecords })
      .mockResolvedValueOnce({ data: [...initialRecords, { id: 4, title: "追加の勉強", time: 4 }] });

    // 実行
    await act(async () => {
      render(<App />);
    });

    // 検証(登録後の要素が存在しないこと)
    await waitFor(() => {
      expect(screen.queryByText("追加の勉強 4時間")).not.toBeInTheDocument();
      const sumTimeText = screen.getByText("合計時間:6/1000(h)");
      expect(sumTimeText).toBeInTheDocument();
    });

    // 入力
    const titleInput = screen.getByRole("textbox", { name: "title" });
    await userEvent.type(titleInput, "追加の勉強");
    const timeInput = screen.getByRole("spinbutton", { name: "time" });
    await userEvent.type(timeInput, "4");
    // 登録ボタン押下
    const registButton = screen.getByRole("button", { name: "登録" });
    fireEvent.click(registButton);

    // 検証
    await waitFor(() => {
      const registeredRecord = screen.getByText("追加の勉強 4時間");
      expect(registeredRecord).toBeInTheDocument();
      const sumTimeText = screen.getByText("合計時間:10/1000(h)");
      expect(sumTimeText).toBeInTheDocument();
    });
  });

  test("[異常系]入力項目に値を設定せず登録ボタン押下後、画面上にエラーメッセージが表示されること", async () => {
    // モック化
    DB.fetchAllRecords.mockResolvedValue({ data: [] });

    // 実行
    await act(async () => {
      render(<App />);
    });

    // 入力項目に値を設定しない
    // 登録ボタン押下
    const registButton = screen.getByRole("button", { name: "登録" });
    fireEvent.click(registButton);

    // 検証
    await waitFor(() => {
      const registedRecord = screen.getByText("入力されていない項目があります。");
      expect(registedRecord).toBeInTheDocument();
    });
  });
});

describe("学習記録削除のテスト", () => {
  test("[正常系]削除ボタン押下後、学習記録が画面上に表示されないこと", async () => {
    // モック化
    DB.fetchAllRecords
      .mockResolvedValueOnce({ data: [...initialRecords, { id: 4, title: "削除予定の勉強", time: 4 }] })
      .mockResolvedValueOnce({ data: initialRecords });

    // 実行
    await act(async () => {
      render(<App />);
    });

    // 検証(削除前の要素が存在すること)
    await waitFor(() => {
      const toBeDeletedRecord = screen.getByText("削除予定の勉強 4時間");
      expect(toBeDeletedRecord).toBeInTheDocument();
      const sumTimeText = screen.getByText("合計時間:10/1000(h)");
      expect(sumTimeText).toBeInTheDocument();
    });

    // 削除ボタン押下
    const deleteButton = screen.getAllByRole("button", { name: "削除" })[3];
    fireEvent.click(deleteButton);

    // 検証
    await waitFor(() => {
      expect(screen.queryByText("削除予定の勉強 4時間")).not.toBeInTheDocument();
      const sumTimeText = screen.getByText("合計時間:6/1000(h)");
      expect(sumTimeText).toBeInTheDocument();
    });
  });
});

簡易なアプリなのに結構長ったらしいコードになってしまいましたね。
(しかもモック化のところでなかなか苦労しました…)

テストケースの分け方としては、大きく以下の通りです。

テストケース項目

  • 初期表示のテスト
    • タイトルが表示されていること
    • ローディング中にロード中であることを示すワードが表示されていること
    • ロード後は学習記録が表示されていること
  • 登録のテスト
    • 登録ボタン押下後、画面上に新たに学習記録が表示されること
    • インプット項目に値を設定せずに登録ボタンを押下するとエラーメッセージが表示されること
  • 削除のテスト
    • 削除ボタン押下後、対応する学習記録が表示されなくなること

Jestにはdescribeというメソッドが用意されており、関連するテストをまとめることができるようです。便利ですね!
そしてテストを実行した結果がこちら↓(※筆者はBunを使っています)。

テスト実行コマンド
bun run test -- --coverage

coverageオプションをつけることで、単に実行するだけでなくカバレッジ(テスト対象コードの網羅率)も出力するように出来ます!

実行結果
 PASS  src/tests/App.test.jsx
  初期表示のテスト
    ✓ [正常系]タイトルが画面上に表示されていること (41 ms)
    ✓ [正常系]ローディング中であることを示す文言が表示されていること (5 ms)
    ✓ [正常系]学習記録が画面に表示されていること (8 ms)
  学習記録登録のテスト
    ✓ [正常系]登録ボタン押下後、学習記録が画面上に表示されること (67 ms)
    ✓ [異常系]入力項目に値を設定せず登録ボタン押下後、画面上にエラーメッセージが表示されること (7 ms)
  学習記録削除のテスト
    ✓ [正常系]削除ボタン押下後、学習記録が画面上に表示されないこと (21 ms)

------------------|---------|----------|---------|---------|-------------------
File              | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
------------------|---------|----------|---------|---------|-------------------
All files         |     100 |      100 |     100 |     100 |                   
 src              |     100 |      100 |     100 |     100 |                   
  App.jsx         |     100 |      100 |     100 |     100 |                   
 src/components   |     100 |      100 |     100 |     100 |                   
  Form.jsx        |     100 |      100 |     100 |     100 |                   
  RecordsList.jsx |     100 |      100 |     100 |     100 |                   
------------------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       6 passed, 6 total
Snapshots:   0 total
Time:        1.659 s, estimated 2 s
Ran all test suites.

まず実行結果の上部分ですが、キレイにテストケース別に分かれて表示されていますね!
どうやらdescribeメソッドで定義したとおりにコンソール上では表示してくれるみたいです(しかも入れ子構造も可能)。
テストコードを書く際は今後も意識していきたいですね。
また、下半分はテスト実行時のカバレッジが表形式で表示されています。
テスト対象コードをファイル別に分けてくれているようですが、どうやら実行したテストコードで100%の網羅率をカバーできているようです(この瞬間が楽しい)。

Github Actionsでプッシュ時にテストを実行させる

さて、ローカルでのテストは実行出来るようになりましたので、今度はGithub上でプッシュ時に実行させるようにしましょう。
とはいえ、こちらはymlファイル上でテスト実行コマンドを書けばいいだけなので簡単です(実行OSを指定してランタイムをDLして依存関係をinstallして実行する↓)。

github-ci.yml
name: Github CI

on: push

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Set up Bun
        uses: oven-sh/setup-bun@v2

      - name: Install dependencies
        run: bun install

      - name: Do Jest test
        run: bun run test -- --coverage

それでは早速上記のコードをpushして結果を見てみましょう↓。

スクリーンショット 2024-08-03 14.16.23.png

キタ━━━━(゚∀゚)━━━━!!

と、いうわけでGithub上でも正常に動作することが確認できました。
pushするだけでアプリのデプロイやテストまで自動で実行できる様になるなんて凄いですね!(自画自賛)

おわりに

個人的には、テストコードは書きやすさや実行速度よりも読みやすさの方が遥かに重要 だと思っています。
それは、テストコードは書かれることよりも読まれることの方が機会が多く、テストのしやすさがコードの保守性にも直結するためです。
そういう意味では、実際に動くコードを書くよりも、ある種テストコードの方が書くのは難しいと言えるのかもしれませんね(私のテストコードが読みやすいかは別問題)。

今回、Jestを使用するにあたり色々調べましたが、どうやら沢山の機能があり、マスターするのは中々難しそうだな、と思いました。
つまり今後も慢心せず頑張ろう!というお話でした:punch::punch::punch:

JISOUのメンバー募集中🔥

プログラミングコーチングJISOUではメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか?
気になる方はぜひHPからライン登録お願いします!👇

4
3
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
4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?