Testing Unit Testing - aku11i/phantom GitHub Wiki

Unit Testing

This guide covers writing and running unit tests for Phantom using Node.js's built-in test runner.

Test Framework

Node.js Test Runner

Phantom uses Node.js's native test runner (available in Node.js v22+):

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

Key features:

  • Built into Node.js (no dependencies)
  • TypeScript support
  • Native module mocking
  • Async/await support
  • Parallel execution

Writing Unit Tests

Basic Test Structure

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

Testing Async Functions

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

Using Hooks

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

Module Mocking

Basic Module Mock

Source: src/core/worktree/create.test.ts

import { mock } from "node:test";

// Mock the fs module
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 a custom module
mock.module("../git/executor.js", {
  namedExports: {
    GitExecutor: mock.fn(() => ({
      execute: mock.fn(() => Result.ok("success"))
    }))
  }
});

Advanced Mocking Patterns

Dynamic Mock Responses

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

Mocking with State

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

Testing Patterns

Testing Result Types

Source: 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/);
    }
  });
});

Testing Error Scenarios

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

Testing Command Handlers

Example from a handler test:

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" });
    
    // Verify core operation was called
    assert.strictEqual(mockCore.createWorktree.mock.calls.length, 1);
    
    // Verify output was formatted
    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);
  });
});

Test Organization

Describe Blocks

Organize tests hierarchically:

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

Test Naming

Use descriptive test names:

// Good test names
it("should create worktree with specified branch")
it("should return error when worktree name contains path separator")
it("should cleanup resources when creation fails")

// Poor test names
it("should work")
it("test create")
it("error case")

Assertions

Common Assertions

// Equality assertions
assert.strictEqual(actual, expected);
assert.deepStrictEqual(actualObject, expectedObject);

// Boolean assertions
assert(condition);
assert.ok(value);

// Error assertions
assert.throws(() => {
  throwingFunction();
}, /expected error message/);

// Async error assertions
await assert.rejects(
  async () => await failingAsyncFunction(),
  { message: /expected error/ }
);

// Pattern matching
assert.match(string, /pattern/);
assert.doesNotMatch(string, /pattern/);

Custom Assertions

// Type assertion helper
function assertOk<T>(
  result: Result<T>
): asserts result is { ok: true; value: T } {
  assert.strictEqual(result.ok, true, "Expected successful result");
}

// Usage
const result = await operation();
assertOk(result);
// TypeScript now knows result.value is available
console.log(result.value);

Mock Verification

Verifying Mock Calls

it("should call git with correct arguments", async () => {
  await createWorktree("feature", "develop");
  
  // Verify number of calls
  assert.strictEqual(mockGit.execute.mock.calls.length, 2);
  
  // Verify first call arguments
  assert.deepStrictEqual(
    mockGit.execute.mock.calls[0].arguments,
    ["rev-parse", "--show-toplevel"](/aku11i/phantom/wiki/"rev-parse",-"--show-toplevel")
  );
  
  // Verify second call arguments
  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")
  );
});

Verifying Mock State

it("should not create directory if validation fails", async () => {
  const result = await createWorktree("invalid/name");
  
  // Should not reach file system operations
  assert.strictEqual(mockFs.mkdir.mock.calls.length, 0);
  assert.strictEqual(mockGit.execute.mock.calls.length, 0);
});

Test Data and Fixtures

Creating Test Fixtures

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

Using Test Builders

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

// Usage in tests
const worktree = new WorktreeBuilder()
  .withName("feature")
  .withBranch("develop")
  .build();

Running Tests

Command Line

# Run all tests
pnpm test

# Run specific test file
node --test src/core/worktree/create.test.ts

# Run tests matching pattern
node --test --test-name-pattern="validation"

# Run with watch mode
node --test --watch

# Run with coverage
node --test --experimental-test-coverage

IDE Integration

VS Code

Add to .vscode/launch.json:

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

Best Practices

1. Test Independence

Each test should be independent:

// Good: Independent test
it("should create worktree", async () => {
  const result = await createWorktree("feature");
  assert.strictEqual(result.ok, true);
});

// Bad: Dependent on previous test
it("should list created worktree", async () => {
  // Assumes previous test created "feature"
  const result = await listWorktrees();
  assert(result.value.some(w => w.name === "feature"));
});

2. Clear Test Structure

Follow Arrange-Act-Assert pattern:

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. Meaningful Test Data

Use realistic test data:

// Good: Realistic data
const testBranch = "feature/user-authentication";
const testPath = "/home/user/projects/myapp";

// Bad: Meaningless data
const testBranch = "foo";
const testPath = "/test";

4. Error Message Testing

Test error messages for debugging:

it("should provide helpful error message", async () => {
  const result = await createWorktree("my/feature");
  
  assert.strictEqual(result.ok, false);
  if (!result.ok) {
    // Verify error message helps user
    assert.match(result.error.message, /cannot contain/);
    assert.match(result.error.message, /slash/);
  }
});

Summary

Unit testing in Phantom:

  1. Native Tools - Uses Node.js built-in test runner
  2. Mock Everything - Isolate units under test
  3. Test Behavior - Focus on outcomes, not implementation
  4. Clear Structure - Organized, readable tests
  5. Fast Execution - Milliseconds per test

Following these patterns ensures reliable, maintainable tests that give confidence in code correctness.