Repository: frontend-testing-book/unittest Branch: main Commit: 30183e545423 Files: 95 Total size: 72.9 KB Directory structure: gitextract_23g8f7c9/ ├── .github/ │ └── workflows/ │ └── test.yaml ├── .gitignore ├── .storybook/ │ ├── main.js │ └── preview.js ├── LICENSE ├── README.md ├── jest.config.ts ├── jest.setup.ts ├── package.json ├── src/ │ ├── 03/ │ │ ├── 02/ │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ ├── 04/ │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ ├── 05/ │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ ├── 06/ │ │ │ └── index.test.ts │ │ └── 07/ │ │ ├── index.test.ts │ │ └── index.ts │ ├── 04/ │ │ ├── 02/ │ │ │ ├── greet.ts │ │ │ ├── greet1.test.ts │ │ │ ├── greet2.test.ts │ │ │ ├── greet3.test.ts │ │ │ └── greet4.test.ts │ │ ├── 03/ │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ ├── 04/ │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ ├── 05/ │ │ │ ├── checkConfig.test.ts │ │ │ ├── checkConfig.ts │ │ │ ├── greet.test.ts │ │ │ └── greet.ts │ │ ├── 06/ │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ ├── 07/ │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ └── fetchers/ │ │ ├── fixtures.ts │ │ ├── index.ts │ │ └── type.ts │ ├── 05/ │ │ ├── 03/ │ │ │ ├── Form.stories.tsx │ │ │ ├── Form.test.tsx │ │ │ ├── Form.tsx │ │ │ └── __snapshots__/ │ │ │ └── Form.test.tsx.snap │ │ ├── 04/ │ │ │ ├── ArticleList.stories.tsx │ │ │ ├── ArticleList.test.tsx │ │ │ ├── ArticleList.tsx │ │ │ ├── ArticleListItem.test.tsx │ │ │ ├── ArticleListItem.tsx │ │ │ ├── __snapshots__/ │ │ │ │ ├── ArticleList.test.tsx.snap │ │ │ │ └── ArticleListItem.test.tsx.snap │ │ │ └── fixture.ts │ │ ├── 05/ │ │ │ ├── Agreement.stories.tsx │ │ │ ├── Agreement.test.tsx │ │ │ ├── Agreement.tsx │ │ │ ├── Form.stories.tsx │ │ │ ├── Form.test.tsx │ │ │ ├── Form.tsx │ │ │ ├── InputAccount.stories.tsx │ │ │ ├── InputAccount.test.tsx │ │ │ ├── InputAccount.tsx │ │ │ └── __snapshots__/ │ │ │ ├── Agreement.test.tsx.snap │ │ │ ├── Form.test.tsx.snap │ │ │ └── InputAccount.test.tsx.snap │ │ ├── 06/ │ │ │ ├── ContactNumber.stories.tsx │ │ │ ├── ContactNumber.test.tsx │ │ │ ├── ContactNumber.tsx │ │ │ ├── DeliveryAddress.stories.tsx │ │ │ ├── DeliveryAddress.test.tsx │ │ │ ├── DeliveryAddress.tsx │ │ │ ├── Form.stories.tsx │ │ │ ├── Form.test.tsx │ │ │ ├── Form.tsx │ │ │ ├── PastDeliveryAddress.stories.tsx │ │ │ ├── PastDeliveryAddress.test.tsx │ │ │ ├── PastDeliveryAddress.tsx │ │ │ ├── RegisterDeliveryAddress.stories.tsx │ │ │ ├── RegisterDeliveryAddress.test.tsx │ │ │ ├── RegisterDeliveryAddress.tsx │ │ │ ├── __snapshots__/ │ │ │ │ └── Form.test.tsx.snap │ │ │ └── fixtures.ts │ │ └── 07/ │ │ ├── RegisterAddress.stories.tsx │ │ ├── RegisterAddress.test.tsx │ │ ├── RegisterAddress.tsx │ │ ├── __snapshots__/ │ │ │ └── RegisterAddress.test.tsx.snap │ │ ├── fetchers/ │ │ │ ├── fixtures.ts │ │ │ ├── index.ts │ │ │ ├── mock.ts │ │ │ └── type.ts │ │ ├── handleSubmit.ts │ │ ├── testingUtils.ts │ │ └── validations.ts │ └── 06/ │ ├── Articles.test.tsx │ ├── Articles.tsx │ ├── greetByTime.test.ts │ └── greetByTime.ts └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/test.yaml ================================================ name: Run Test on: push jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: node-version: 18 - name: Install dependencies run: npm ci - name: Jest unit test run: npm test ================================================ FILE: .gitignore ================================================ node_modules .DS_Store coverage __reports__ ================================================ FILE: .storybook/main.js ================================================ module.exports = { stories: ["../src/**/*.stories.@(js|jsx|ts|tsx)"], addons: [ "@storybook/addon-links", "@storybook/addon-essentials", "@storybook/addon-interactions", "@storybook/addon-a11y", ], framework: "@storybook/react", core: { builder: "@storybook/builder-webpack5", }, } ================================================ FILE: .storybook/preview.js ================================================ export const parameters = { actions: { argTypesRegex: "^on[A-Z].*" }, controls: { matchers: { color: /(background|color)$/i, date: /Date$/, }, }, } ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2023 frontend-testing-book Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # フロントエンドのテスト入門 これは「フロントエンド開発のためのテスト入門」の第3章〜第6章で解説する、トレーニングリポジトリです。対象読者は、初めてフロントエンドのテストに取り組まれる方を想定しており、Jestの基本的な使用方法から解説しています。 - 第3章 はじめの単体テスト - 第4章 モック - 第5章 UI コンポーネントテスト - 第6章 カバレッジレポートの読み方 ## 第3章 はじめの単体テスト Jest を使用したテストコードを解説します。はじめてテストに取り組まれる方にとって、最適な内容です。 【サンプルコード】`src/03/**/*.test.ts` ``` $ npm test ``` ## 第4章 モック テストを実施するうえで、必要不可欠な「モック」について解説します。Jest は標準でモック機能を備えています。 【サンプルコード】`src/04/**/*.test.ts` ``` $ npm test ``` ## 第5章 UI コンポーネントテスト Node.js には DOM API がありませんが、jsdom という DOM API をシュミレートするテスト環境を用意することで、実際のブラウザでテストするのと同じようなテストが「高速に」実施できます。Testing Library は、そのテスト環境において、UIコンポーネントテストをサポートするライブラリです。 【サンプルコード】`src/05/**/*.test.tsx` ``` $ npm test ``` ## 第6章 カバレッジレポートの読み方 単体テスト・結合テストに慣れたら、カバレッジレポートを確認して、不足しているテストを見つけてみましょう。カバレッジレポートを確認することで、テストコードを書くポイントがはっきりします。 【サンプルコード】`src/06/**/*.test.{ts,tsx}` ``` $ npm test ``` ================================================ FILE: jest.config.ts ================================================ export default { clearMocks: true, collectCoverage: false, coverageDirectory: "coverage", moduleFileExtensions: ["js", "jsx", "ts", "tsx"], testEnvironment: "jest-environment-jsdom", transform: { "^.+\\.(ts|tsx)$": ["esbuild-jest", { sourcemap: true }] }, setupFilesAfterEnv: ["./jest.setup.ts"], reporters: [ "default", [ "jest-html-reporters", { publicPath: "__reports__", filename: "jest.html", }, ], ], }; ================================================ FILE: jest.setup.ts ================================================ import "@testing-library/jest-dom"; import React from "react"; global.React = React; ================================================ FILE: package.json ================================================ { "name": "frontend-testing-book-unittest", "version": "1.0.0", "private": true, "license": "MIT", "scripts": { "test": "jest", "format": "prettier --write \"src/**/*.{ts,tsx}\"", "storybook": "start-storybook -p 6006", "build-storybook": "build-storybook" }, "dependencies": { "react": "^18.2.0", "react-dom": "^18.2.0" }, "devDependencies": { "@babel/core": "^7.20.12", "@babel/preset-typescript": "^7.18.6", "@storybook/addon-a11y": "^6.5.13", "@storybook/addon-actions": "^6.5.13", "@storybook/addon-essentials": "^6.5.13", "@storybook/addon-interactions": "^6.5.13", "@storybook/addon-links": "^6.5.13", "@storybook/builder-webpack5": "^6.5.13", "@storybook/manager-webpack5": "^6.5.13", "@storybook/react": "^6.5.13", "@storybook/testing-library": "^0.0.13", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^14.4.3", "@types/jest": "^29.4.0", "babel-loader": "^9.1.2", "esbuild": "^0.17.5", "esbuild-jest": "^0.5.0", "jest": "^29.4.1", "jest-environment-jsdom": "^29.4.1", "jest-html-reporters": "^3.1.1", "prettier": "^2.8.3", "prettier-plugin-organize-imports": "^3.2.2", "ts-jest": "^29.0.5", "ts-node": "^10.9.1", "typescript": "^4.9.5", "whatwg-fetch": "^3.6.2" }, "babel": { "presets": [ "@babel/preset-typescript" ] } } ================================================ FILE: src/03/02/index.test.ts ================================================ import { add, sub } from "."; describe("四則演算", () => { describe("add", () => { test("1 + 1 は 2", () => { expect(add(1, 1)).toBe(2); }); test("1 + 2 は 3", () => { expect(add(1, 2)).toBe(3); }); }); describe("sub", () => { test("1 - 1 は 0", () => { expect(sub(1, 1)).toBe(0); }); test("2 - 1 は 1", () => { expect(sub(2, 1)).toBe(1); }); }); }); ================================================ FILE: src/03/02/index.ts ================================================ export function add(a: number, b: number) { return a + b; } export function sub(a: number, b: number) { return a - b; } ================================================ FILE: src/03/04/index.test.ts ================================================ import { add, sub } from "."; describe("四則演算", () => { describe("add", () => { test("返り値は、第一引数と第二引数の「和」である", () => { expect(add(50, 50)).toBe(100); }); test("合計の上限は、'100'である", () => { expect(add(70, 80)).toBe(100); }); }); describe("sub", () => { test("返り値は、第一引数と第二引数の「差」である", () => { expect(sub(51, 50)).toBe(1); }); test("返り値の合計は、下限が'0'である", () => { expect(sub(70, 80)).toBe(0); }); }); }); ================================================ FILE: src/03/04/index.ts ================================================ export function add(a: number, b: number) { const sum = a + b; if (sum > 100) { return 100; } return sum; } export function sub(a: number, b: number) { const sum = a - b; if (sum < 0) { return 0; } return sum; } ================================================ FILE: src/03/05/index.test.ts ================================================ import { add, RangeError, sub } from "."; describe("四則演算", () => { describe("add", () => { test("返り値は、第一引数と第二引数の「和」である", () => { expect(add(50, 50)).toBe(100); }); test("合計の上限は、'100'である", () => { expect(add(70, 80)).toBe(100); }); test("引数が'0〜100'の範囲外だった場合、例外をスローする", () => { const message = "入力値は0〜100の間で入力してください"; expect(() => add(-10, 10)).toThrow(message); expect(() => add(10, -10)).toThrow(message); expect(() => add(-10, 110)).toThrow(message); }); }); describe("sub", () => { test("返り値は、第一引数と第二引数の「差」である", () => { expect(sub(51, 50)).toBe(1); }); test("返り値の合計は、下限が'0'である", () => { expect(sub(70, 80)).toBe(0); }); test("引数が'0〜100'の範囲外だった場合、例外をスローする", () => { expect(() => sub(-10, 10)).toThrow(RangeError); expect(() => sub(10, -10)).toThrow(RangeError); expect(() => sub(-10, 110)).toThrow(Error); }); }); }); ================================================ FILE: src/03/05/index.ts ================================================ export class RangeError extends Error {} function checkRange(value: number) { if (value < 0 || value > 100) { throw new RangeError("入力値は0〜100の間で入力してください"); } } export function add(a: number, b: number) { checkRange(a); checkRange(b); const sum = a + b; if (sum > 100) { return 100; } return sum; } export function sub(a: number, b: number) { checkRange(a); checkRange(b); const sum = a - b; if (sum < 0) { return 0; } return sum; } ================================================ FILE: src/03/06/index.test.ts ================================================ describe("真偽値の検証", () => { test("「真の値」の検証", () => { expect(1).toBeTruthy(); expect("1").toBeTruthy(); expect(true).toBeTruthy(); expect(0).not.toBeTruthy(); expect("").not.toBeTruthy(); expect(false).not.toBeTruthy(); }); test("「偽の値」の検証", () => { expect(0).toBeFalsy(); expect("").toBeFalsy(); expect(false).toBeFalsy(); expect(1).not.toBeFalsy(); expect("1").not.toBeFalsy(); expect(true).not.toBeFalsy(); }); test("「null, undefined」の検証", () => { expect(null).toBeFalsy(); expect(undefined).toBeFalsy(); expect(null).toBeNull(); expect(undefined).toBeUndefined(); expect(undefined).not.toBeDefined(); }); }); describe("数値の検証", () => { const value = 2 + 2; test("検証値 は 期待値 と等しい", () => { expect(value).toBe(4); expect(value).toEqual(4); }); test("検証値 は 期待値 より大きい", () => { expect(value).toBeGreaterThan(3); // 4 > 3 expect(value).toBeGreaterThanOrEqual(4); // 4 >= 4 }); test("検証値 は 期待値 より小さい", () => { expect(value).toBeLessThan(5); // 4 < 5 expect(value).toBeLessThanOrEqual(4); // 4 <= 4 }); test("小数計算は正確ではない", () => { expect(0.1 + 0.2).not.toBe(0.3); }); test("小数計算の指定桁までを比較する", () => { expect(0.1 + 0.2).toBeCloseTo(0.3); // デフォルトは 2桁 expect(0.1 + 0.2).toBeCloseTo(0.3, 15); expect(0.1 + 0.2).not.toBeCloseTo(0.3, 16); }); }); describe("文字列の検証", () => { const str = "こんにちは世界"; const obj = { status: 200, message: str }; test("検証値 は 期待値 と等しい", () => { expect(str).toBe("こんにちは世界"); expect(str).toEqual("こんにちは世界"); }); test("toHaveLength", () => { expect(str).toHaveLength(7); expect(str).not.toHaveLength(8); }); test("toContain", () => { expect(str).toContain("世界"); expect(str).not.toContain("さようなら"); }); test("toMatch", () => { expect(str).toMatch(/世界/); expect(str).not.toMatch(/さようなら/); }); test("stringContaining", () => { expect(obj).toEqual({ status: 200, message: expect.stringContaining("世界"), }); }); test("stringMatching", () => { expect(obj).toEqual({ status: 200, message: expect.stringMatching(/世界/), }); }); }); describe("配列の検証", () => { describe("プリミティブ配列", () => { const tags = ["Jest", "Storybook", "Playwright", "React", "Next.js"]; test("toContain", () => { expect(tags).toContain("Jest"); expect(tags).toHaveLength(5); }); }); describe("オブジェクト配列", () => { const article1 = { author: "taro", title: "Testing Next.js" }; const article2 = { author: "jiro", title: "Storybook play function" }; const article3 = { author: "hanako", title: "Visual Regression Testing " }; const articles = [article1, article2, article3]; test("toContainEqual", () => { expect(articles).toContainEqual(article1); }); test("arrayContaining", () => { expect(articles).toEqual(expect.arrayContaining([article1, article3])); }); }); }); describe("オブジェクトの検証", () => { const author = { name: "taroyamada", age: 38 }; const article = { title: "Testing with Jest", author, }; test("toMatchObject", () => { expect(author).toMatchObject({ name: "taroyamada", age: 38 }); expect(author).toMatchObject({ name: "taroyamada" }); expect(author).not.toMatchObject({ gender: "man" }); }); test("toHaveProperty", () => { expect(author).toHaveProperty("name"); expect(author).toHaveProperty("age"); }); test("objectContaining", () => { expect(article).toEqual({ title: "Testing with Jest", author: expect.objectContaining({ name: "taroyamada" }), }); expect(article).toEqual({ title: "Testing with Jest", author: expect.not.objectContaining({ gender: "man" }), }); }); }); ================================================ FILE: src/03/07/index.test.ts ================================================ import { timeout, wait } from "."; describe("非同期処理", () => { describe("wait", () => { test("指定時間待つと、経過時間をもって resolve される", () => { return wait(50).then((duration) => { expect(duration).toBe(50); }); }); test("指定時間待つと、経過時間をもって resolve される", () => { return expect(wait(50)).resolves.toBe(50); }); test("指定時間待つと、経過時間をもって resolve される", async () => { await expect(wait(50)).resolves.toBe(50); }); test("指定時間待つと、経過時間をもって resolve される", async () => { expect(await wait(50)).toBe(50); }); }); describe("timeout", () => { test("指定時間待つと、経過時間をもって reject される", () => { return timeout(50).catch((duration) => { expect(duration).toBe(50); }); }); test("指定時間待つと、経過時間をもって reject される", () => { return expect(timeout(50)).rejects.toBe(50); }); test("指定時間待つと、経過時間をもって reject される", async () => { await expect(timeout(50)).rejects.toBe(50); }); }); }); test("指定時間待つと、経過時間をもって reject される", async () => { expect.assertions(1); try { await timeout(50); // timeout関数のつもりが、wait関数にしてしまった // ここで終了してしまい、テストは成功する } catch (err) { // アサーションは実行されない expect(err).toBe(50); } }); test("return していないため、Promise が解決する前にテストが終了してしまう", () => { // 失敗を期待して書かれたアサーション expect(wait(2000)).resolves.toBe(3000); // 正しくはアサーションを return する // return expect(wait(2000)).resolves.toBe(3000); }); ================================================ FILE: src/03/07/index.ts ================================================ export function wait(duration: number) { return new Promise((resolve) => { setTimeout(() => { resolve(duration); }, duration); }); } export function timeout(duration: number) { return new Promise((_, reject) => { setTimeout(() => { reject(duration); }, duration); }); } ================================================ FILE: src/04/02/greet.ts ================================================ export function greet(name: string) { return `Hello! ${name}.`; } export function sayGoodBye(name: string) { throw new Error("未実装"); } ================================================ FILE: src/04/02/greet1.test.ts ================================================ import { greet } from "./greet"; test("挨拶を返す(本来の実装どおり)", () => { expect(greet("Taro")).toBe("Hello! Taro."); }); ================================================ FILE: src/04/02/greet2.test.ts ================================================ import { greet } from "./greet"; jest.mock("./greet"); test("挨拶を返さない(本来の実装ではない)", () => { expect(greet("Taro")).toBe(undefined); }); ================================================ FILE: src/04/02/greet3.test.ts ================================================ import { greet, sayGoodBye } from "./greet"; jest.mock("./greet", () => ({ sayGoodBye: (name: string) => `Good bye, ${name}.`, })); test("挨拶が未実装(本来の実装ではない)", () => { expect(greet).toBe(undefined); }); test("さよならを返す(本来の実装ではない)", () => { const message = `${sayGoodBye("Taro")} See you.`; expect(message).toBe("Good bye, Taro. See you."); }); ================================================ FILE: src/04/02/greet4.test.ts ================================================ import { greet, sayGoodBye } from "./greet"; jest.mock("./greet", () => ({ ...jest.requireActual("./greet"), sayGoodBye: (name: string) => `Good bye, ${name}.`, })); test("挨拶を返す(本来の実装どおり)", () => { expect(greet("Taro")).toBe("Hello! Taro."); }); test("さよならを返す(本来の実装ではない)", () => { const message = `${sayGoodBye("Taro")} See you.`; expect(message).toBe("Good bye, Taro. See you."); }); ================================================ FILE: src/04/03/index.test.ts ================================================ import { getGreet } from "."; import * as Fetchers from "../fetchers"; import { httpError } from "../fetchers/fixtures"; jest.mock("../fetchers"); describe("getGreet", () => { test("データ取得成功時:ユーザー名がない場合", async () => { // getMyProfile が resolve した時の値を再現 jest.spyOn(Fetchers, "getMyProfile").mockResolvedValueOnce({ id: "xxxxxxx-123456", email: "taroyamada@myapi.testing.com", }); await expect(getGreet()).resolves.toBe("Hello, anonymous user!"); }); test("データ取得成功時:ユーザー名がある場合", async () => { jest.spyOn(Fetchers, "getMyProfile").mockResolvedValueOnce({ id: "xxxxxxx-123456", email: "taroyamada@myapi.testing.com", name: "taroyamada", }); await expect(getGreet()).resolves.toBe("Hello, taroyamada!"); }); test("データ取得失敗時", async () => { // getMyProfile が reject した時の値を再現 jest.spyOn(Fetchers, "getMyProfile").mockRejectedValueOnce(httpError); await expect(getGreet()).rejects.toMatchObject({ err: { message: "internal server error" }, }); }); test("データ取得失敗時、エラー相当のデータが例外としてスローされる", async () => { expect.assertions(1); jest.spyOn(Fetchers, "getMyProfile").mockRejectedValueOnce(httpError); try { await getGreet(); } catch (err) { expect(err).toMatchObject(httpError); } }); }); ================================================ FILE: src/04/03/index.ts ================================================ import { getMyProfile } from "../fetchers"; export async function getGreet() { // テストしたいのはここのデータ取得と const data = await getMyProfile(); // 取得したデータをここで連結する処理 if (!data.name) { return `Hello, anonymous user!`; } return `Hello, ${data.name}!`; } ================================================ FILE: src/04/04/index.test.ts ================================================ import { getMyArticleLinksByCategory } from "."; import * as Fetchers from "../fetchers"; import { getMyArticlesData, httpError } from "../fetchers/fixtures"; jest.mock("../fetchers"); function mockGetMyArticles(status = 200) { if (status > 299) { return jest .spyOn(Fetchers, "getMyArticles") .mockRejectedValueOnce(httpError); } return jest .spyOn(Fetchers, "getMyArticles") .mockResolvedValueOnce(getMyArticlesData); } test("指定したタグをもつ記事が一件もない場合、null が返る", async () => { mockGetMyArticles(); const data = await getMyArticleLinksByCategory("playwright"); expect(data).toBeNull(); }); test("指定したタグをもつ記事が一件以上ある場合、リンク一覧が返る", async () => { mockGetMyArticles(); const data = await getMyArticleLinksByCategory("testing"); expect(data).toMatchObject([ { link: "/articles/howto-testing-with-typescript", title: "TypeScript を使ったテストの書き方", }, { link: "/articles/react-component-testing-with-jest", title: "Jest ではじめる React のコンポーネントテスト", }, ]); }); test("データ取得に失敗した場合、reject される", async () => { mockGetMyArticles(500); await getMyArticleLinksByCategory("testing").catch((err) => { expect(err).toMatchObject({ err: { message: "internal server error" }, }); }); }); ================================================ FILE: src/04/04/index.ts ================================================ import { getMyArticles } from "../fetchers"; export async function getMyArticleLinksByCategory(category: string) { // データを取得する関数 const data = await getMyArticles(); // 取得したデータのうち、指定したタグが含まれる記事に絞り込む const articles = data.articles.filter((article) => article.tags.includes(category) ); if (!articles.length) { // 該当記事がない場合、null を返す return null; } // 該当記事がある場合、一覧向けに加工したデータを返す return articles.map((article) => ({ title: article.title, link: `/articles/${article.id}`, })); } ================================================ FILE: src/04/05/checkConfig.test.ts ================================================ import { checkConfig } from "./checkConfig"; test("モック関数は実行時引数のオブジェクト検証ができる", () => { const mockFn = jest.fn(); checkConfig(mockFn); expect(mockFn).toHaveBeenCalledWith({ mock: true, feature: { spy: true }, }); }); test("expect.objectContaining による部分検証", () => { const mockFn = jest.fn(); checkConfig(mockFn); expect(mockFn).toHaveBeenCalledWith( expect.objectContaining({ feature: { spy: true }, }) ); }); ================================================ FILE: src/04/05/checkConfig.ts ================================================ const config = { mock: true, feature: { spy: true }, }; export function checkConfig(callback?: (payload: object) => void) { callback?.(config); } ================================================ FILE: src/04/05/greet.test.ts ================================================ import { greet } from "./greet"; test("モック関数は実行された", () => { const mockFn = jest.fn(); mockFn(); expect(mockFn).toBeCalled(); }); test("モック関数は実行されていない", () => { const mockFn = jest.fn(); expect(mockFn).not.toBeCalled(); }); test("モック関数は実行された回数を記録している", () => { const mockFn = jest.fn(); mockFn(); expect(mockFn).toHaveBeenCalledTimes(1); mockFn(); expect(mockFn).toHaveBeenCalledTimes(2); }); test("モック関数は関数の中でも実行できる", () => { const mockFn = jest.fn(); function greet() { mockFn(); } greet(); expect(mockFn).toHaveBeenCalledTimes(1); }); test("モック関数は実行時の引数を記録している", () => { const mockFn = jest.fn(); function greet(message: string) { mockFn(message); } greet("hello"); expect(mockFn).toHaveBeenCalledWith("hello"); }); test("モック関数はテスト対象の引数として使用できる", () => { const mockFn = jest.fn(); greet("Jiro", mockFn); expect(mockFn).toHaveBeenCalledWith("Hello! Jiro"); }); ================================================ FILE: src/04/05/greet.ts ================================================ export function greet(name: string, callback?: (message: string) => void) { callback?.(`Hello! ${name}`); } ================================================ FILE: src/04/06/index.test.ts ================================================ import { checkLength } from "."; import * as Fetchers from "../fetchers"; import { postMyArticle } from "../fetchers"; import { httpError, postMyArticleData } from "../fetchers/fixtures"; import { ArticleInput } from "../fetchers/type"; jest.mock("../fetchers"); function mockPostMyArticle(input: ArticleInput, status = 200) { if (status > 299) { return jest .spyOn(Fetchers, "postMyArticle") .mockRejectedValueOnce(httpError); } try { checkLength(input.title); checkLength(input.body); return jest .spyOn(Fetchers, "postMyArticle") .mockResolvedValue({ ...postMyArticleData, ...input }); } catch (err) { return jest .spyOn(Fetchers, "postMyArticle") .mockRejectedValueOnce(httpError); } } function inputFactory(input?: Partial) { return { tags: ["testing"], title: "TypeScript を使ったテストの書き方", body: "テストを書く時、TypeScript を使うことで、テストの保守性が向上します。", ...input, }; } test("バリデーションに成功した場合、成功レスポンスが返る", async () => { // バリデーションに通過する入力値を用意 const input = inputFactory(); // 入力値を含んだ成功レスポンスが返るよう、モックを施す const mock = mockPostMyArticle(input); // テスト対象の関数に、input を与えて実行 const data = await postMyArticle(input); // 取得したデータに、入力内容が含まれているかを検証 expect(data).toMatchObject(expect.objectContaining(input)); // モック関数が呼び出されたかを検証 expect(mock).toHaveBeenCalled(); }); test("バリデーションに失敗した場合、reject される", async () => { expect.assertions(2); // バリデーションに通過しない入力値を用意 const input = inputFactory({ title: "", body: "" }); // 入力値を含んだ成功レスポンスが返るよう、モックを施す const mock = mockPostMyArticle(input); // バリデーションに通過せず reject されるかを検証 await postMyArticle(input).catch((err) => { // エラーオブジェクトをもって reject されたことを検証 expect(err).toMatchObject({ err: { message: expect.anything() } }); // モック関数が呼び出されたことを検証 expect(mock).toHaveBeenCalled(); }); }); test("データ取得に失敗した場合、reject される", async () => { expect.assertions(2); // バリデーションに通過する入力値を用意 const input = inputFactory(); // 失敗レスポンスが返るようモックを施す const mock = mockPostMyArticle(input, 500); // reject されるかを検証 await postMyArticle(input).catch((err) => { // エラーオブジェクトをもって reject されたことを検証 expect(err).toMatchObject({ err: { message: expect.anything() } }); // モック関数が呼び出されたことを検証 expect(mock).toHaveBeenCalled(); }); }); ================================================ FILE: src/04/06/index.ts ================================================ export class ValidationError extends Error { } export function checkLength(value: string) { if (value.length === 0) { throw new ValidationError("1文字以上入力してください"); } } ================================================ FILE: src/04/07/index.test.ts ================================================ import { greetByTime } from "."; describe("greetByTime(", () => { beforeEach(() => { jest.useFakeTimers(); }); afterEach(() => { jest.useRealTimers(); }); test("朝は「おはよう」を返す", () => { jest.setSystemTime(new Date(2023, 4, 23, 8, 0, 0)); expect(greetByTime()).toBe("おはよう"); }); test("昼は「こんにちは」を返す", () => { jest.setSystemTime(new Date(2023, 4, 23, 14, 0, 0)); expect(greetByTime()).toBe("こんにちは"); }); test("夜は「こんばんは」を返す", () => { jest.setSystemTime(new Date(2023, 4, 23, 21, 0, 0)); expect(greetByTime()).toBe("こんばんは"); }); }); ================================================ FILE: src/04/07/index.ts ================================================ export function greetByTime() { const hour = new Date().getHours(); if (hour < 12) { return "おはよう"; } else if (hour < 18) { return "こんにちは"; } return "こんばんは"; } ================================================ FILE: src/04/fetchers/fixtures.ts ================================================ import type { Article, Articles, HttpError } from "./type"; export const httpError: HttpError = { err: { message: "internal server error" }, }; export const getMyArticlesData: Articles = { articles: [ { id: "howto-testing-with-typescript", createdAt: "2022-07-19T22:38:41.005Z", tags: ["testing"], title: "TypeScript を使ったテストの書き方", body: "テストを書く時、TypeScript を使うことで、テストの保守性が向上します…", }, { id: "nextjs-link-component", createdAt: "2022-07-19T22:38:41.005Z", tags: ["nextjs"], title: "Next.js の Link コンポーネント", body: "Next.js の画面遷移には、Link コンポーネントを使用します…", }, { id: "react-component-testing-with-jest", createdAt: "2022-07-19T22:38:41.005Z", tags: ["testing", "react"], title: "Jest ではじめる React のコンポーネントテスト", body: "Jest は単体テストとして、UIコンポーネントのテストが可能です…", }, ], }; export const postMyArticleData: Article = { id: "xxxxxxx-123456", createdAt: "2022-07-19T22:38:41.005Z", tags: ["testing", "react"], title: "Jest ではじめる React のコンポーネントテスト", body: "Jest は単体テストとして、UIコンポーネントのテストが可能です。", }; ================================================ FILE: src/04/fetchers/index.ts ================================================ import type { Article, ArticleInput, Articles, Profile } from "./type"; async function handleResponse(res: Response) { const data = await res.json(); if (!res.ok) { throw data; } return data; } const host = (path: string) => `https://myapi.testing.com${path}`; export function getMyProfile(): Promise { return fetch(host("/my/profile")).then(handleResponse); } export function getMyArticles(): Promise { return fetch(host("/my/articles")).then(handleResponse); } export function postMyArticle(input: ArticleInput): Promise
{ return fetch(host("/my/articles"), { method: "POST", body: JSON.stringify(input), }).then(handleResponse); } ================================================ FILE: src/04/fetchers/type.ts ================================================ export type HttpError = { err: { message: string }; }; export type Profile = { id: string; name?: string; age?: number; email: string; }; export type Article = { id: string; createdAt: string; tags: string[]; title: string; body: string; }; export type Articles = { articles: Article[]; }; export type ArticleInput = { tags: string[]; title: string; body: string; }; export type PartialArticleInput = { tags?: string[]; title?: string; body?: string; }; ================================================ FILE: src/05/03/Form.stories.tsx ================================================ import { ComponentMeta, ComponentStoryObj } from "@storybook/react"; import { Form } from "./Form"; export default { component: Form, args: { name: "taro" }, } as ComponentMeta; type Story = ComponentStoryObj; export const Default: Story = {}; ================================================ FILE: src/05/03/Form.test.tsx ================================================ import { fireEvent, logRoles, render, screen } from "@testing-library/react"; import { Form } from "./Form"; test("名前の表示", () => { render(
); expect(screen.getByText("taro")).toBeInTheDocument(); }); test("ボタンの表示", () => { render(); expect(screen.getByRole("button")).toBeInTheDocument(); }); test("見出しの表示", () => { render(); expect(screen.getByRole("heading")).toHaveTextContent("アカウント情報"); }); test("ボタンを押下すると、イベントハンドラーが呼ばれる", () => { const mockFn = jest.fn(); render(); fireEvent.click(screen.getByRole("button")); expect(mockFn).toHaveBeenCalled(); }); test("Snapshot: アカウント名「taro」が表示される", () => { const { container } = render(); expect(container).toMatchSnapshot(); }); test("logRoles: レンダリング結果からロール・アクセシブルネームを確認", () => { const { container } = render(); logRoles(container); }); ================================================ FILE: src/05/03/Form.tsx ================================================ type Props = { name: string; onSubmit?: (event: React.FormEvent) => void; }; export const Form = ({ name, onSubmit }: Props) => { return ( { event.preventDefault(); onSubmit?.(event); }} >

アカウント情報

{name}

); }; ================================================ FILE: src/05/03/__snapshots__/Form.test.tsx.snap ================================================ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Snapshot: アカウント名「taro」が表示される 1`] = `

アカウント情報

taro

`; ================================================ FILE: src/05/04/ArticleList.stories.tsx ================================================ import { ComponentMeta, ComponentStoryObj } from "@storybook/react"; import { ArticleList } from "./ArticleList"; import { items } from "./fixture"; export default { component: ArticleList, } as ComponentMeta; type Story = ComponentStoryObj; export const Default: Story = { args: { items }, }; export const NoItem: Story = { args: { items: [] }, }; ================================================ FILE: src/05/04/ArticleList.test.tsx ================================================ import { render, screen, within } from "@testing-library/react"; import { ArticleList } from "./ArticleList"; import { items } from "./fixture"; test("タイトルの表示", () => { render(); expect(screen.getByRole("heading", { name: "記事一覧" })).toBeInTheDocument(); }); test("items の数だけ一覧表示される", () => { render(); expect(screen.getAllByRole("listitem")).toHaveLength(3); }); test("items の数だけ一覧表示される", () => { render(); const list = screen.getByRole("list"); expect(list).toBeInTheDocument(); expect(within(list).getAllByRole("listitem")).toHaveLength(3); }); test("一覧アイテムが空のとき「投稿記事がありません」が表示される", () => { render(); const list = screen.queryByRole("list"); expect(list).not.toBeInTheDocument(); expect(list).toBeNull(); expect(screen.getByText("投稿記事がありません")).toBeInTheDocument(); }); test("Snapshot: items の数だけ一覧表示される", () => { const { container } = render(); expect(container).toMatchSnapshot(); }); ================================================ FILE: src/05/04/ArticleList.tsx ================================================ import { ArticleListItem, ItemProps } from "./ArticleListItem"; type Props = { items: ItemProps[]; }; export const ArticleList = ({ items }: Props) => { return (

記事一覧

{items.length ? (
    {items.map((item) => ( ))}
) : (

投稿記事がありません

)}
); }; ================================================ FILE: src/05/04/ArticleListItem.test.tsx ================================================ import { render, screen } from "@testing-library/react"; import { ArticleListItem, ItemProps } from "./ArticleListItem"; const item: ItemProps = { id: "howto-testing-with-typescript", title: "TypeScript を使ったテストの書き方", body: "テストを書く時、TypeScript を使うことで、テストの保守性が向上します…", }; test("ID に紐づいたリンクが表示される", () => { render(); expect(screen.getByRole("link", { name: "もっと見る" })).toHaveAttribute( "href", "/articles/howto-testing-with-typescript" ); }); test("Snapshot: 一覧要素が表示される", () => { const { container } = render(); expect(container).toMatchSnapshot(); }); ================================================ FILE: src/05/04/ArticleListItem.tsx ================================================ export type ItemProps = { id: string; title: string; body: string; }; export const ArticleListItem = ({ id, title, body }: ItemProps) => { return (
  • {title}

    {body}

    もっと見る
  • ); }; ================================================ FILE: src/05/04/__snapshots__/ArticleList.test.tsx.snap ================================================ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Snapshot: items の数だけ一覧表示される 1`] = `

    記事一覧

    • TypeScript を使ったテストの書き方

      テストを書く時、TypeScript を使うことで、テストの保守性が向上します…

      もっと見る
    • Next.js の Link コンポーネント

      Next.js の画面遷移には、Link コンポーネントを使用します…

      もっと見る
    • Jest ではじめる React のコンポーネントテスト

      Jest は単体テストとして、UIコンポーネントのテストが可能です…

      もっと見る
    `; ================================================ FILE: src/05/04/__snapshots__/ArticleListItem.test.tsx.snap ================================================ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Snapshot: 一覧要素が表示される 1`] = `
  • TypeScript を使ったテストの書き方

    テストを書く時、TypeScript を使うことで、テストの保守性が向上します…

    もっと見る
  • `; ================================================ FILE: src/05/04/fixture.ts ================================================ import { ItemProps } from "./ArticleListItem"; export const items: ItemProps[] = [ { id: "howto-testing-with-typescript", title: "TypeScript を使ったテストの書き方", body: "テストを書く時、TypeScript を使うことで、テストの保守性が向上します…", }, { id: "nextjs-link-component", title: "Next.js の Link コンポーネント", body: "Next.js の画面遷移には、Link コンポーネントを使用します…", }, { id: "react-component-testing-with-jest", title: "Jest ではじめる React のコンポーネントテスト", body: "Jest は単体テストとして、UIコンポーネントのテストが可能です…", }, ]; ================================================ FILE: src/05/05/Agreement.stories.tsx ================================================ import { ComponentMeta, ComponentStoryObj } from "@storybook/react"; import { Agreement } from "./Agreement"; export default { component: Agreement, } as ComponentMeta; type Story = ComponentStoryObj; export const Default: Story = {}; ================================================ FILE: src/05/05/Agreement.test.tsx ================================================ import { render, screen } from "@testing-library/react"; import { Agreement } from "./Agreement"; test("fieldset のアクセシブルネームは、legend を引用している", () => { render(); expect( screen.getByRole("group", { name: "利用規約の同意" }) ).toBeInTheDocument(); }); test("チェックボックスはチェックが入っていない", () => { render(); expect(screen.getByRole("checkbox")).not.toBeChecked(); }); test("利用規約へのリンクがある", () => { render(); expect(screen.getByRole("link")).toBeInTheDocument(); expect(screen.getByRole("link")).toHaveTextContent("利用規約"); expect(screen.getByRole("link")).toHaveAttribute("href", "/terms"); expect(screen.getByRole("link", { name: "利用規約" })).toHaveAttribute( "href", "/terms" ); }); test("Snapshot: 利用規約の同意が表示される", () => { const { container } = render(); expect(container).toMatchSnapshot(); }); ================================================ FILE: src/05/05/Agreement.tsx ================================================ type Props = { onChange?: React.ChangeEventHandler; }; export const Agreement = ({ onChange }: Props) => { return (
    利用規約の同意
    ); }; ================================================ FILE: src/05/05/Form.stories.tsx ================================================ import { ComponentMeta, ComponentStoryObj } from "@storybook/react"; import { Form } from "./Form"; export default { component: Form, } as ComponentMeta; type Story = ComponentStoryObj; export const Default: Story = {}; ================================================ FILE: src/05/05/Form.test.tsx ================================================ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { Form } from "./Form"; const user = userEvent.setup(); test("form のアクセシブルネームは、見出しを引用している", () => { render(
    ); expect( screen.getByRole("form", { name: "新規アカウント登録" }) ).toBeInTheDocument(); }); test("主要エリアが表示されている", () => { render(); expect( screen.getByRole("heading", { name: "新規アカウント登録" }) ).toBeInTheDocument(); expect( screen.getByRole("group", { name: "アカウント情報の入力" }) ).toBeInTheDocument(); expect( screen.getByRole("group", { name: "利用規約の同意" }) ).toBeInTheDocument(); expect( screen.getByRole("button", { name: "サインアップ" }) ).toBeInTheDocument(); }); test("「サインアップ」ボタンは非活性", () => { render(); expect(screen.getByRole("button", { name: "サインアップ" })).toBeDisabled(); }); test("「利用規約の同意」チェックボックスを押下すると「サインアップ」ボタンは活性化", async () => { render(); await user.click(screen.getByRole("checkbox")); expect(screen.getByRole("button", { name: "サインアップ" })).toBeEnabled(); }); test("Snapshot: 新規アカウント登録フォームが表示される", () => { const { container } = render(); expect(container).toMatchSnapshot(); }); ================================================ FILE: src/05/05/Form.tsx ================================================ import { useId, useState } from "react"; import { Agreement } from "./Agreement"; import { InputAccount } from "./InputAccount"; export const Form = () => { const [checked, setChecked] = useState(false); const headingId = useId(); return (

    新規アカウント登録

    { setChecked(event.currentTarget.checked); }} />
    ); }; ================================================ FILE: src/05/05/InputAccount.stories.tsx ================================================ import { ComponentMeta, ComponentStoryObj } from "@storybook/react"; import { InputAccount } from "./InputAccount"; export default { component: InputAccount, } as ComponentMeta; type Story = ComponentStoryObj; export const Default: Story = {}; ================================================ FILE: src/05/05/InputAccount.test.tsx ================================================ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { InputAccount } from "./InputAccount"; const user = userEvent.setup(); test("fieldset のアクセシブルネームは、legend を引用している", () => { render(); expect( screen.getByRole("group", { name: "アカウント情報の入力" }) ).toBeInTheDocument(); }); test("メールアドレス入力欄", async () => { render(); const textbox = screen.getByRole("textbox", { name: "メールアドレス" }); const value = "taro.tanaka@example.com"; await user.type(textbox, value); expect(screen.getByDisplayValue(value)).toBeInTheDocument(); }); test("パスワード入力欄", async () => { render(); expect(() => screen.getByPlaceholderText("8文字以上で入力")).not.toThrow(); expect(() => screen.getByRole("textbox", { name: "パスワード" })).toThrow(); }); test("パスワード入力欄", async () => { render(); const password = screen.getByPlaceholderText("8文字以上で入力"); const value = "abcd1234"; await user.type(password, value); expect(screen.getByDisplayValue(value)).toBeInTheDocument(); }); test("Snapshot: アカウント情報の入力フォームが表示される", () => { const { container } = render(); expect(container).toMatchSnapshot(); }); ================================================ FILE: src/05/05/InputAccount.tsx ================================================ export const InputAccount = () => { return (
    アカウント情報の入力
    ); }; ================================================ FILE: src/05/05/__snapshots__/Agreement.test.tsx.snap ================================================ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Snapshot: 利用規約の同意が表示される 1`] = `
    利用規約の同意
    `; ================================================ FILE: src/05/05/__snapshots__/Form.test.tsx.snap ================================================ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Snapshot: 新規アカウント登録フォームが表示される 1`] = `

    新規アカウント登録

    アカウント情報の入力
    利用規約の同意
    `; ================================================ FILE: src/05/05/__snapshots__/InputAccount.test.tsx.snap ================================================ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Snapshot: アカウント情報の入力フォームが表示される 1`] = `
    アカウント情報の入力
    `; ================================================ FILE: src/05/06/ContactNumber.stories.tsx ================================================ import { ComponentMeta, ComponentStoryObj } from "@storybook/react"; import { ContactNumber } from "./ContactNumber"; export default { component: ContactNumber, } as ComponentMeta; type Story = ComponentStoryObj; export const Default: Story = {}; ================================================ FILE: src/05/06/ContactNumber.test.tsx ================================================ import { render, screen } from "@testing-library/react"; import { ContactNumber } from "./ContactNumber"; describe("連絡先", () => { test("タイトル", () => { render(); expect(screen.getByText("連絡先")).toBeInTheDocument(); }); test("電話番号", () => { render(); expect(screen.getByLabelText("電話番号")).toBeInTheDocument(); }); test("お名前", () => { render(); expect(screen.getByLabelText("お名前")).toBeInTheDocument(); }); }); ================================================ FILE: src/05/06/ContactNumber.tsx ================================================ export const ContactNumber = () => { return (
    連絡先
    ); }; ================================================ FILE: src/05/06/DeliveryAddress.stories.tsx ================================================ import { ComponentMeta, ComponentStoryObj } from "@storybook/react"; import { DeliveryAddress } from "./DeliveryAddress"; export default { component: DeliveryAddress, } as ComponentMeta; type Story = ComponentStoryObj; export const Default: Story = {}; ================================================ FILE: src/05/06/DeliveryAddress.test.tsx ================================================ import { render, screen } from "@testing-library/react"; import { DeliveryAddress } from "./DeliveryAddress"; describe("お届け先", () => { test("タイトル", () => { render(); expect(screen.getByText("お届け先")).toBeInTheDocument(); }); test("タイトルが変更できる", () => { render(); expect(screen.getByText("新しいお届け先")).toBeInTheDocument(); }); test("郵便番号", () => { render(); expect( screen.getByRole("textbox", { name: "郵便番号" }) ).toBeInTheDocument(); }); test("都道府県", () => { render(); expect( screen.getByRole("textbox", { name: "都道府県" }) ).toBeInTheDocument(); }); test("市区町村", () => { render(); expect( screen.getByRole("textbox", { name: "市区町村" }) ).toBeInTheDocument(); }); test("番地番号", () => { render(); expect( screen.getByRole("textbox", { name: "番地番号" }) ).toBeInTheDocument(); }); }); ================================================ FILE: src/05/06/DeliveryAddress.tsx ================================================ export const DeliveryAddress = ({ title = "お届け先" }: { title?: string }) => { return (
    {title}
    ); }; ================================================ FILE: src/05/06/Form.stories.tsx ================================================ import { ComponentMeta, ComponentStoryObj } from "@storybook/react"; import { deliveryAddresses } from "./fixtures"; import { Form } from "./Form"; export default { component: Form, } as ComponentMeta; type Story = ComponentStoryObj; export const NoDeliveryAddresses: Story = { storyName: "過去のお届け先がない場合", args: { deliveryAddresses: [] }, }; export const HasDeliveryAddresses: Story = { storyName: "過去のお届け先がある場合", args: { deliveryAddresses }, }; ================================================ FILE: src/05/06/Form.test.tsx ================================================ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { deliveryAddresses } from "./fixtures"; import { Form } from "./Form"; const user = userEvent.setup(); async function inputContactNumber( inputValues = { name: "田中 太郎", phoneNumber: "000-0000-0000", } ) { await user.type( screen.getByRole("textbox", { name: "電話番号" }), inputValues.phoneNumber ); await user.type( screen.getByRole("textbox", { name: "お名前" }), inputValues.name ); return inputValues; } async function inputDeliveryAddress( inputValues = { postalCode: "167-0051", prefectures: "東京都", municipalities: "杉並区荻窪1", streetNumber: "00-00", } ) { await user.type( screen.getByRole("textbox", { name: "郵便番号" }), inputValues.postalCode ); await user.type( screen.getByRole("textbox", { name: "都道府県" }), inputValues.prefectures ); await user.type( screen.getByRole("textbox", { name: "市区町村" }), inputValues.municipalities ); await user.type( screen.getByRole("textbox", { name: "番地番号" }), inputValues.streetNumber ); return inputValues; } async function clickSubmit() { await user.click( screen.getByRole("button", { name: "注文内容の確認へ進む" }) ); } function mockHandleSubmit() { const mockFn = jest.fn(); const onSubmit = (event: React.FormEvent) => { event.preventDefault(); const formData = new FormData(event.currentTarget); const data: { [k: string]: unknown } = {}; formData.forEach((value, key) => (data[key] = value)); mockFn(data); }; return [mockFn, onSubmit] as const; } describe("過去のお届け先がない場合", () => { test("お届け先入力欄がある", () => { render(
    ); expect(screen.getByRole("group", { name: "連絡先" })).toBeInTheDocument(); expect(screen.getByRole("group", { name: "お届け先" })).toBeInTheDocument(); }); test("入力・送信すると、入力内容が送信される", async () => { const [mockFn, onSubmit] = mockHandleSubmit(); render(); const contactNumber = await inputContactNumber(); const deliveryAddress = await inputDeliveryAddress(); await clickSubmit(); expect(mockFn).toHaveBeenCalledWith( expect.objectContaining({ ...contactNumber, ...deliveryAddress }) ); }); test("Snapshot", () => { const { container } = render(); expect(container).toMatchSnapshot(); }); }); describe("過去のお届け先がある場合", () => { test("設問に答えるまで、お届け先を選べない", () => { render(); expect( screen.getByRole("group", { name: "新しいお届け先を登録しますか?" }) ).toBeInTheDocument(); expect( screen.getByRole("group", { name: "過去のお届け先" }) ).toBeDisabled(); }); test("「いいえ」を選択・入力・送信すると、入力内容が送信される", async () => { const [mockFn, onSubmit] = mockHandleSubmit(); render(); await user.click(screen.getByLabelText("いいえ")); expect( screen.getByRole("group", { name: "過去のお届け先" }) ).toBeInTheDocument(); const inputValues = await inputContactNumber(); await clickSubmit(); expect(mockFn).toHaveBeenCalledWith(expect.objectContaining(inputValues)); }); test("「はい」を選択・入力・送信すると、入力内容が送信される", async () => { const [mockFn, onSubmit] = mockHandleSubmit(); render(); await user.click(screen.getByLabelText("はい")); expect( screen.getByRole("group", { name: "新しいお届け先" }) ).toBeInTheDocument(); const contactNumber = await inputContactNumber(); const deliveryAddress = await inputDeliveryAddress(); await clickSubmit(); expect(mockFn).toHaveBeenCalledWith( expect.objectContaining({ ...contactNumber, ...deliveryAddress }) ); }); test("Snapshot", () => { const { container } = render( ); expect(container).toMatchSnapshot(); }); }); ================================================ FILE: src/05/06/Form.tsx ================================================ import { useState } from "react"; import { ContactNumber } from "./ContactNumber"; import { DeliveryAddress } from "./DeliveryAddress"; import { PastDeliveryAddress } from "./PastDeliveryAddress"; import { RegisterDeliveryAddress } from "./RegisterDeliveryAddress"; export type AddressOption = React.ComponentProps<"option"> & { id: string }; export type Props = { deliveryAddresses?: AddressOption[]; onSubmit?: (event: React.FormEvent) => void; }; export const Form = (props: Props) => { const [registerNew, setRegisterNew] = useState( undefined ); return (

    お届け先情報の入力

    {props.deliveryAddresses?.length ? ( <> {registerNew ? ( ) : ( )} ) : ( )}
    ); }; ================================================ FILE: src/05/06/PastDeliveryAddress.stories.tsx ================================================ import { ComponentMeta, ComponentStoryObj } from "@storybook/react"; import { PastDeliveryAddress } from "./PastDeliveryAddress"; export default { component: PastDeliveryAddress, args: { options: [ { id: "xxx", value: "xxx", children: "〒167-0051 東京都杉並区荻窪1-00-00", }, ], }, } as ComponentMeta; type Story = ComponentStoryObj; export const Disabled: Story = { args: { disabled: true, }, }; export const Enabled: Story = { args: { disabled: false, }, }; ================================================ FILE: src/05/06/PastDeliveryAddress.test.tsx ================================================ import { render, screen } from "@testing-library/react"; import { PastDeliveryAddress } from "./PastDeliveryAddress"; describe("過去のお届け先", () => { const options = [ { id: "xxx", value: "xxx", children: "〒167-0051 東京都杉並区荻窪1-00-00", }, ]; test("disabled={true} の場合、combobox も非活性", () => { render(); expect(screen.getByRole("combobox")).toBeDisabled(); }); }); ================================================ FILE: src/05/06/PastDeliveryAddress.tsx ================================================ import { AddressOption } from "./Form"; export const PastDeliveryAddress = ({ disabled, options, }: { disabled?: boolean; options: AddressOption[]; }) => { return (
    過去のお届け先
    ); }; ================================================ FILE: src/05/06/RegisterDeliveryAddress.stories.tsx ================================================ import { ComponentMeta, ComponentStoryObj } from "@storybook/react"; import { RegisterDeliveryAddress } from "./RegisterDeliveryAddress"; export default { component: RegisterDeliveryAddress, } as ComponentMeta; type Story = ComponentStoryObj; export const Default: Story = {}; ================================================ FILE: src/05/06/RegisterDeliveryAddress.test.tsx ================================================ import { fireEvent, render, screen } from "@testing-library/react"; import { RegisterDeliveryAddress } from "./RegisterDeliveryAddress"; describe("新しいお届け先を登録しますか?", () => { test("ラジオボタンをクリックすると、コールバックハンドラが呼ばれる", () => { const fn = jest.fn(); render(); fireEvent.click(screen.getByLabelText("いいえ")); expect(fn).toHaveBeenCalledWith(false); fireEvent.click(screen.getByLabelText("はい")); expect(fn).toHaveBeenCalledWith(true); }); }); ================================================ FILE: src/05/06/RegisterDeliveryAddress.tsx ================================================ type Props = { onChange: (flag: boolean) => void }; export const RegisterDeliveryAddress = ({ onChange }: Props) => { return (
    新しいお届け先を登録しますか?
    ); }; ================================================ FILE: src/05/06/__snapshots__/Form.test.tsx.snap ================================================ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`過去のお届け先がある場合 Snapshot 1`] = `

    お届け先情報の入力

    連絡先
    新しいお届け先を登録しますか?
    過去のお届け先

    `; exports[`過去のお届け先がない場合 Snapshot 1`] = `

    お届け先情報の入力

    連絡先
    お届け先

    `; ================================================ FILE: src/05/06/fixtures.ts ================================================ export const deliveryAddresses = [ { id: "address_id_xxxx", value: "address_id_xxxx", children: "〒167-0051 東京都杉並区荻窪1-00-00", }, ]; ================================================ FILE: src/05/07/RegisterAddress.stories.tsx ================================================ import { ComponentMeta, ComponentStoryObj } from "@storybook/react"; import { RegisterAddress } from "./RegisterAddress"; export default { component: RegisterAddress, } as ComponentMeta; type Story = ComponentStoryObj; export const Default: Story = {}; ================================================ FILE: src/05/07/RegisterAddress.test.tsx ================================================ import { render, screen } from "@testing-library/react"; import { mockPostMyAddress } from "./fetchers/mock"; import { RegisterAddress } from "./RegisterAddress"; import { clickSubmit, inputContactNumber, inputDeliveryAddress, } from "./testingUtils"; jest.mock("./fetchers"); async function fillValuesAndSubmit() { const contactNumber = await inputContactNumber(); const deliveryAddress = await inputDeliveryAddress(); const submitValues = { ...contactNumber, ...deliveryAddress }; await clickSubmit(); return submitValues; } async function fillInvalidValuesAndSubmit() { const contactNumber = await inputContactNumber({ name: "田中 太郎", phoneNumber: "abc-defg-hijkl", }); const deliveryAddress = await inputDeliveryAddress(); const submitValues = { ...contactNumber, ...deliveryAddress }; await clickSubmit(); return submitValues; } beforeEach(() => { jest.resetAllMocks(); }); test("成功時「登録しました」が表示される", async () => { const mockFn = mockPostMyAddress(); render(); const submitValues = await fillValuesAndSubmit(); expect(mockFn).toHaveBeenCalledWith(expect.objectContaining(submitValues)); expect(screen.getByText("登録しました")).toBeInTheDocument(); }); test("失敗時「登録に失敗しました」が表示される", async () => { const mockFn = mockPostMyAddress(500); render(); const submitValues = await fillValuesAndSubmit(); expect(mockFn).toHaveBeenCalledWith(expect.objectContaining(submitValues)); expect(screen.getByText("登録に失敗しました")).toBeInTheDocument(); }); test("バリデーションエラー時「不正な入力値が含まれています」が表示される", async () => { render(); await fillInvalidValuesAndSubmit(); expect(screen.getByText("不正な入力値が含まれています")).toBeInTheDocument(); }); test("不明なエラー時「不明なエラーが発生しました」が表示される", async () => { render(); await fillValuesAndSubmit(); expect(screen.getByText("不明なエラーが発生しました")).toBeInTheDocument(); }); test("Snapshot: 登録フォームが表示される", async () => { mockPostMyAddress(); // const mockFn = mockPostMyAddress(); const { container } = render(); // const submitValues = await fillValuesAndSubmit(); // expect(mockFn).toHaveBeenCalledWith(expect.objectContaining(submitValues)); expect(container).toMatchSnapshot(); }); ================================================ FILE: src/05/07/RegisterAddress.tsx ================================================ import { useState } from "react"; import { Form } from "../06/Form"; import { postMyAddress } from "./fetchers"; import { handleSubmit } from "./handleSubmit"; import { checkPhoneNumber, ValidationError } from "./validations"; export const RegisterAddress = () => { const [postResult, setPostResult] = useState(""); return (
    { try { checkPhoneNumber(values.phoneNumber); postMyAddress(values) .then(() => { setPostResult("登録しました"); }) .catch(() => { setPostResult("登録に失敗しました"); }); } catch (err) { if (err instanceof ValidationError) { setPostResult("不正な入力値が含まれています"); return; } setPostResult("不明なエラーが発生しました"); } })} /> {postResult &&

    {postResult}

    }
    ); }; ================================================ FILE: src/05/07/__snapshots__/RegisterAddress.test.tsx.snap ================================================ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Snapshot: 登録フォームが表示される 1`] = `

    お届け先情報の入力

    連絡先
    お届け先

    `; ================================================ FILE: src/05/07/fetchers/fixtures.ts ================================================ import type { HttpError, Result } from "./type"; export const httpError: HttpError = { err: { message: "internal server error" }, }; export const postMyAddressMock: Result = { result: "ok", }; ================================================ FILE: src/05/07/fetchers/index.ts ================================================ import { Result } from "./type"; async function handleResponse(res: Response) { const data = await res.json(); if (!res.ok) { throw data; } return data; } const host = (path: string) => `https://myapi.testing.com${path}`; const headers = { Accept: "application/json", "Content-Type": "application/json", }; export function postMyAddress(values: unknown): Promise { return fetch(host("/my/address"), { method: "POST", body: JSON.stringify(values), headers, }).then(handleResponse); } ================================================ FILE: src/05/07/fetchers/mock.ts ================================================ import * as Fetchers from "."; import { httpError, postMyAddressMock } from "./fixtures"; export function mockPostMyAddress(status = 201) { if (status > 299) { return jest .spyOn(Fetchers, "postMyAddress") .mockRejectedValueOnce(httpError); } return jest .spyOn(Fetchers, "postMyAddress") .mockResolvedValueOnce(postMyAddressMock); } ================================================ FILE: src/05/07/fetchers/type.ts ================================================ export type HttpError = { err: { message: string }; }; export type Result = { result: string; }; ================================================ FILE: src/05/07/handleSubmit.ts ================================================ export function handleSubmit(callback: (values: any) => Promise | void) { return (event: React.FormEvent) => { event.preventDefault(); const formData = new FormData(event.currentTarget); const values: { [k: string]: unknown } = {}; formData.forEach((value, key) => (values[key] = value)); return callback(values); }; } ================================================ FILE: src/05/07/testingUtils.ts ================================================ import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; const user = userEvent.setup(); export function getGroupByName(name: string) { return screen.getByRole("group", { name }); } export async function inputContactNumber( inputValues = { name: "田中 太郎", phoneNumber: "000-0000-0000", } ) { await user.type( screen.getByRole("textbox", { name: "電話番号" }), inputValues.phoneNumber ); await user.type( screen.getByRole("textbox", { name: "お名前" }), inputValues.name ); return inputValues; } export async function inputDeliveryAddress( inputValues = { postalCode: "167-0051", prefectures: "東京都", municipalities: "杉並区荻窪1", streetNumber: "00-00", } ) { await user.type( screen.getByRole("textbox", { name: "郵便番号" }), inputValues.postalCode ); await user.type( screen.getByRole("textbox", { name: "都道府県" }), inputValues.prefectures ); await user.type( screen.getByRole("textbox", { name: "市区町村" }), inputValues.municipalities ); await user.type( screen.getByRole("textbox", { name: "番地番号" }), inputValues.streetNumber ); return inputValues; } export async function clickSubmit() { await user.click( screen.getByRole("button", { name: "注文内容の確認へ進む" }) ); } ================================================ FILE: src/05/07/validations.ts ================================================ export class ValidationError extends Error {} export function checkPhoneNumber(value: any) { if (!value.match(/^[0-9\-]+$/)) { throw new ValidationError(); } } ================================================ FILE: src/06/Articles.test.tsx ================================================ import { render, screen } from "@testing-library/react"; import { Articles } from "./Articles"; xtest("読み込み中の場合「..loading」が表示される", () => { render(); expect(screen.getByText("...loading")).toBeInTheDocument(); }); xtest("一覧要素が空の場合「投稿記事がありません」が表示される", () => { render(); expect(screen.getByText("投稿記事がありません")).toBeInTheDocument(); }); test("一覧要素がある場合、一覧が表示される", () => { const items = [ { id: 1, title: "Testing Next.js" }, { id: 2, title: "Storybook play function" }, { id: 3, title: "Visual Regression Testing " }, ]; render(); expect(screen.getByRole("list")).toBeInTheDocument(); }); ================================================ FILE: src/06/Articles.tsx ================================================ type Props = { items: { id: number; title: string }[]; isLoading?: boolean; }; export const Articles = ({ items, isLoading }: Props) => { if (isLoading) { return

    ...loading

    ; } return (

    記事一覧

    {items.length ? ( ) : (

    投稿記事がありません

    )}
    ); }; ================================================ FILE: src/06/greetByTime.test.ts ================================================ import { greetByTime } from "./greetByTime"; describe("greetByTime(", () => { beforeEach(() => { jest.useFakeTimers(); }); afterEach(() => { jest.useRealTimers(); }); // (1) 「おはよう」を返す関数 test("朝は「おはよう」を返す", () => { jest.setSystemTime(new Date(2023, 4, 23, 8, 0, 0)); expect(greetByTime()).toBe("おはよう"); }); // (2) 「こんにちは」を返す関数 xtest("昼は「こんにちは」を返す", () => { jest.setSystemTime(new Date(2023, 4, 23, 14, 0, 0)); expect(greetByTime()).toBe("こんにちは"); }); // (3) 「こんばんは」を返す関数 xtest("夜は「こんばんは」を返す", () => { jest.setSystemTime(new Date(2023, 4, 23, 21, 0, 0)); expect(greetByTime()).toBe("こんばんは"); }); }); ================================================ FILE: src/06/greetByTime.ts ================================================ export function greetByTime() { const hour = new Date().getHours(); if (hour < 12) { return "おはよう"; } else if (hour < 18) { return "こんにちは"; } return "こんばんは"; } ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { /* Visit https://aka.ms/tsconfig to read more about this file */ /* Projects */ // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ /* Language and Environment */ "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ "jsx": "preserve", /* Specify what JSX code is generated. */ // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ /* Modules */ "module": "commonjs", /* Specify what module code is generated. */ // "rootDir": "./", /* Specify the root folder within your source files. */ // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ // "types": [], /* Specify type package names to be included without being referenced in a source file. */ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ // "resolveJsonModule": true, /* Enable importing .json files. */ // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ /* JavaScript Support */ // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ /* Emit */ // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ // "declarationMap": true, /* Create sourcemaps for d.ts files. */ // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ // "outDir": "./", /* Specify an output folder for all emitted files. */ // "removeComments": true, /* Disable emitting comments. */ // "noEmit": true, /* Disable emitting files from a compilation. */ // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ // "newLine": "crlf", /* Set the newline character for emitting files. */ // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ /* Interop Constraints */ // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ /* Type Checking */ "strict": true, /* Enable all strict type-checking options. */ // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ /* Completeness */ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ "skipLibCheck": true /* Skip type checking all .d.ts files. */ } }