🙄

今更だけど、フロントエンドの単体テストの考え方

2023/05/07に公開

はじめに

所謂ベンチャーでフロントエンドエンジニアをやっております。白色と申します。
3回目の投稿となります。

概要

  • TypeScript + React で書かれているソースの単体テスト
  • 今回は社内で記述したコードになります。
  • ブログ用に使用したい社内ソースのマスクをChatGPTで行う

コード

  • 社内開発のため、なし

単体テストのすゝめ

目的

  • 最小単位のメソッド、コンポーネントのテスト

注意点

  • APIやRedux,Context等は使わず、モックしてテストする

    • 原因が記述した点にある可能性もあり、純粋なフロントのテストにならないため
  • 「なぜそこをテストするのか?」を明確にする

  • 全てを舐めようと思わない、重要な部分だけを行い、比重は単体テスト<結合テストとしたい

  • カバレッジを意識するのではなく、「そのメソッドに対してテストをする意味」を考える

粒度

  • domain層,hooksはマスト 他はバグが起きてほしくないところに記述したい
  • 全て書いていくとキリがないため

一部ソースコード

  • 以下のソースはChatGPTでマスキングを行なっています
hooksテスト
import { renderHook } from "@testing-library/react-hooks";

import { State } from "./flux/student";
import { useLanguage } from "./language/useLanguage";
// contextProviderのモック
import { StudentWrapper } from "./mocks";

// reduxデータのモック
const data = {
    languages: {
        data: [
            { id: 1, language: "Lang1" },
            { id: 2, language: "Lang2" },
            { id: 3, language: "Lang3" },
            { id: 4, language: "Lang4" },
            { id: 5, language: "Lang5" },
            { id: 6, language: "Lang6" },
            { id: 7, language: "Lang7" },
            { id: 8, language: "Lang8" },
            { id: 9, language: "Lang9" },
            { id: 10, language: "Lang10" },
            { id: 11, language: "Lang11" },
            { id: 12, language: "Lang12" },
            { id: 13, language: "Lang13" },
            { id: 14, language: "Lang14" },
            { id: 15, language: "Lang15" },
            { id: 16, language: "Lang16" },
            { id: 17, language: "Lang17" },
            { id: 18, language: "Lang18" },
            { id: 19, language: "Lang19" },
            { id: 20, language: "Lang20" },
            { id: 21, language: "Lang21" },
            { id: 22, language: "Lang22" },
            { id: 23, language: "Lang23" },
            { id: 24, language: "Lang24" },
            { id: 25, language: "Lang25" },
            { id: 26, language: "Other" },
        ],
    },
};

// モック
const mockUseRedux = jest.fn();
const mockUseContext = jest.fn();

// contextのモック
jest.mock("react", () => {
    const originalModule = jest.requireActual("react");
    return {
    ...originalModule,
    useContext: () => mockUseContext(),
    };
});
// reduxの処理を集めているhooksのモック
jest.mock("../../redux/useRedux", () => ({
    useRedux: () => mockUseRedux(),
}));

describe("Test a useLanguage", () => {
    beforeEach(() => {
    mockUseRedux.mockReset();
    mockUseContext.mockReset();
});

test("最小値", async () => {
    const contextValue: State = {
        student: {
        language: {
            values: [{ id: { value: 1 }, level: 1 }],
        },
        },
        form: {
        language: {
            values: [{ id: 6, level: 2 }],
        },
        },
    } as unknown as State;
    mockUseRedux.mockReturnValue(data);
    mockUseContext.mockReturnValue(contextValue);
    const { result } = renderHook(() => useLanguage(), {
        wrapper: StudentWrapper,
    });

    expect(result.current.studentItems).toEqual([
        { level: "MaskedString1", language: "MaskedString3" },
    ]);
    expect(result.current.formItems).toEqual([
        { level: "MaskedString2", language: "MaskedString4", isDuplicate: false },
    ]);
});

test("最大値", async () => {
    mockUseRedux.mockReturnValue(data);
    mockUseContext.mockReturnValue({
    student: {
        language: {
        values: [{ id: { value: 26 }, level: 3 }],
        },
    },
    form: {
        language: {
        values: [
            {
            id: 26,
            level: 1,
            },
        ],
        },
    },
    });
    const { result } = renderHook(() => useLanguage(), {
    wrapper: StudentWrapper,
    });

    expect(result.current.studentItems).toEqual([
        { level: "MaskedString1", language: "MaskedString3" },
    ]);
    expect(result.current.formItems).toEqual([
        { level: "MaskedString2", language: "MaskedString3", isDuplicate: false },
    ]);
});

test("Multiple", async () => {
    mockUseRedux.mockReturnValue(data);
    mockUseContext.mockReturnValue({
        student: {
        language: {
            values: [
            { id: { value: 10 }, level: 1 },
            { id: { value: 15 }, level: 2 },
            { id: { value: 20 }, level: 3 },
            ],
        },
        },
        form: {
        language: {
            values: [
            { id: 10, level: 1 },
            { id: 15, level: 2 },
            { id: 20, level: 3 },
            ],
        },
        },
    });
    const { result } = renderHook(() => useLanguage(), {
        wrapper: StudentWrapper,
    });

    expect(result.current.studentItems).toEqual([
        { level: "MaskedString1", language: "MaskedString2" },
        { level: "MaskedString3", language: "MaskedString4" },
        { level: "MaskedString5", language: "MaskedString6" },
    ]);
    expect(result.current.formItems).toEqual([
        { level: "MaskedString1", language: "MaskedString2", isDuplicate: false },
        {
        level: "MaskedString3",
        language: "MaskedString4",
        isDuplicate: false,
        },
        {
        level: "MaskedString5",
        language: "MaskedString6",
        isDuplicate: false,
        },
    ]);
});

describe("Test error useLanguage", () => {
    beforeEach(() => {
        mockUseRedux.mockReset();
        mockUseContext.mockReset();
    });
    test("id: MaskedString1(境界値)", async () => {
        const contextValue: State = {
            student: {
                language: {
                values: [{ id: { value: MaskedString1 }, level: 0 }],
                },
            },
            form: {
                language: {
                values: [{ id: MaskedString1, level: 0 }],
                },
            },
        } as unknown as State;
        mockUseRedux.mockReturnValue(data);
        mockUseContext.mockReturnValue(contextValue);
        const { result } = renderHook(() => useLanguage(), {
        wrapper: StudentWrapper,
        });

        expect(result.current.studentItems).toEqual([
            { level: undefined, language: undefined },
        ]);
        expect(result.current.formItems).toEqual([
            { level: undefined, language: undefined, isDuplicate: false },
        ]);
    });
,,,以下続く
domain層テスト
import { BirthDayDate } from './BirthDayDate';

describe('BirthDayDateモデルの正常系', () => {
  test('BirthDayDateモデルに変換できる', () => {
    // テスト用の日付
    const date = 28;
    // 日付をBirthDayDateモデルに変換する
    const result = BirthDayDate.new(date);
    // 正常系のテスト
    expect(result.isOk()).toBeTruthy();
    expect(result.unwrap().value).toEqual(date);
  });
});

describe('BirthDayDateモデルの異常系', () => {
  test('日付が異常なときはエラー', () => {
    // 異常な日付
    const date = 0;
    // 日付をBirthDayDateモデルに変換する
    const result = BirthDayDate.new(date);
    // 異常系のテスト
    expect(result.isErr()).toBeTruthy();
    expect(result.unwrapErr().message).toEqual('データが不正です。');
  });
});

まとめ

  • 外部のシステムは使わずにモックして行う
    • 別プロジェクトでモックされていないテスト修正を行った際に修正箇所が甚大になったため
    • モックを行わないとフロントエンドのテストにならない
  • 単体テストは重要だと考えられるメソッドのみで行う(重要)
  • フロントのテストは時間や工数的に全て行う事が不可能だと思いますので、どこまでやるかなど悩んだ際に参考にしてください

Discussion