JA Testing Unit Testing - aku11i/phantom GitHub Wiki

ユニットテスト

English | 日本語

このガイドでは、Node.jsの組み込みテストランナーを使用したPhantomのユニットテストの書き方と実行方法について説明します。

テストフレームワーク

Node.jsテストランナー

PhantomはNode.jsのネイティブテストランナー(Node.js v22+で利用可能)を使用します:

import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert";

主な機能:

  • Node.jsに組み込み(依存関係なし)
  • TypeScriptサポート
  • ネイティブモジュールモッキング
  • Async/awaitサポート
  • 並列実行

ユニットテストの書き方

基本的なテスト構造

import { describe, it } from "node:test";
import assert from "node:assert";
import { validateWorktreeName } from "./validate.js";

describe("validateWorktreeName", () => {
  it("should accept valid names", () => {
    const result = validateWorktreeName("feature-123");
    assert.strictEqual(result.ok, true);
  });

  it("should reject empty names", () => {
    const result = validateWorktreeName("");
    assert.strictEqual(result.ok, false);
  });
});

非同期関数のテスト

import { describe, it } from "node:test";
import assert from "node:assert";

describe("async operations", () => {
  it("should handle async operations", async () => {
    const result = await asyncOperation();
    assert.strictEqual(result.ok, true);
  });

  it("should handle async errors", async () => {
    const result = await failingAsyncOperation();
    assert.strictEqual(result.ok, false);
    if (!result.ok) {
      assert.match(result.error.message, /expected error/);
    }
  });
});

フックの使用

describe("WorktreeManager", () => {
  let manager: WorktreeManager;
  let mockGit: MockGitExecutor;

  beforeEach(() => {
    mockGit = createMockGitExecutor();
    manager = new WorktreeManager(mockGit);
  });

  afterEach(() => {
    mock.reset();
  });

  it("should initialize correctly", () => {
    assert(manager instanceof WorktreeManager);
  });
});

モジュールモッキング

基本的なモジュールモック

ソース: src/core/worktree/create.test.ts

import { mock } from "node:test";

// fsモジュールをモック
mock.module("node:fs/promises", {
  namedExports: {
    access: mock.fn(() => Promise.reject(new Error("ENOENT"))),
    mkdir: mock.fn(() => Promise.resolve()),
    readFile: mock.fn(() => Promise.resolve("content"))
  }
});

// カスタムモジュールをモック
mock.module("../git/executor.js", {
  namedExports: {
    GitExecutor: mock.fn(() => ({
      execute: mock.fn(() => Result.ok("success"))
    }))
  }
});

高度なモッキングパターン

動的モックレスポンス

const mockGitExecute = mock.fn((args: string[]) => {
  const command = args.join(" ");
  
  switch (command) {
    case "worktree list --porcelain":
      return Result.ok("worktree /main\nHEAD abc123");
    case "rev-parse --show-toplevel":
      return Result.ok("/path/to/repo");
    case "branch --show-current":
      return Result.ok("main");
    default:
      return Result.error(new Error(`Unknown command: ${command}`));
  }
});

mock.module("../git/executor.js", {
  namedExports: {
    GitExecutor: mock.fn(() => ({
      execute: mockGitExecute
    }))
  }
});

状態を持つモッキング

describe("stateful operations", () => {
  let callCount = 0;
  
  const mockWithState = mock.fn(() => {
    callCount++;
    if (callCount === 1) {
      return Result.error(new Error("First call fails"));
    }
    return Result.ok("Success on retry");
  });

  beforeEach(() => {
    callCount = 0;
  });
});

テストパターン

Result型のテスト

ソース: src/core/types/result.ts

describe("Result type operations", () => {
  it("should handle ok results", () => {
    const result = functionReturningResult();
    
    assert.strictEqual(result.ok, true);
    if (result.ok) {
      assert.strictEqual(result.value, expectedValue);
    }
  });

  it("should handle error results", () => {
    const result = functionThatFails();
    
    assert.strictEqual(result.ok, false);
    if (!result.ok) {
      assert(result.error instanceof Error);
      assert.match(result.error.message, /expected pattern/);
    }
  });
});

エラーシナリオのテスト

describe("error handling", () => {
  it("should handle validation errors", async () => {
    const result = await createWorktree("../invalid-name");
    
    assert.strictEqual(result.ok, false);
    if (!result.ok) {
      assert.match(result.error.message, /invalid.*name/i);
    }
  });

  it("should handle git command failures", async () => {
    mockGit.execute.mockReturnValue(
      Result.error(new Error("fatal: worktree already exists"))
    );
    
    const result = await createWorktree("duplicate");
    assert.strictEqual(result.ok, false);
  });

  it("should handle file system errors", async () => {
    mockFs.mkdir.mockRejectedValue(
      new Error("EACCES: permission denied")
    );
    
    const result = await createWorktree("no-permission");
    assert.strictEqual(result.ok, false);
  });
});

コマンドハンドラーのテスト

ハンドラーテストの例:

describe("CreateHandler", () => {
  let handler: CreateHandler;
  let mockOutput: MockOutput;
  
  beforeEach(() => {
    mockOutput = createMockOutput();
    handler = new CreateHandler(mockOutput);
  });

  it("should create worktree and display success", async () => {
    await handler.execute({ name: "feature", branch: "main" });
    
    // コア操作が呼ばれたことを確認
    assert.strictEqual(mockCore.createWorktree.mock.calls.length, 1);
    
    // 出力がフォーマットされたことを確認
    assert.strictEqual(mockOutput.success.mock.calls.length, 1);
    assert.match(
      mockOutput.success.mock.calls[0].arguments[0],
      /Created phantom 'feature'/
    );
  });

  it("should display error on failure", async () => {
    mockCore.createWorktree.mockReturnValue(
      Result.error(new Error("Creation failed"))
    );
    
    await handler.execute({ name: "feature" });
    
    assert.strictEqual(mockOutput.error.mock.calls.length, 1);
    assert.strictEqual(process.exitCode, 1);
  });
});

テストの編成

Describeブロック

階層的にテストを編成:

describe("Git Module", () => {
  describe("GitExecutor", () => {
    describe("execute method", () => {
      it("should execute git commands", () => {
        // テスト
      });
      
      it("should handle command errors", () => {
        // テスト
      });
    });
    
    describe("executeJson method", () => {
      it("should parse JSON output", () => {
        // テスト
      });
    });
  });
});

テストの命名

説明的なテスト名を使用:

// 良いテスト名
it("should create worktree with specified branch")
it("should return error when worktree name contains path separator")
it("should cleanup resources when creation fails")

// 悪いテスト名
it("should work")
it("test create")
it("error case")

アサーション

一般的なアサーション

// 等価性アサーション
assert.strictEqual(actual, expected);
assert.deepStrictEqual(actualObject, expectedObject);

// ブール値アサーション
assert(condition);
assert.ok(value);

// エラーアサーション
assert.throws(() => {
  throwingFunction();
}, /expected error message/);

// 非同期エラーアサーション
await assert.rejects(
  async () => await failingAsyncFunction(),
  { message: /expected error/ }
);

// パターンマッチング
assert.match(string, /pattern/);
assert.doesNotMatch(string, /pattern/);

カスタムアサーション

// 型アサーションヘルパー
function assertOk<T>(
  result: Result<T>
): asserts result is { ok: true; value: T } {
  assert.strictEqual(result.ok, true, "Expected successful result");
}

// 使用方法
const result = await operation();
assertOk(result);
// TypeScriptはresult.valueが利用可能であることを知っている
console.log(result.value);

モック検証

モック呼び出しの検証

it("should call git with correct arguments", async () => {
  await createWorktree("feature", "develop");
  
  // 呼び出し回数を検証
  assert.strictEqual(mockGit.execute.mock.calls.length, 2);
  
  // 最初の呼び出しの引数を検証
  assert.deepStrictEqual(
    mockGit.execute.mock.calls[0].arguments,
    ["rev-parse", "--show-toplevel"](/aku11i/phantom/wiki/"rev-parse",-"--show-toplevel")
  );
  
  // 2番目の呼び出しの引数を検証
  assert.deepStrictEqual(
    mockGit.execute.mock.calls[1].arguments,
    ["worktree", "add", "--detach", "/tmp/phantom/repo/feature", "develop"](/aku11i/phantom/wiki/"worktree",-"add",-"--detach",-"/tmp/phantom/repo/feature",-"develop")
  );
});

モック状態の検証

it("should not create directory if validation fails", async () => {
  const result = await createWorktree("invalid/name");
  
  // ファイルシステム操作に到達してはならない
  assert.strictEqual(mockFs.mkdir.mock.calls.length, 0);
  assert.strictEqual(mockGit.execute.mock.calls.length, 0);
});

テストデータとフィクスチャ

テストフィクスチャの作成

const testFixtures = {
  worktrees: {
    main: {
      name: "main",
      path: "/repo",
      branch: "main",
      isMain: true
    },
    feature: {
      name: "feature",
      path: "/tmp/phantom/repo/feature",
      branch: "feature/new-ui",
      isMain: false
    }
  },
  
  gitOutput: {
    worktreeList: `worktree /repo
HEAD abc123
branch refs/heads/main

worktree /tmp/phantom/repo/feature
HEAD def456
branch refs/heads/feature/new-ui`
  },
  
  invalidNames: ["", ".", "..", "../escape", "with/slash", "with space"]
};

テストビルダーの使用

class WorktreeBuilder {
  private worktree: Partial<WorktreeInfo> = {};
  
  withName(name: string): this {
    this.worktree.name = name;
    return this;
  }
  
  withBranch(branch: string): this {
    this.worktree.branch = branch;
    return this;
  }
  
  build(): WorktreeInfo {
    return {
      name: this.worktree.name || "test",
      branch: this.worktree.branch || "main",
      path: `/tmp/phantom/repo/${this.worktree.name || "test"}`,
      isMain: false
    };
  }
}

// テストでの使用
const worktree = new WorktreeBuilder()
  .withName("feature")
  .withBranch("develop")
  .build();

テストの実行

コマンドライン

# すべてのテストを実行
pnpm test

# 特定のテストファイルを実行
node --test src/core/worktree/create.test.ts

# パターンに一致するテストを実行
node --test --test-name-pattern="validation"

# ウォッチモードで実行
node --test --watch

# カバレッジ付きで実行
node --test --experimental-test-coverage

IDE統合

VS Code

.vscode/launch.jsonに追加:

{
  "type": "node",
  "request": "launch",
  "name": "Run Current Test File",
  "program": "${file}",
  "args": ["--test"],
  "console": "integratedTerminal",
  "skipFiles": ["<node_internals>/**"]
}

ベストプラクティス

1. テストの独立性

各テストは独立している必要があります:

// 良い: 独立したテスト
it("should create worktree", async () => {
  const result = await createWorktree("feature");
  assert.strictEqual(result.ok, true);
});

// 悪い: 前のテストに依存
it("should list created worktree", async () => {
  // 前のテストが"feature"を作成したと仮定
  const result = await listWorktrees();
  assert(result.value.some(w => w.name === "feature"));
});

2. 明確なテスト構造

Arrange-Act-Assertパターンに従う:

it("should handle missing branch", async () => {
  // Arrange
  mockGit.execute.mockImplementation((args) => {
    if (args.includes("rev-parse")) {
      return Result.error(new Error("unknown revision"));
    }
    return Result.ok("");
  });
  
  // Act
  const result = await createWorktree("feature", "non-existent");
  
  // Assert
  assert.strictEqual(result.ok, false);
  assert.match(result.error.message, /branch.*not.*found/i);
});

3. 意味のあるテストデータ

現実的なテストデータを使用:

// 良い: 現実的なデータ
const testBranch = "feature/user-authentication";
const testPath = "/home/user/projects/myapp";

// 悪い: 意味のないデータ
const testBranch = "foo";
const testPath = "/test";

4. エラーメッセージのテスト

デバッグのためにエラーメッセージをテスト:

it("should provide helpful error message", async () => {
  const result = await createWorktree("my/feature");
  
  assert.strictEqual(result.ok, false);
  if (!result.ok) {
    // エラーメッセージがユーザーに役立つことを確認
    assert.match(result.error.message, /cannot contain/);
    assert.match(result.error.message, /slash/);
  }
});

まとめ

Phantomのユニットテスト:

  1. ネイティブツール - Node.js組み込みテストランナーを使用
  2. すべてをモック - テスト対象を分離
  3. 振る舞いをテスト - 実装ではなく結果に焦点を当てる
  4. 明確な構造 - 整理された読みやすいテスト
  5. 高速実行 - テストあたりミリ秒

これらのパターンに従うことで、コードの正確性に対する信頼を与える、信頼性が高く保守可能なテストが保証されます。