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:
- Native Tools - Uses Node.js built-in test runner
- Mock Everything - Isolate units under test
- Test Behavior - Focus on outcomes, not implementation
- Clear Structure - Organized, readable tests
- Fast Execution - Milliseconds per test
Following these patterns ensures reliable, maintainable tests that give confidence in code correctness.