Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

エラーハンドリング対応 #22

Merged
merged 9 commits into from
Dec 20, 2024
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,4 @@ next-env.d.ts

# local server directory
/localdocs
/errordocs
36 changes: 36 additions & 0 deletions documents/テスト仕様書.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,26 @@
- 子コンポーネント`<Result/>`の見出し「Result」が表示されること。
- 子コンポーネント`<IpTable/>`の見出し「Simple IP Address Table」が表示されること。
- 子コンポーネント`<Footer/>`のリンク「Repository」が表示されること。
- Error コンポーネント
- 表示内容確認
- アイコン`bi-exclamation-triangle`が表示されること。
- メッセージ「Something went wrong.」が表示されること。
- ボタン「Retry」が表示されること。
- ボタン「Reload」が表示されること。
- Retry ボタン動作確認
- Retry ボタン押下により reset 関数が呼び出されること。
- Reload ボタン動作確認
- Reload ボタン押下により`window.location.reload()`が呼び出されること。
- BinSpan コンポーネント
- 表示テキスト確認
- インデックスに 0 が指定された場合、太字フォント及びセカンダリテキスト表示が適用されないこと。
- 指定されたインデックスに応じて、セカンダリテキスト表示が適用されること。
- 指定されたインデックスに応じて、太字フォント及びセカンダリテキスト表示が適用されること。
- EventHandlerErrorBoundary コンポーネント
- 例外送出処理確認
- ラップされたイベントハンドラが正常終了した場合、例外が送出されないこと。
- ラップされたイベントハンドラ内でエラーが発生した場合、境界コンポーネントから再送出されること。
- 境界コンポーネントが使用されていない場合、無効呼出エラーが送出されること。
- Footer コンポーネント
- 構成要素確認
- フッターの構成が正しいこと。
Expand All @@ -33,6 +48,7 @@
- 入力値なしでのボタン押下の場合、変換結果 DTO 用の state セッタ関数が呼び出されないこと。
- CIDR 入力なしの場合、入力値に対応した変換結果 DTO が、state セッタ関数によって設定されること。
- CIDR 入力ありの場合、入力値に対応した変換結果 DTO が、state セッタ関数によって設定されること。
- テキストボックス上での Enter キー押下により、Convert ボタンが押下されること。
- Clear ボタン動作確認
- 入力フォームの値が初期化されること。
- エラーメッセージが初期化されること。
Expand Down Expand Up @@ -168,6 +184,17 @@
- Converter
- convert
- IPv4 アドレス文字列と CIDR ブロック文字列を変換結果 DTO に変換できること。
- AssertionError
- name
- エラー名称が正しく設定されること。
- message
- エラーメッセージが正しく設定されること。
- InvalidCallError
- name
- エラー名称が正しく設定されること。
- message
- 単一のエラーメッセージが正しく設定されること。
- 複数のエラーメッセージが正しく設定されること。
- Factory
- createFactory
- Factory インスタンスを生成できること。
Expand Down Expand Up @@ -238,6 +265,9 @@
- createBinArraysOfNetworkAndBroadcast
- オクテットを区切る CIDR に応じた、2 進数ネットワークアドレス配列および 2 進数ブロードキャストアドレス配列を作成できること。
- オクテットをまたぐ CIDR に応じた、2 進数ネットワークアドレス配列および 2 進数ブロードキャストアドレス配列を作成できること。
- EventHandlerUtils
- handleClickButtonByPressingEnter
- 指定されたキーボードイベントの Enter キー押下により、ボタン要素のクリックイベントが発火されること。
- Validator
- validate
- `<input>`要素の値が正常の場合、エラーメッセージが設定されないこと。
Expand Down Expand Up @@ -295,3 +325,9 @@
- 入力チェックが正しく行われること。
- 変換結果が正しく表示されること。
- IP アドレス表が正しく動作すること。
- 異常系
- 画面表示確認
- エラー画面が正しく表示されること。
- ボタン動作確認
- Retry ボタンが正しく動作すること。
- Reload ボタンが正しく動作すること。
10 changes: 10 additions & 0 deletions e2e/assertions.mock
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// 想定外エラー発生テスト用サーバ起動用モックオブジェクト
// ビルド前の npm scripts によるファイル置き換えに使用する
export class Assertions {

private constructor() { }

public static assertNotNull<T>(param: T | null | undefined): asserts param is T {
throw new Error("Mock method 'Assertions#assertNotNull' threw Error.");
}
}
96 changes: 96 additions & 0 deletions e2e/unexpected-error.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { expect, test } from "@playwright/test";

test.describe("異常系", () => {

test.beforeEach(async ({ page }) => {
await page.goto("http://127.0.0.1:3001/ip-address-converter/");
});

test.describe("画面表示確認", () => {

test("エラー画面が正しく表示されること。", async ({ page }) => {

// コンソールログ
const messages: string[] = [];
page.on("console", message => messages.push(message.text()));

// 通常ページ要素
const normalHeading = page.locator("h2");
const convert = page.getByRole("button", { name: "Convert" });

// エラーページ要素
const icon = page.locator("i");
const errorHeading = page.locator("h1");
const retry = page.getByRole("button", { name: "Retry" });
const reload = page.getByRole("button", { name: "Reload" });

// 通常ページ表示
await expect(normalHeading).toBeVisible();
await expect(normalHeading).toHaveText("IP Address Converter");
await expect(errorHeading).toBeHidden();

// 想定外エラー発生
await convert.click();

// エラーページ表示
await expect(icon).toBeVisible();
await expect(icon).toHaveClass(/bi-exclamation-triangle/);

await expect(normalHeading).toBeHidden();
await expect(errorHeading).toBeVisible();
await expect(errorHeading).toHaveText("Something went wrong.");

await expect(retry).toBeVisible();
await expect(retry).toHaveClass(/btn-primary/);

await expect(reload).toBeVisible();
await expect(reload).toHaveClass(/btn-outline-primary/);

// コンソールログ出力なし
expect(messages).toHaveLength(0);
});
});

test.describe("ボタン動作確認", () => {

test("Retryボタンが正しく動作すること。", async ({ page }) => {

const normalHeading = page.locator("h2");
const errorHeading = page.locator("h1");

// 想定外エラー発生
await page.getByRole("button", { name: "Convert" }).click();

await expect(normalHeading).toBeHidden();
await expect(errorHeading).toBeVisible();
await expect(errorHeading).toHaveText("Something went wrong.");

// Retryボタン押下
await page.getByRole("button", { name: "Retry" }).click();

await expect(normalHeading).toBeVisible();
await expect(normalHeading).toHaveText("IP Address Converter");
await expect(errorHeading).toBeHidden();
});

test("Reloadボタンが正しく動作すること。", async ({ page }) => {

const normalHeading = page.locator("h2");
const errorHeading = page.locator("h1");

// 想定外エラー発生
await page.getByRole("button", { name: "Convert" }).click();

await expect(normalHeading).toBeHidden();
await expect(errorHeading).toBeVisible();
await expect(errorHeading).toHaveText("Something went wrong.");

// Reloadボタン押下
await page.getByRole("button", { name: "Reload" }).click();

await expect(normalHeading).toBeVisible();
await expect(normalHeading).toHaveText("IP Address Converter");
await expect(errorHeading).toBeHidden();
});
});
});
10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,19 @@
"dev": "next dev",
"build": "next build",
"build:local": "npm run build && npm run remove-localdocs && npm run copy-docs-to-localdocs",
"build:error": "npm run build && npm run remove-errordocs && npm run copy-docs-to-errordocs",
"start": "npm run build:local && serve localdocs",
"start:error": "npm run mock-assertion && npm run build:error && npm run restore-backup && serve -l 3001 errordocs",
"remove-localdocs": "node -e \"require('fs').rmSync('./localdocs', { recursive: true, force: true })\"",
"remove-errordocs": "node -e \"require('fs').rmSync('./errordocs', { recursive: true, force: true })\"",
"copy-docs-to-localdocs": "node -e \"require('fs').cpSync('./docs', './localdocs/ip-address-converter', { recursive: true })\"",
"copy-docs-to-errordocs": "node -e \"require('fs').cpSync('./docs', './errordocs/ip-address-converter', { recursive: true })\"",
"copy-mock-to-assertion": "node -e \"require('fs').copyFileSync('./e2e/assertions.mock', './src/app/_lib/assertions.ts', constants.COPYFILE_EXCL)\"",
"remove-error-assertion": "node -e \"require('fs').rmSync('./src/app/_lib/assertions.ts', { force: true })\"",
"rename-to-bk": "node -e \"require('fs').renameSync('./src/app/_lib/assertions.ts', './src/app/_lib/assertions.ts.bk')\"",
"rename-to-ts": "node -e \"require('fs').renameSync('./src/app/_lib/assertions.ts.bk', './src/app/_lib/assertions.ts')\"",
"mock-assertion": "npm run rename-to-bk && npm run copy-mock-to-assertion",
"restore-backup": "npm run remove-error-assertion && npm run rename-to-ts",
"lint": "next lint",
"test": "jest --runInBand",
"e2e": "playwright test"
Expand Down
17 changes: 12 additions & 5 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,16 @@ export default defineConfig({
],

/* Run your local dev server before starting the tests */
webServer: {
command: 'npm run start',
url: 'http://127.0.0.1:3000',
reuseExistingServer: !process.env.CI,
},
webServer: [
{
command: 'npm run start',
url: 'http://127.0.0.1:3000',
reuseExistingServer: !process.env.CI,
},
{
command: 'npm run start:error',
url: 'http://127.0.0.1:3001',
reuseExistingServer: !process.env.CI,
}
],
});
86 changes: 86 additions & 0 deletions src/app/_components/error-boundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"use client";

import { Dispatch, SetStateAction, useState } from "react";
import { InvalidCallError } from "../_lib/errors";
import ErrorComponent from "../error";

/**
* イベントハンドラ内エラー境界コンポーネント
*
* 子コンポーネントのイベントハンドラ内で送出された例外を
* {@linkcode EventHandlerErrorPublisher}を介して検出し、再送出する。
*
* Reactコンポーネントから例外を再送出することにより
* イベントハンドラ内エラーを {@linkcode ErrorComponent Error}(`error.tsx`) 表示の対象とする。
*/
export function EventHandlerErrorBoundary({
children
}: Readonly<{
children: React.ReactNode;
}>): JSX.Element {

const [error, setError] = useState<Error | null>(null);

EventHandlerErrorBroker.setError = setError;
if (error != null) throw error;

return <>{children}</>;
}

/**
* イベントハンドラ内エラー仲介者
*
* エラー伝播用 stateセッタ関数 `setError` をモジュール内プライベートに保ち、
* {@linkcode EventHandlerErrorPublisher} と {@linkcode EventHandlerErrorBoundary} を仲介する。
*/
class EventHandlerErrorBroker {
/** エラー伝播用 stateセッタ関数 */
public static setError: Dispatch<SetStateAction<Error | null>> | null = null;
}

/**
* イベントハンドラ内エラー発行者
*/
export class EventHandlerErrorPublisher {

private constructor() { }

/**
* イベント発火時の動作をラップし例外処理を追加した関数を返却
*
* 指定された関数内でエラーが発生した場合、
* {@linkcode EventHandlerErrorBoundary} に通知し、
* {@linkcode ErrorComponent Error}(`error.tsx`) 表示対象とする例外処理が追加される。
* @param action イベント発火時の動作
* @returns イベント発火時の動作をラップし例外処理を追加した関数
*/
public static wrap(action: () => void): () => void {
return () => {
try {
action();
} catch (error) {
EventHandlerErrorPublisher.notify(error);
}
};
}

/**
* {@linkcode EventHandlerErrorBroker} を介して {@linkcode EventHandlerErrorBoundary} にエラーを通知
* @param error 通知対象エラー
* @throws :{@linkcode InvalidCallError} `EventHandlerErrorBroker.setError`が`null`(`EventHandlerErrorBoundary`未使用)の場合
*/
private static notify(error: unknown): void {

if (EventHandlerErrorBroker.setError == null) {
throw new InvalidCallError("'EventHandlerErrorBroker.setError' is null.",
"'EventHandlerErrorBoundary' might not wrap components.",
"'EventHandlerErrorPublisher' must be called in wrapped components.");
}

if (error instanceof Error) {
EventHandlerErrorBroker.setError(error);
} else {
EventHandlerErrorBroker.setError(new Error(String(error)));
}
}
}
19 changes: 14 additions & 5 deletions src/app/_components/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { Dispatch, SetStateAction, useRef, useState } from "react";
import { Char, IpAddress } from "../_lib/const";
import { Factory } from "../_lib/factory";
import { ResultDto } from "../_lib/result-dto";
import { EventHandlerUtils } from "../_lib/utils";
import { EventHandlerErrorPublisher } from "./error-boundary";

/**
* フォームコンポーネント
Expand All @@ -21,6 +23,7 @@ export function Form({
const formElementRef = useRef<HTMLFormElement>(null);
const inputIpv4Ref = useRef<HTMLInputElement>(null);
const inputCidrRef = useRef<HTMLInputElement>(null);
const buttonConvertRef = useRef<HTMLButtonElement>(null);

const [wasValidated, setWasValidated] = useState(false);
const [invalidFeedback, setInvalidFeedback] = useState(Char.EMPTY);
Expand All @@ -33,7 +36,6 @@ export function Form({
return (
<form ref={formElementRef}
className={"needs-validation" + (wasValidated ? Char.SPACE + "was-validated" : Char.EMPTY)}
onSubmit={formEvent => formEvent.preventDefault()}
noValidate>
<div className="my-3 p-3 border rounded">
<h4>Form</h4>
Expand All @@ -46,23 +48,30 @@ export function Form({
<div className="col-9 col-md-4 col-lg-3 px-md-0">
<div className="input-group has-validation">
<input onInput={() => view.updateDefaultCidrBasedOn(inputIpv4Ref.current?.value)}
onKeyDown={event => EventHandlerUtils.handleClickButtonByPressingEnter(event, buttonConvertRef.current)}
ref={inputIpv4Ref} id="ipv4" className="form-control w-50" type="text" placeholder="0.0.0.0" />
<span className="input-group-text">/</span>
<input ref={inputCidrRef} id="cidr" className="form-control" type="text" placeholder={defaultCidr} />
<input onKeyDown={event => EventHandlerUtils.handleClickButtonByPressingEnter(event, buttonConvertRef.current)}
ref={inputCidrRef} id="cidr" className="form-control" type="text" placeholder={defaultCidr} />
{invalidFeedback === Char.EMPTY ? <div className="valid-feedback">OK</div>
: <div className="invalid-feedback">{invalidFeedback}</div>}
</div>
</div>
<div className="col-auto pt-2 pt-md-0">
<div className="d-flex gap-3 gap-md-2">
<button className="btn btn-primary"
type="submit"
onClick={() => controller.convert(formElementRef.current, inputIpv4Ref.current, inputCidrRef.current, defaultCidr)}>
type="button"
ref={buttonConvertRef}
onClick={EventHandlerErrorPublisher.wrap(() => controller.convert(formElementRef.current,
inputIpv4Ref.current,
inputCidrRef.current,
defaultCidr))}>
Convert
</button>
<button className="btn btn-outline-primary"
type="button"
onClick={() => controller.clear(inputIpv4Ref.current, inputCidrRef.current)}>
onClick={EventHandlerErrorPublisher.wrap(() => controller.clear(inputIpv4Ref.current,
inputCidrRef.current))}>
Clear
</button>
</div>
Expand Down
8 changes: 5 additions & 3 deletions src/app/_lib/assertions.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { AssertionError } from "./errors";

/**
* アサーション
*/
Expand All @@ -8,14 +10,14 @@ export class Assertions {
/**
* 引数`param`が`null`または`undefined`でないことをアサート
* @param param アサート対象
* @throws `Error` 引数`param`が`null`または`undefined`の場合
* @throws :{@linkcode AssertionError} 引数`param`が`null`または`undefined`の場合
*/
public static assertNotNull<T>(param: T | null | undefined): asserts param is T {
if (param === null) {
throw new Error(param + " is null.");
throw new AssertionError("Parameter is null.");
}
if (param === undefined) {
throw new Error(param + " is undefined.");
throw new AssertionError("Parameter is undefined.");
}
}
}
Loading