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

Testing: Unit Testing

Unit Testing Overview

Unit testing in Rails FactoryBot Jump focuses on testing individual components in isolation, ensuring each piece of functionality works correctly without dependencies on external systems like the file system or VSCode APIs.

Test Structure and Organization

Test Suite Configuration

Mocha Configuration (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);
      }
    });
  });
}

Source: src/test/suite/index.ts

Main Test Suite

Test File Structure (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("Extension Test Suite", () => {
  vscode.window.showInformationMessage("Start all tests.");

  // Core functionality tests
  // Configuration tests
  // Error handling tests
  // Performance tests
});

Source: src/test/suite/extension.test.ts

Core Unit Tests

1. Factory Detection Tests

Pattern Recognition Testing:

suite("Factory Detection", () => {
  let provider: FactoryLinkProvider;

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

  test("detects basic factory calls", () => {
    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("detects factory calls without parentheses", () => {
    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("detects multiple factory methods", () => {
    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, `Failed for: ${testCase}`);
      assert.strictEqual(matches[0][1], "user", `Failed for: ${testCase}`);
    });
  });

  test("detects factory calls with traits", () => {
    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");

    // Test trait extraction
    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("ignores factory calls in comments", () => {
    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. Cache Management Tests

Cache Operations Testing:

suite("Cache Management", () => {
  let provider: FactoryLinkProvider;
  let mockWorkspace: sinon.SinonStub;
  let mockFindFiles: sinon.SinonStub;
  let mockReadFile: sinon.SinonStub;

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

    // Mock VSCode workspace API
    mockWorkspace = sinon.stub(vscode.workspace, "findFiles");
    mockReadFile = sinon.stub();
  });

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

  test("builds empty cache with no factory files", async () => {
    mockWorkspace.resolves([]);

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

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

  test("builds factory cache from file content", 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);

    // Mock file reading
    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("builds trait cache from factory content", 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("handles cache updates correctly", 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);

    // Initial cache build
    await provider.initializeFactoryFiles();
    let cache = provider.getFactoryCache();
    assert.ok(cache.has("user"));
    assert.ok(!cache.has("post"));

    // Update cache
    await provider.initializeFactoryFiles();
    cache = provider.getFactoryCache();
    assert.ok(cache.has("user"));
    assert.ok(cache.has("post"));
  });
});

3. Link Generation Tests

Document Link Creation Testing:

suite("Link Generation", () => {
  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("creates document links for factory calls", async () => {
    // Setup cache with factory definition
    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("creates links for factory calls with traits", 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);

    // Should create links for both factory and trait
    assert.strictEqual(links.length, 2);
  });

  test("returns empty array for unknown factories", 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("handles multiple factory calls in same document", 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. Configuration Tests

Settings Handling Testing:

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

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

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

  test("uses default factory paths when no configuration", () => {
    mockConfiguration.get.withArgs("factoryPaths").returns(undefined);

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

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

  test("uses custom factory paths from configuration", () => {
    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("handles invalid configuration gracefully", () => {
    mockConfiguration.get.withArgs("factoryPaths").returns("invalid-string");

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

    // Should fall back to default
    assert.deepStrictEqual(paths, ["spec/factories/**/*.rb"]);
  });
});

5. Error Handling Tests

Robust Error Handling Testing:

suite("Error Handling", () => {
  let provider: FactoryLinkProvider;

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

  test("handles file reading errors gracefully", 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("File not found"));

    // Should not throw
    await assert.doesNotReject(async () => {
      await provider.initializeFactoryFiles();
    });

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

    sinon.restore();
  });

  test("handles malformed factory files", async () => {
    const malformedContent = `
      This is not valid Ruby syntax
      factory :user
      end without begin
    `;

    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);

    // Should not throw and should continue processing
    await assert.doesNotReject(async () => {
      await provider.initializeFactoryFiles();
    });

    sinon.restore();
  });

  test("handles empty factory files", 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();
  });
});

Test Utilities and Helpers

Mock Factory Content Generator

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";
  }
}

// Usage in tests
const factoryContent = new MockFactoryBuilder()
  .addFactory("user", ["admin", "verified"])
  .addFactory("post", ["published"])
  .build();

Test Document Builder

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)),
    };
  }
}

// Usage in tests
const testDocument = new MockDocumentBuilder()
  .addFactoryCall("create", "user", ["admin"])
  .addFactoryCall("build", "post")
  .build();

Performance Testing

Benchmark Tests

suite("Performance", () => {
  test("cache initialization performance", async () => {
    const provider = new FactoryLinkProvider();

    // Create mock factory files
    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,
      `Cache initialization took ${duration}ms, expected < 5000ms`
    );

    sinon.restore();
  });

  test("link generation performance", () => {
    const provider = new FactoryLinkProvider();

    // Large test document
    const largeDocument = new MockDocumentBuilder();
    for (let i = 0; i < 1000; i++) {
      largeDocument.addFactoryCall("create", "user");
    }
    const document = largeDocument.build();

    // Setup cache
    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,
      `Link generation took ${duration}ms, expected < 1000ms`
    );
  });
});

Running Unit Tests

Test Execution Commands

# Run all tests
npm test

# Run tests in watch mode (if configured)
npm run test:watch

# Run with coverage (if configured)
npm run test:coverage

# Run only unit tests (excluding integration)
npm run test:unit

Test Output Analysis

Successful Test Run:

Extension Test Suite
  Factory Detection
    ✓ detects basic factory calls (2ms)
    ✓ detects factory calls without parentheses (1ms)
    ✓ detects multiple factory methods (5ms)
    ✓ detects factory calls with traits (3ms)
    ✓ ignores factory calls in comments (1ms)

  Cache Management
    ✓ builds empty cache with no factory files (15ms)
    ✓ builds factory cache from file content (25ms)
    ✓ builds trait cache from factory content (20ms)
    ✓ handles cache updates correctly (30ms)

  27 passing (145ms)

This comprehensive unit testing approach ensures each component of the Rails FactoryBot Jump extension works correctly in isolation, providing confidence in the overall system reliability and maintainability.

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