JA Testing Unit Testing - hiraishikentaro/rails-factorybot-jump GitHub Wiki

ユニットテスト

ユニットテスト概要

Rails FactoryBot Jump のユニットテストは、個々のコンポーネントを分離してテストし、ファイルシステムや VSCode API などの外部システムに依存することなく、各機能が正しく動作することを確認することに焦点を当てています。

テスト構造と組織

ユニットテスト専用構造

ユニットテストディレクトリ (src/test/unit/):

  • models/ - データモデルのテスト
  • services/ - ビジネスサービスのテスト
  • utils/ - ユーティリティ関数のテスト
  • unitTestsRunner.ts - 専用ユニットテストランナー

統合テストとの分離

統合テスト (src/test/suite/):

  • VSCode Extension Development Host で実行
  • 実際の VSCode API との統合をテスト

ユニットテスト (src/test/unit/):

  • 軽量で高速な実行
  • モックを使用した分離されたテスト

テストスイート設定

Mocha 設定src/test/suite/index.ts):

import * as path from "path";
import * as Mocha from "mocha";
import * as glob from "glob";

export function run(): Promise<void> {
  const mocha = new Mocha({
    ui: "tdd",
    color: true,
    timeout: 10000,
  });

  const testsRoot = path.resolve(__dirname, "..");

  return new Promise((c, e) => {
    glob("**/**.test.js", { cwd: testsRoot }, (err, files) => {
      if (err) return e(err);

      files.forEach((f) => mocha.addFile(path.resolve(testsRoot, f)));

      try {
        mocha.run((failures) => {
          if (failures > 0) {
            e(new Error(`${failures} tests failed.`));
          } else {
            c();
          }
        });
      } catch (err) {
        e(err);
      }
    });
  });
}

ソース: src/test/suite/index.ts

メインテストスイート

テストファイル構造src/test/suite/extension.test.ts):

import * as assert from "assert";
import * as vscode from "vscode";
import * as sinon from "sinon";
import { FactoryLinkProvider } from "../../providers/factoryLinkProvider";

suite("拡張機能テストスイート", () => {
  vscode.window.showInformationMessage("すべてのテストを開始します。");

  // コア機能テスト
  // 設定テスト
  // エラーハンドリングテスト
  // パフォーマンステスト
});

ソース: src/test/suite/extension.test.ts

コアユニットテスト

1. ファクトリ検出テスト

パターン認識テスト:

suite("ファクトリ検出", () => {
  let provider: FactoryLinkProvider;

  setup(() => {
    provider = new FactoryLinkProvider();
  });

  test("基本的なファクトリコールを検出する", () => {
    const text = "user = create(:user)";
    const regex = /(?:create|build)\s*\(\s*:([a-zA-Z0-9_]+)/g;
    const matches = Array.from(text.matchAll(regex));

    assert.strictEqual(matches.length, 1);
    assert.strictEqual(matches[0][1], "user");
  });

  test("括弧なしのファクトリコールを検出する", () => {
    const text = "user = create :user";
    const regex = /(?:create|build)\s+:([a-zA-Z0-9_]+)/g;
    const matches = Array.from(text.matchAll(regex));

    assert.strictEqual(matches.length, 1);
    assert.strictEqual(matches[0][1], "user");
  });

  test("複数のファクトリメソッドを検出する", () => {
    const testCases = [
      "create(:user)",
      "build(:user)",
      "create_list(:user, 5)",
      "build_list(:user, 3)",
      "build_stubbed(:user)",
      "build_stubbed_list(:user, 2)",
    ];

    const regex =
      /(?:create|create_list|build|build_list|build_stubbed|build_stubbed_list)\s*(?:\(\s*)?:([a-zA-Z0-9_]+)/g;

    testCases.forEach((testCase) => {
      const matches = Array.from(testCase.matchAll(regex));
      assert.strictEqual(matches.length, 1, `失敗: ${testCase}`);
      assert.strictEqual(matches[0][1], "user", `失敗: ${testCase}`);
    });
  });

  test("トレイト付きファクトリコールを検出する", () => {
    const text = "admin = create(:user, :admin, :verified)";
    const regex =
      /(?:create|build)\s*\(\s*:([a-zA-Z0-9_]+)(?:\s*,\s*:([a-zA-Z0-9_]+))*/g;
    const match = regex.exec(text);

    assert.ok(match);
    assert.strictEqual(match[1], "user");

    // トレイト抽出をテスト
    const traitRegex = /:([a-zA-Z0-9_]+)/g;
    const traits = Array.from(text.matchAll(traitRegex)).map((m) => m[1]);
    assert.deepStrictEqual(traits, ["user", "admin", "verified"]);
  });

  test("コメント内のファクトリコールを無視する", () => {
    const text = "# user = create(:user)\nreal_user = create(:user)";
    const lines = text.split("\n");
    const nonCommentLines = lines.filter(
      (line) => !line.trim().startsWith("#")
    );

    assert.strictEqual(nonCommentLines.length, 1);
    assert.ok(nonCommentLines[0].includes("real_user"));
  });
});

2. キャッシュ管理テスト

キャッシュ操作テスト:

suite("キャッシュ管理", () => {
  let provider: FactoryLinkProvider;
  let mockWorkspace: sinon.SinonStub;
  let mockFindFiles: sinon.SinonStub;
  let mockReadFile: sinon.SinonStub;

  setup(() => {
    provider = new FactoryLinkProvider();

    // VSCodeワークスペースAPIをモック
    mockWorkspace = sinon.stub(vscode.workspace, "findFiles");
    mockReadFile = sinon.stub();
  });

  teardown(() => {
    sinon.restore();
  });

  test("ファクトリファイルなしで空のキャッシュを構築する", async () => {
    mockWorkspace.resolves([]);

    await provider.initializeFactoryFiles();
    const cache = provider.getFactoryCache();

    assert.strictEqual(cache.size, 0);
  });

  test("ファイル内容からファクトリキャッシュを構築する", async () => {
    const factoryFileUri = vscode.Uri.file("/spec/factories/users.rb");
    const factoryContent = `
FactoryBot.define do
  factory :user do
    name { "Test User" }
  end
  
  factory :admin_user do
    name { "Admin User" }
  end
end`;

    mockWorkspace.resolves([factoryFileUri]);
    mockReadFile.withArgs(factoryFileUri).resolves(factoryContent);

    // ファイル読み込みをモック
    sinon.stub(vscode.workspace, "openTextDocument").resolves({
      getText: () => factoryContent,
    } as any);

    await provider.initializeFactoryFiles();
    const cache = provider.getFactoryCache();

    assert.ok(cache.has("user"));
    assert.ok(cache.has("admin_user"));
    assert.strictEqual(cache.get("user")?.uri.path, "/spec/factories/users.rb");
  });

  test("ファクトリ内容からトレイトキャッシュを構築する", async () => {
    const factoryContent = `
FactoryBot.define do
  factory :user do
    name { "Test User" }

    trait :admin do
      admin { true }
    end

    trait :verified do
      verified { true }
    end
  end
end`;

    const factoryFileUri = vscode.Uri.file("/spec/factories/users.rb");
    mockWorkspace.resolves([factoryFileUri]);

    sinon.stub(vscode.workspace, "openTextDocument").resolves({
      getText: () => factoryContent,
    } as any);

    await provider.initializeFactoryFiles();
    const traitCache = provider.getTraitCache();

    assert.ok(traitCache.has("user:admin"));
    assert.ok(traitCache.has("user:verified"));
    assert.strictEqual(traitCache.get("user:admin")?.factory, "user");
  });

  test("キャッシュ更新を正しく処理する", async () => {
    const initialContent = `
FactoryBot.define do
  factory :user do
    name { "Test User" }
  end
end`;

    const updatedContent = `
FactoryBot.define do
  factory :user do
    name { "Test User" }
  end

  factory :post do
    title { "Test Post" }
  end
end`;

    const factoryFileUri = vscode.Uri.file("/spec/factories/users.rb");
    mockWorkspace.resolves([factoryFileUri]);

    const mockDocument = sinon.stub();
    mockDocument.onFirstCall().resolves({ getText: () => initialContent });
    mockDocument.onSecondCall().resolves({ getText: () => updatedContent });

    sinon.stub(vscode.workspace, "openTextDocument").callsFake(mockDocument);

    // 初期キャッシュ構築
    await provider.initializeFactoryFiles();
    let cache = provider.getFactoryCache();
    assert.ok(cache.has("user"));
    assert.ok(!cache.has("post"));

    // キャッシュ更新
    await provider.initializeFactoryFiles();
    cache = provider.getFactoryCache();
    assert.ok(cache.has("user"));
    assert.ok(cache.has("post"));
  });
});

3. リンク生成テスト

ドキュメントリンク作成テスト:

suite("リンク生成", () => {
  let provider: FactoryLinkProvider;
  let mockDocument: any;

  setup(() => {
    provider = new FactoryLinkProvider();

    mockDocument = {
      uri: vscode.Uri.file("/spec/models/user_spec.rb"),
      getText: () => "user = create(:user)",
      lineAt: sinon.stub(),
      positionAt: sinon.stub().returns(new vscode.Position(0, 0)),
    };
  });

  test("ファクトリコール用のドキュメントリンクを作成する", async () => {
    // ファクトリ定義でキャッシュを設定
    const factoryUri = vscode.Uri.file("/spec/factories/users.rb");
    provider.setFactoryCache(
      new Map([["user", { uri: factoryUri, lineNumber: 1 }]])
    );

    const links = provider.provideDocumentLinks(mockDocument);

    assert.strictEqual(links.length, 1);
    assert.ok(links[0].target);
    assert.ok(links[0].target.toString().includes("gotoLine"));
  });

  test("トレイト付きファクトリコール用のリンクを作成する", async () => {
    mockDocument.getText = () => "admin = create(:user, :admin)";

    provider.setFactoryCache(
      new Map([
        [
          "user",
          { uri: vscode.Uri.file("/spec/factories/users.rb"), lineNumber: 1 },
        ],
      ])
    );

    provider.setTraitCache(
      new Map([
        [
          "user:admin",
          {
            uri: vscode.Uri.file("/spec/factories/users.rb"),
            lineNumber: 5,
            factory: "user",
          },
        ],
      ])
    );

    const links = provider.provideDocumentLinks(mockDocument);

    // ファクトリとトレイト両方のリンクを作成すべき
    assert.strictEqual(links.length, 2);
  });

  test("不明なファクトリに対して空の配列を返す", async () => {
    mockDocument.getText = () => "user = create(:unknown_factory)";

    provider.setFactoryCache(
      new Map([
        [
          "user",
          { uri: vscode.Uri.file("/spec/factories/users.rb"), lineNumber: 1 },
        ],
      ])
    );

    const links = provider.provideDocumentLinks(mockDocument);

    assert.strictEqual(links.length, 0);
  });

  test("同じドキュメント内の複数ファクトリコールを処理する", async () => {
    mockDocument.getText = () => `
      user = create(:user)
      post = create(:post)
      comment = build(:comment)
    `;

    provider.setFactoryCache(
      new Map([
        [
          "user",
          { uri: vscode.Uri.file("/spec/factories/users.rb"), lineNumber: 1 },
        ],
        [
          "post",
          { uri: vscode.Uri.file("/spec/factories/posts.rb"), lineNumber: 1 },
        ],
        [
          "comment",
          {
            uri: vscode.Uri.file("/spec/factories/comments.rb"),
            lineNumber: 1,
          },
        ],
      ])
    );

    const links = provider.provideDocumentLinks(mockDocument);

    assert.strictEqual(links.length, 3);
  });
});

4. 設定テスト

設定処理テスト:

suite("設定", () => {
  let mockConfiguration: any;

  setup(() => {
    mockConfiguration = {
      get: sinon.stub(),
    };
    sinon.stub(vscode.workspace, "getConfiguration").returns(mockConfiguration);
  });

  teardown(() => {
    sinon.restore();
  });

  test("設定がない場合にデフォルトファクトリパスを使用する", () => {
    mockConfiguration.get.withArgs("factoryPaths").returns(undefined);

    const provider = new FactoryLinkProvider();
    const paths = provider.getFactoryPaths();

    assert.deepStrictEqual(paths, ["spec/factories/**/*.rb"]);
  });

  test("設定からカスタムファクトリパスを使用する", () => {
    const customPaths = ["test/factories/**/*.rb", "lib/factories/**/*.rb"];
    mockConfiguration.get.withArgs("factoryPaths").returns(customPaths);

    const provider = new FactoryLinkProvider();
    const paths = provider.getFactoryPaths();

    assert.deepStrictEqual(paths, customPaths);
  });

  test("無効な設定を優雅に処理する", () => {
    mockConfiguration.get.withArgs("factoryPaths").returns("invalid-string");

    const provider = new FactoryLinkProvider();
    const paths = provider.getFactoryPaths();

    // デフォルトにフォールバックすべき
    assert.deepStrictEqual(paths, ["spec/factories/**/*.rb"]);
  });
});

5. エラーハンドリングテスト

堅牢なエラーハンドリングテスト:

suite("エラーハンドリング", () => {
  let provider: FactoryLinkProvider;

  setup(() => {
    provider = new FactoryLinkProvider();
  });

  test("ファイル読み込みエラーを優雅に処理する", async () => {
    const mockWorkspace = sinon.stub(vscode.workspace, "findFiles");
    mockWorkspace.resolves([vscode.Uri.file("/spec/factories/users.rb")]);

    const mockOpenDocument = sinon.stub(vscode.workspace, "openTextDocument");
    mockOpenDocument.rejects(new Error("ファイルが見つかりません"));

    // 例外を投げるべきではない
    await assert.doesNotReject(async () => {
      await provider.initializeFactoryFiles();
    });

    const cache = provider.getFactoryCache();
    assert.strictEqual(cache.size, 0);

    sinon.restore();
  });

  test("不正なファクトリファイルを処理する", async () => {
    const malformedContent = `
      これは有効なRuby構文ではありません
      factory :user
      beginなしのend
    `;

    const factoryFileUri = vscode.Uri.file("/spec/factories/users.rb");
    sinon.stub(vscode.workspace, "findFiles").resolves([factoryFileUri]);
    sinon.stub(vscode.workspace, "openTextDocument").resolves({
      getText: () => malformedContent,
    } as any);

    // 例外を投げず処理を継続すべき
    await assert.doesNotReject(async () => {
      await provider.initializeFactoryFiles();
    });

    sinon.restore();
  });

  test("空のファクトリファイルを処理する", async () => {
    const emptyContent = "";

    const factoryFileUri = vscode.Uri.file("/spec/factories/users.rb");
    sinon.stub(vscode.workspace, "findFiles").resolves([factoryFileUri]);
    sinon.stub(vscode.workspace, "openTextDocument").resolves({
      getText: () => emptyContent,
    } as any);

    await provider.initializeFactoryFiles();
    const cache = provider.getFactoryCache();

    assert.strictEqual(cache.size, 0);

    sinon.restore();
  });
});

テストユーティリティとヘルパー

モックファクトリ内容ジェネレーター

class MockFactoryBuilder {
  private content = "FactoryBot.define do\n";

  addFactory(name: string, traits: string[] = []): this {
    this.content += `  factory :${name} do\n`;
    this.content += `    name { "Test ${name}" }\n`;

    traits.forEach((trait) => {
      this.content += `    trait :${trait} do\n`;
      this.content += `      ${trait} { true }\n`;
      this.content += `    end\n`;
    });

    this.content += `  end\n\n`;
    return this;
  }

  build(): string {
    return this.content + "end";
  }
}

// テストでの使用方法
const factoryContent = new MockFactoryBuilder()
  .addFactory("user", ["admin", "verified"])
  .addFactory("post", ["published"])
  .build();

テストドキュメントビルダー

class MockDocumentBuilder {
  private lines: string[] = [];

  addLine(content: string): this {
    this.lines.push(content);
    return this;
  }

  addFactoryCall(method: string, factory: string, traits: string[] = []): this {
    const traitString =
      traits.length > 0 ? `, ${traits.map((t) => `:${t}`).join(", ")}` : "";
    this.addLine(`${factory}_instance = ${method}(:${factory}${traitString})`);
    return this;
  }

  build(): any {
    const content = this.lines.join("\n");
    return {
      uri: vscode.Uri.file("/test/spec.rb"),
      getText: () => content,
      lineAt: sinon.stub(),
      positionAt: sinon.stub().returns(new vscode.Position(0, 0)),
    };
  }
}

// テストでの使用方法
const testDocument = new MockDocumentBuilder()
  .addFactoryCall("create", "user", ["admin"])
  .addFactoryCall("build", "post")
  .build();

パフォーマンステスト

ベンチマークテスト

suite("パフォーマンス", () => {
  test("キャッシュ初期化パフォーマンス", async () => {
    const provider = new FactoryLinkProvider();

    // モックファクトリファイルを作成
    const factoryFiles = Array.from({ length: 100 }, (_, i) =>
      vscode.Uri.file(`/spec/factories/factory_${i}.rb`)
    );

    const factoryContent = new MockFactoryBuilder()
      .addFactory("user", ["admin"])
      .addFactory("post", ["published"])
      .build();

    sinon.stub(vscode.workspace, "findFiles").resolves(factoryFiles);
    sinon.stub(vscode.workspace, "openTextDocument").resolves({
      getText: () => factoryContent,
    } as any);

    const startTime = Date.now();
    await provider.initializeFactoryFiles();
    const duration = Date.now() - startTime;

    assert.ok(
      duration < 5000,
      `キャッシュ初期化に${duration}ms かかりました、期待値は5000ms未満`
    );

    sinon.restore();
  });

  test("リンク生成パフォーマンス", () => {
    const provider = new FactoryLinkProvider();

    // 大きなテストドキュメント
    const largeDocument = new MockDocumentBuilder();
    for (let i = 0; i < 1000; i++) {
      largeDocument.addFactoryCall("create", "user");
    }
    const document = largeDocument.build();

    // キャッシュを設定
    provider.setFactoryCache(
      new Map([
        [
          "user",
          { uri: vscode.Uri.file("/spec/factories/users.rb"), lineNumber: 1 },
        ],
      ])
    );

    const startTime = Date.now();
    const links = provider.provideDocumentLinks(document);
    const duration = Date.now() - startTime;

    assert.strictEqual(links.length, 1000);
    assert.ok(
      duration < 1000,
      `リンク生成に${duration}ms かかりました、期待値は1000ms未満`
    );
  });
});

ユニットテスト実行

テスト実行コマンド

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

# ウォッチモードでテストを実行(設定されている場合)
npm run test:watch

# カバレッジ付きで実行(設定されている場合)
npm run test:coverage

# ユニットテストのみ実行(統合テストを除く)
npm run test:unit

テスト出力解析

成功したテスト実行:

拡張機能テストスイート
  ファクトリ検出
    ✓ 基本的なファクトリコールを検出する (2ms)
    ✓ 括弧なしのファクトリコールを検出する (1ms)
    ✓ 複数のファクトリメソッドを検出する (5ms)
    ✓ トレイト付きファクトリコールを検出する (3ms)
    ✓ コメント内のファクトリコールを無視する (1ms)

  キャッシュ管理
    ✓ ファクトリファイルなしで空のキャッシュを構築する (15ms)
    ✓ ファイル内容からファクトリキャッシュを構築する (25ms)
    ✓ ファクトリ内容からトレイトキャッシュを構築する (20ms)
    ✓ キャッシュ更新を正しく処理する (30ms)

  27 passing (145ms)

この包括的なユニットテストアプローチにより、Rails FactoryBot Jump 拡張機能の各コンポーネントが分離して正しく動作することを保証し、全体的なシステムの信頼性と保守性に自信を提供します。

⚠️ **GitHub.com Fallback** ⚠️