| name | tdd-workflow |
| description | Use this skill when writing new features, fixing bugs, or refactoring code. Enforces test-driven development with 100% coverage on core transforms and 80%+ overall coverage. |
| origin | ECC |
Test-Driven Development Workflow
This skill ensures all code development follows TDD principles with comprehensive test coverage, tailored for the gas-vite-plugin project.
When to Activate
- Writing new features or functionality
- Fixing bugs or issues
- Refactoring existing code
- Adding new transform functions
- Modifying Vite plugin hooks
Core Principles
1. Tests BEFORE Code
ALWAYS write tests first, then implement code to make tests pass.
2. Coverage Requirements
src/transforms.ts (core logic): 100% coverage — statements, branches, functions, lines
- Overall: 80%+ coverage
- All edge cases covered
- Error scenarios tested
- Boundary conditions verified
3. Test Types
Unit Tests (tests/unit/)
- Pure transform functions in isolation
- String-in, string-out validation
- Edge cases: empty strings, comments, string literals, indentation
Integration Tests (tests/integration/)
- Real Vite builds against fixture projects
- Assert on actual build output
- Fixtures created and torn down per test (no shared mutable state)
- Plugin options and config hook behavior
TDD Workflow Steps
Step 1: Write User Journeys
As a [role], I want to [action], so that [benefit]
Example:
As a GAS developer, I want export keywords stripped from my build output,
so that Google Apps Script can execute my functions as top-level declarations.
Step 2: Generate Test Cases
For each user journey, create comprehensive test cases:
import { describe, expect, it } from "vitest";
import { stripExportKeywords } from "../../src/transforms.js";
describe("stripExportKeywords", () => {
it("strips export from function declarations", () => {
const input = "export function onOpen() { return 1; }";
const result = stripExportKeywords(input);
expect(result).toBe("function onOpen() { return 1; }");
});
it("returns code unchanged when no exports exist", () => {
const input = "function foo() {}";
const result = stripExportKeywords(input);
expect(result).toBe(input);
});
it("does not modify export inside a string literal", () => {
const input = 'const msg = "export function fake() {}";';
const result = stripExportKeywords(input);
expect(result).toBe(input);
});
});
Step 3: Run Tests (They Should Fail)
pnpm test
Step 4: Implement Code
Write minimal code to make tests pass:
export function stripExportKeywords(code: string): string {
}
Step 5: Run Tests Again
pnpm test
Step 6: Refactor
Improve code quality while keeping tests green:
- Remove duplication
- Improve naming
- Optimize performance
- Enhance readability
Step 7: Verify Coverage
pnpm test:coverage
Testing Patterns
Unit Test Pattern (Vitest)
import { describe, expect, it } from "vitest";
import { removeExportBlocks } from "../../src/transforms.js";
describe("removeExportBlocks", () => {
it("removes single named export block", () => {
const input = "function foo() {}\nexport { foo };";
const result = removeExportBlocks(input);
expect(result).toBe("function foo() {}\n");
});
it("removes export default prefix", () => {
const input = "export default function handler() {}";
const result = removeExportBlocks(input);
expect(result).toBe("function handler() {}");
});
it("returns code unchanged when no export blocks exist", () => {
const input = "function foo() {}\nconst bar = 1;";
const result = removeExportBlocks(input);
expect(result).toBe(input);
});
});
Integration Test Pattern (Vitest + real Vite builds)
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { build } from "vite";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import gasPlugin from "../../src/index.js";
const FIXTURES_DIR = resolve(import.meta.dirname, "../fixtures");
function createFixture(name: string, files: Record<string, string>): string {
const dir = resolve(FIXTURES_DIR, name);
rmSync(dir, { recursive: true, force: true });
mkdirSync(dir, { recursive: true });
mkdirSync(resolve(dir, "src"), { recursive: true });
for (const [path, content] of Object.entries(files)) {
const fullPath = resolve(dir, path);
mkdirSync(resolve(fullPath, ".."), { recursive: true });
writeFileSync(fullPath, content);
}
return dir;
}
async function buildFixture(fixtureDir: string, pluginOptions = {}) {
await build({
root: fixtureDir,
logLevel: "silent",
plugins: [gasPlugin(pluginOptions)],
build: {
lib: {
entry: resolve(fixtureDir, "src/main.ts"),
formats: ["es"],
fileName: () => "Code.js",
},
},
});
}
beforeEach(() => {
rmSync(FIXTURES_DIR, { recursive: true, force: true });
});
afterEach(() => {
rmSync(FIXTURES_DIR, { recursive: true, force: true });
});
describe("Build output", () => {
it("removes export keywords from function declarations", async () => {
const dir = createFixture("basic", {
"src/main.ts": "export function onOpen() { return 1; }",
});
await buildFixture(dir);
const output = readFileSync(resolve(dir, "dist/Code.js"), "utf-8");
expect(output).not.toMatch(/^export\s/m);
expect(output).toMatch(/^function onOpen\(\)/m);
});
});
Mocking with Vitest
import { vi } from "vitest";
const warnSpy = vi
.spyOn(console, "warn")
.mockImplementation(() => {});
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("[gas-vite-plugin] manifest not found"),
);
warnSpy.mockRestore();
Test File Organization
packages/gas-vite-plugin/
├── src/
│ ├── index.ts # Plugin factory + Vite hooks
│ └── transforms.ts # Pure string transforms (100% coverage)
├── tests/
│ ├── unit/
│ │ └── transforms.test.ts # Unit tests for pure functions
│ ├── integration/
│ │ └── build.test.ts # Real Vite build tests with fixtures
│ └── fixtures/ # Created/destroyed per test run
└── vitest.config.ts
Coverage Configuration (vitest.config.ts)
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["tests/**/*.test.ts"],
coverage: {
provider: "v8",
include: ["src/transforms.ts"],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
},
});
Common Testing Mistakes to Avoid
Don't: Test Implementation Details
expect(output).toContain("const greeting = 'hello'");
Do: Test Observable Behavior
expect(output).toMatch(/^function onOpen\(/m);
expect(output).not.toMatch(/^export\s/m);
Don't: Share Mutable State Between Tests
const dir = createFixture("shared", { ... });
Do: Isolate Each Test
beforeEach(() => rmSync(FIXTURES_DIR, { recursive: true, force: true }));
afterEach(() => rmSync(FIXTURES_DIR, { recursive: true, force: true }));
Development Workflow Commands
Run Tests
pnpm test
Run Tests in Watch Mode
pnpm --filter gas-vite-plugin exec vitest --watch
Run Coverage Report
pnpm test:coverage
Lint & Format Check
pnpm check
CI/CD (GitHub Actions)
- name: Run Tests
run: pnpm test
- name: Check Coverage
run: pnpm test:coverage
- name: Lint & Format
run: pnpm check
Best Practices
- Write Tests First — Always TDD
- Pure Functions for Transforms — String-in, string-out; no side effects
- Real Builds for Integration — Use actual Vite builds, not mocks
- Fixture Per Test — Create and tear down fixtures in beforeEach/afterEach
- Test Edge Cases — Empty strings, comments, string literals, indentation
- Test Error Paths — Missing manifests, invalid configs
- Keep Tests Fast — Unit tests < 50ms; integration tests use
logLevel: "silent"
- No Shared Mutable State — Every test is independent
- Regex Assertions on Output — Use
toMatch(/pattern/m) for build output validation
- Review Coverage Reports — 100% on transforms.ts is enforced, not optional
Success Metrics
- 100% code coverage on
src/transforms.ts
- 80%+ overall coverage
- All tests passing (green)
- No skipped or disabled tests
- Integration tests cover all user stories (US1, US2, US3, ...)
- Tests catch regressions before CI fails
Remember: Tests are not optional. Core transform logic requires 100% coverage. Integration tests use real Vite builds — no mocking the build pipeline.