Skip to content

Testing Guidelines for Nagare

This document outlines the testing strategy and best practices for the Nagare project. Following these guidelines ensures consistent, maintainable, and effective tests that provide confidence in our codebase.

We follow a pragmatic testing approach focused on:

  • Behavior over implementation - Test what the code does, not how it does it
  • Critical path coverage - Prioritize testing of business-critical functionality
  • Meaningful assertions - Each test should verify actual behavior, not just make coverage numbers look good
  • Maintainability - Tests should be easy to understand and update

Based on component criticality:

Component TypeTarget CoverageRationale
Core Business Logic (ReleaseManager, GitOperations)80-85%Critical to application functionality
API/Public Interfaces80-90%External contracts must be reliable
Utility Functions70-80%Important but often straightforward
UI Components60-70%Visual testing often more valuable
Configuration FilesMinimalLow risk, low complexity
Test Helpers/MocksExcludedSupport code, not production code

Overall Target: 49% minimum (CI requirement), 80% recommended

src/
├── component.ts # Production code
├── component_test.ts # Unit tests (colocated)
└── component_test_helper.ts # Test utilities (if needed)
tests/
├── integration/ # Integration tests
├── e2e/ # End-to-end tests
└── fixtures/ # Test data and fixtures
// Test suite names describe the component
Deno.test("ComponentName - method or feature being tested", async () => {
// Test implementation
});
// Use descriptive sub-tests for multiple scenarios
Deno.test("GitOperations - commit analysis", async (t) => {
await t.step("parses conventional commits", async () => {
// Test implementation
});
await t.step("handles non-conventional commits", async () => {
// Test implementation
});
});

Mock external dependencies to isolate the unit under test:

// Mock Deno.Command for git operations
const originalCommand = Deno.Command;
(Deno as any).Command = class MockCommand {
constructor(public cmd: string, public options?: any) {}
output() {
return Promise.resolve({
success: true,
stdout: new TextEncoder().encode("mock output"),
stderr: new TextEncoder().encode(""),
code: 0,
});
}
};
// Don't forget to restore after test
try {
// Test code
} finally {
(Deno as any).Command = originalCommand;
}

Create reusable test utilities:

// Test data factories
export function createTestConfig(overrides?: Partial<NagareConfig>): NagareConfig {
return {
...DEFAULT_CONFIG,
project: {
name: "test-project",
repository: "https://github.com/test/test",
...overrides?.project,
},
...overrides,
};
}
// Mock dependencies factory
export function createMockDeps(config: NagareConfig, state?: TestState) {
return {
git: new MockGitOperations(state?.gitState),
fileHandler: new MockFileHandler(state?.files),
logger: new MockLogger(),
// ... other mocks
};
}

Ensure both success and failure paths are tested:

Deno.test("handles success case", async () => {
const result = await functionUnderTest(validInput);
assertEquals(result.success, true);
assertEquals(result.value, expectedValue);
});
Deno.test("handles error case", async () => {
await assertRejects(
async () => await functionUnderTest(invalidInput),
NagareError,
"Expected error message",
);
});

Test edge cases and boundary conditions:

Deno.test("handles version 0.x.x correctly", async () => {
// In 0.x.x, breaking changes bump minor, not major
const result = await calculateVersion("0.5.0", BumpType.BREAKING);
assertEquals(result, "0.6.0");
});
Deno.test("handles empty input", async () => {
const result = await processCommits([]);
assertEquals(result.bumpType, BumpType.PATCH); // Default behavior
});

Always properly handle async operations:

// ✅ Good - Properly awaited
Deno.test("async operation", async () => {
const result = await asyncFunction();
assertEquals(result, expected);
});
// ❌ Bad - Missing await
Deno.test("async operation", () => {
asyncFunction().then((result) => {
assertEquals(result, expected);
});
});

Use Deno’s testing utilities for spying and stubbing:

import { spy, stub } from "@std/testing/mock";
Deno.test("tracks function calls", () => {
const mySpy = spy();
functionThatCallsCallback(mySpy);
assertEquals(mySpy.calls.length, 1);
assertEquals(mySpy.calls[0].args[0], expectedArg);
});

Ensure proper cleanup of resources:

Deno.test({
name: "test with resources",
sanitizeResources: false, // Only if necessary
sanitizeOps: false, // Only if necessary
fn: async () => {
const tempDir = await Deno.makeTempDir();
try {
// Test code using tempDir
} finally {
await Deno.remove(tempDir, { recursive: true });
}
},
});
Terminal window
# Run all tests
deno test
# Run with permissions
deno test --allow-read --allow-write --allow-env --allow-run
# Run specific test file
deno test src/release/release-manager_test.ts
# Run with coverage
deno test --coverage=coverage
# Generate coverage report
deno coverage coverage --html

Use the predefined tasks in deno.json:

Terminal window
# Run tests with proper flags
deno task test
# Run tests with coverage
deno task test:coverage
# Generate coverage reports
deno task coverage:generate
# Run tests in watch mode
deno task test:watch

The CI pipeline enforces:

  • Minimum coverage: 49% (will fail build if below)
  • No test failures: All tests must pass
  • Type checking: Tests must pass strict type checking

Some integration tests may be skipped in CI:

Deno.test({
ignore: Deno.env.get("CI") === "true",
name: "integration test requiring real git repo",
fn: async () => {
// Test that requires actual git operations
},
});
// ❌ Bad - Tests internal implementation
test("uses Array.push", () => {
const spy = spyOn(Array.prototype, "push");
addItem(list, item);
expect(spy).toHaveBeenCalled();
});
// ✅ Good - Tests behavior
test("adds item to list", () => {
const list = ["apple"];
addItem(list, "banana");
expect(list).toContain("banana");
});

Don’t mock what you’re testing:

// ❌ Bad - Mocking the system under test
const mockReleaseManager = new MockReleaseManager();
test("release manager", () => {
mockReleaseManager.release();
expect(mockReleaseManager.releaseCalled).toBe(true);
});
// ✅ Good - Testing actual behavior with mocked dependencies
test("release manager", () => {
const mockGit = new MockGitOperations();
const manager = new ReleaseManager(config, { git: mockGit });
const result = manager.release();
expect(result.success).toBe(true);
});

Always handle async errors properly:

// ✅ Good - Properly catches async errors
await assertRejects(
async () => await functionThatThrows(),
Error,
"Expected error",
);
Terminal window
# Run with debug logging
deno test --log-level=DEBUG
# Run specific test with verbose output
deno test --filter "test name" src/file_test.ts
Deno.test("debug this test", async () => {
debugger; // Breakpoint when running with --inspect
const result = await complexFunction();
assertEquals(result, expected);
});

Run with debugger:

Terminal window
deno test --inspect-brk
  1. Test behavior, not implementation
  2. Mock external dependencies, not the system under test
  3. Write clear, descriptive test names
  4. Keep tests focused and isolated
  5. Clean up resources after tests
  6. Use test helpers to reduce duplication
  7. Test both success and failure paths
  8. Include edge cases and boundary conditions
  9. Maintain tests alongside code changes
  10. Run tests locally before pushing
  • Review test coverage reports monthly
  • Refactor tests when they become brittle
  • Update tests when requirements change
  • Remove obsolete tests
  • Document complex test setups
  • Explain non-obvious assertions
  • Keep this guide updated with new patterns

Last Updated: 2025-08-14 Maintained by: Nagare Development Team