一键导入
forge-tdd
WHEN: About to write any production code. HARD-GATE: Iron law - write test first, watch fail, write minimal code, watch pass. No exceptions.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
WHEN: About to write any production code. HARD-GATE: Iron law - write test first, watch fail, write minimal code, watch pass. No exceptions.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
WHEN: You need to detect performance regressions before merge — TTFB, response time, bundle size. Run as part of forge-eval-gate or standalone before raising a PR.
WHEN: A decision is being superseded, deprecated, or has aged out. Archive it without deletion — marks as warm→cold→archived with full audit trail.
WHEN: You are writing a new decision, superseding an old one, or querying relationships across decisions. Create semantic edges between decisions and link concepts across products/projects/time.
WHEN: You are about to make a decision and need to check if prior art or past learnings exist. Recall decisions, patterns, and gotchas from the brain before proceeding.
WHEN: You need to trace the full provenance of a specific decision — who made it, when, why, and what alternatives were considered. Shows why, when, by whom, evidence, alternatives, outcome.
WHEN: You need to record a decision, lock a spec, log an eval run, or document learnings in the brain.
| name | forge-tdd |
| description | WHEN: About to write any production code. HARD-GATE: Iron law - write test first, watch fail, write minimal code, watch pass. No exceptions. |
| type | rigid |
| version | 1.1.0 |
| preamble-tier | 3 |
| requires | ["worktree-per-project-per-task"] |
| triggers | ["write test first","TDD","test-driven development","RED test before code"] |
| allowed-tools | ["Bash","Write"] |
Prerequisite (HARD-GATE): You must be executing inside an isolated git worktree created by worktree-per-project-per-task. Step 0 enforces this. No test is written before worktree existence is confirmed.
HARD-GATE: Non-negotiable. No production code without failing test first.
| Rationalization | The Truth |
|---|---|
| "This is a simple feature, TDD feels slow" | Simplicity hides the hardest bugs. TDD catches them. Slow startup, faster debugging. Net win. |
| "I'll write tests after to save time" | You won't. Post-hoc tests miss 40% of edge cases TDD would have caught. Write first. |
| "The spec is clear enough, I don't need tests to clarify it" | No spec is ever clear enough. Test is the spec. Test forces you to think through edge cases. |
| "I can skip the test-run-fail step, I know it will fail" | NO. You MUST run it and see the failure. Seeing the failure teaches you what you're fixing. |
| "Our codebase doesn't do TDD, I'll follow convention" | YOU are disciplined. Convention doesn't override discipline. Do it. |
| "I'll test as I code (test-parallel) instead of test-first" | Wrong order. Test FIRST, then code. The order matters. Test-first catches what test-parallel misses. |
| "This code is internal/hidden, no one will use it, I'll skip TDD" | Internal code is harder to test and debug. TDD is MORE important, not less. |
| "I already know what tests to write, I'll code first" | You don't. Writing code first blinds you to edge cases. Test first clarifies. |
| "The test infrastructure is broken, I'll work around it" | STOP. Report BLOCKED. Don't workaround. Fix or escalate. |
| "I wrote a test that passes but it doesn't actually test anything" | Weak tests are worse than no tests. Test must verify behavior, not just syntax. |
| "RED tests only mirror the tech plan, not approved QA cases" | When qa/manual-test-cases.csv exists for the task, RED should map to those atomic rows (or explicitly document gaps). Otherwise TDD and P4.4 semantic machine eval drift from the acceptance inventory the team signed. |
Before writing a single test line, verify you are executing inside an isolated git worktree.
# Verify current directory is a worktree (not main working tree)
git rev-parse --show-toplevel && git worktree list | grep "$(git rev-parse --show-toplevel)"
Expected output: the current path appears in git worktree list with a branch name matching the task (e.g., task/<task-id>).
If the check fails (current dir is main, or no task branch exists):
worktree-per-project-per-task to create a fresh isolated worktree for this task.cd into the new worktree path.HARD-GATE: No test file is created, no git add is run, and no RED phase begins until git worktree list shows the current path on a task-specific branch.
Before writing the first test, read the project's coding conventions:
cat ~/forge/brain/products/<slug>/codebase/code-style.md
Match test code to these conventions: import style, async pattern (async/await vs .then()), assertion library (expect vs assert), test block naming (describe/it vs test()), error handling shape, and file naming.
Fallback if code-style.md is absent (scan not yet run): Open the 3 most recently modified source files in the repo and infer conventions from them:
git -C <repo-path> log --diff-filter=M --name-only -3 -- '*.ts' '*.py' '*.kt' '*.go' '*.java' | grep '\.'
Log: [WARN] code-style.md absent — test style inferred from recent files.
HARD-GATE: Do not write a single test line before completing this step. Mismatched test style causes P2 reviewer findings that delay merge.
NO PRODUCTION CODE EXISTS BEFORE THE TEST.
If you write 1 line of code without a test first, you have failed.
If you notice any of these, STOP and do not proceed:
delivery_mechanism + implementation_stack (or legacy ui_implementation_stack) from prd-locked.md Q10 and approved QA rows when present.Write a test that describes the desired behavior
Run the test
Success Criterion: Red test fails with clear, meaningful error.
Before writing any production code, you must have failing tests covering ALL four scenario types for every public function/method/endpoint being implemented. One test total is not acceptable.
| Scenario Type | Required count | What it tests |
|---|---|---|
| Happy path | 1+ per public function | Normal input → expected output |
| Edge case | 2+ per public function | At minimum: one null/empty/zero input AND one boundary value (max length, min int, overflow, off-by-one). Also consider: concurrent call, permission denied (if access-controlled), malformed input, missing required field |
| Error / failure | 1+ per public function | Invalid input, dependency down, timeout, permission denied |
| Integration | 1+ per API endpoint or cross-service call | Full request → handler → DB/cache/queue → response round trip |
HARD-GATE: If your test file has fewer than 4 tests per public function (or fewer than 5 for any API endpoint), you have not completed the RED phase. Do not proceed to GREEN.
Anti-patterns that trigger STOP:
it('should work') — not a scenario, not a testassert result is not None / expect(fn).toHaveBeenCalled() as the primary assertion — existence and call-only checks are hollow. Assert the specific output contract.test_login, test_user, test_api) — rewrite with behavior + condition pattern.Recommended test authoring order within RED:
Writing edge cases first is a trap: you'll design the API around edge cases and miss the obvious happy path.
Every test MUST use Arrange-Act-Assert. Label each section with a comment. No exceptions.
# Python example
def test_login_with_valid_credentials_returns_jwt_token():
# --- Arrange ---
mock_user_repo = Mock()
mock_user_repo.find_by_email.return_value = User(
id="user_1", email="test@example.com",
password_hash=hash_password("correct_pass"), active=True
)
service = LoginService(user_repo=mock_user_repo, token_ttl=3600)
# --- Act ---
result = service.authenticate("test@example.com", "correct_pass")
# --- Assert ---
assert result.token is not None, "Valid credentials must yield a token"
assert result.expires_in == 3600, f"Expected ttl=3600, got {result.expires_in}"
assert result.user_id == "user_1", "Token must identify the authenticated user"
// TypeScript example
it('should return 200 with JWT when credentials are valid', async () => {
// --- Arrange ---
jest.spyOn(userRepo, 'findByEmail').mockResolvedValue(
mockUser({ email: 'test@example.com', passwordHash: hash('correct_pass') })
);
// --- Act ---
const response = await request(app)
.post('/api/auth/login')
.send({ email: 'test@example.com', password: 'correct_pass' });
// --- Assert ---
expect(response.status).toBe(200);
expect(response.body.token).toMatch(/^eyJ/); // JWT format
expect(response.body.expiresIn).toBe(3600);
expect(response.body).not.toHaveProperty('passwordHash'); // no secret leak
});
Rules:
Test names must describe behavior and condition, not the function name.
| ❌ BAD (describes code) | ✅ GOOD (describes behavior) |
|---|---|
test_login() | test_login_with_valid_credentials_returns_jwt_token() |
test_user_service() | test_create_user_when_email_already_exists_raises_duplicate_error() |
test_api() | test_get_orders_when_user_has_no_orders_returns_empty_list() |
it('works') | it('should display validation error when email field is empty on submit') |
test_edge_case() | test_validate_email_rejects_string_without_at_symbol() |
Pattern:
test_<subject>_<condition>_<expected_outcome>it('should <expected_outcome> when <condition>')`when <condition>, <expected_outcome>` (backtick syntax)HARD-GATE: A test named test_login, test_user, test_api, test_edge, or any single-word name is NOT a valid test name — rewrite it before committing.
Assertions must verify specific observable behavior, not just existence or invocation.
Assertion quality ladder — only ✅ tiers are acceptable:
| Tier | Example | Verdict |
|---|---|---|
| ❌ Existence | assert result / assert result is not None | Useless — passes for any truthy value |
| ❌ Call-only | expect(fn).toHaveBeenCalled() | Useless — doesn't verify arguments or outcome |
| ❌ Status only | expect(response.status).toBe(200) alone | Incomplete — body might be wrong |
| ✅ Specific value | assert result.token.startswith('eyJ') | Good — verifies content |
| ✅ Full contract | assert result.token, assert result.expires_in == 3600, assert result.user_id == user.id | Best — covers the full output contract |
Rules:
assert x == y, f"Expected {y}, got {x}" / expect(x).toBe(y) (Jest messages are built in).pytest.raises(AuthError) as exc; assert exc.value.code == 'INVALID_CREDENTIALS'update_user is called, assert user.updated_at changed and user.name == new_name.class TestOrderService:
def test_place_order_with_valid_items_creates_order_and_returns_order_id(self):
# --- Arrange ---
mock_inventory = Mock()
mock_inventory.check_availability.return_value = True
mock_order_repo = Mock()
mock_order_repo.save.return_value = Order(id="ord_123", status="PENDING")
service = OrderService(inventory=mock_inventory, order_repo=mock_order_repo)
# --- Act ---
result = service.place_order(user_id="u1", items=[{"sku": "A1", "qty": 2}])
# --- Assert ---
assert result.order_id == "ord_123", f"Expected ord_123, got {result.order_id}"
assert result.status == "PENDING", f"New order must be PENDING, got {result.status}"
mock_order_repo.save.assert_called_once() # side effect verified AFTER output
def test_place_order_when_item_out_of_stock_raises_out_of_stock_error(self):
# --- Arrange ---
mock_inventory = Mock()
mock_inventory.check_availability.return_value = False
service = OrderService(inventory=mock_inventory, order_repo=Mock())
# --- Act / Assert ---
with pytest.raises(OutOfStockError) as exc:
service.place_order(user_id="u1", items=[{"sku": "A1", "qty": 2}])
assert exc.value.sku == "A1", f"Error must name the out-of-stock SKU"
assert exc.value.code == "OUT_OF_STOCK"
describe('POST /api/orders', () => {
it('should return 201 with order id when items are in stock', async () => {
// --- Arrange ---
jest.spyOn(inventoryService, 'checkAvailability').mockResolvedValue(true);
jest.spyOn(orderRepo, 'save').mockResolvedValue({ id: 'ord_123', status: 'PENDING' });
// --- Act ---
const response = await request(app)
.post('/api/orders')
.set('Authorization', `Bearer ${validToken}`)
.send({ items: [{ sku: 'A1', qty: 2 }] });
// --- Assert ---
expect(response.status).toBe(201);
expect(response.body.orderId).toBe('ord_123');
expect(response.body.status).toBe('PENDING');
expect(response.headers['location']).toContain('/api/orders/ord_123');
});
it('should return 409 with OUT_OF_STOCK when item unavailable', async () => {
// --- Arrange ---
jest.spyOn(inventoryService, 'checkAvailability').mockResolvedValue(false);
// --- Act ---
const response = await request(app)
.post('/api/orders')
.set('Authorization', `Bearer ${validToken}`)
.send({ items: [{ sku: 'A1', qty: 2 }] });
// --- Assert ---
expect(response.status).toBe(409);
expect(response.body.error).toBe('OUT_OF_STOCK');
expect(response.body.sku).toBe('A1');
expect(response.body).not.toHaveProperty('orderId'); // no partial order on failure
});
it('should return 401 when Authorization header is missing', async () => {
const response = await request(app).post('/api/orders').send({ items: [] });
expect(response.status).toBe(401);
expect(response.body.error).toBe('UNAUTHORIZED');
});
});
describe('OrderForm', () => {
it('should show quantity error when qty exceeds max stock', async () => {
// --- Arrange ---
render(<OrderForm maxStock={5} onSubmit={jest.fn()} />);
// --- Act ---
await userEvent.type(screen.getByLabelText(/quantity/i), '10');
await userEvent.click(screen.getByRole('button', { name: /place order/i }));
// --- Assert ---
expect(screen.getByRole('alert')).toHaveTextContent('Maximum quantity is 5');
expect(screen.queryByText(/order placed/i)).not.toBeInTheDocument();
});
it('should call onSubmit with sku and qty when form is valid', async () => {
// --- Arrange ---
const onSubmit = jest.fn().mockResolvedValue({ orderId: 'ord_123' });
render(<OrderForm maxStock={10} onSubmit={onSubmit} />);
// --- Act ---
await userEvent.type(screen.getByLabelText(/sku/i), 'A1');
await userEvent.type(screen.getByLabelText(/quantity/i), '3');
await userEvent.click(screen.getByRole('button', { name: /place order/i }));
// --- Assert ---
expect(onSubmit).toHaveBeenCalledWith({ sku: 'A1', qty: 3 });
await screen.findByText(/order placed/i); // async confirmation visible
});
});
class OrderViewModelTest {
@Test
fun `when valid items submitted, emits Success state with order id`() {
// --- Arrange ---
val orderRepo = mockk<OrderRepository>()
coEvery { orderRepo.placeOrder(any()) } returns Result.Success(Order(id = "ord_123", status = "PENDING"))
val viewModel = OrderViewModel(orderRepo)
// --- Act ---
viewModel.placeOrder(items = listOf(OrderItem(sku = "A1", qty = 2)))
// --- Assert ---
val state = viewModel.uiState.value
assertIs<OrderUiState.Success>(state)
assertEquals("ord_123", state.orderId)
assertEquals("PENDING", state.status)
}
@Test
fun `when item is out of stock, emits Error state with OUT_OF_STOCK code`() {
// --- Arrange ---
val orderRepo = mockk<OrderRepository>()
coEvery { orderRepo.placeOrder(any()) } returns Result.Error(OutOfStockException("A1"))
val viewModel = OrderViewModel(orderRepo)
// --- Act ---
viewModel.placeOrder(items = listOf(OrderItem(sku = "A1", qty = 2)))
// --- Assert ---
val state = viewModel.uiState.value
assertIs<OrderUiState.Error>(state)
assertEquals("OUT_OF_STOCK", state.code)
assertEquals("A1", state.sku)
}
}
Mock at system boundaries only. Do NOT mock your own business logic.
| ✅ Mock these (system boundaries) | ❌ Never mock these (your own code) |
|---|---|
| Database / ORM queries | Service classes you wrote |
| HTTP clients (external APIs) | Value objects / DTOs |
| File system reads/writes | Pure functions |
| Message queue producers/consumers | Enum lookups |
Clock / datetime.now() / Date.now() | In-memory data structures |
| Third-party SDKs (payment, auth, SMS) | Constants |
| Email / push notification services | Business rule validators |
Mocking strategy by test type:
Mock verification rule: Always verify mock calls WITH arguments, not just that they were called:
# ❌ Wrong — verifies nothing
mock_email.send.assert_called()
# ✅ Right — verifies the contract
mock_email.send.assert_called_once_with(
to="user@example.com",
subject="Order Confirmation",
template="order-confirm",
context={"order_id": "ord_123"}
)
These tests technically pass the matrix count but provide zero coverage. Any test matching these patterns must be rewritten before committing.
| Pattern | Example | Why it fails | Rewrite target |
|---|---|---|---|
| Trivial equality | assert add(2, 2) == 4 | Only tests the obvious; catches nothing real | Test boundary: add(MAX_INT, 1) → overflow behavior |
| Existence only | assert result is not None | Passes for any truthy output | Assert specific fields: assert result.order_id.startswith('ord_') |
| Call-only mock | expect(fn).toHaveBeenCalled() | Doesn't verify arguments or return value | expect(fn).toHaveBeenCalledWith(expectedArgs) |
| Status-only HTTP | expect(res.status).toBe(200) alone | Body could be empty or wrong | Add body assertions: expect(res.body.data).toEqual(expectedData) |
| Tautology | assert user.name == user.name | Always true | assert user.name == 'Alice' (fixed expected value) |
| No negative path | Only happy path tests for auth | Auth bugs live in the failure paths | Add: wrong password → 401, missing token → 401, expired token → 401 |
| Stub behavior only | Test only verifies mock was set up correctly | Tests the mock, not the real code | Remove mock setup for the function under test; verify the actual output |
When qa/manual-test-cases.csv exists for the task, tie each RED test to an acceptance Id and/or a qa/semantic-automation.csv step Id using a comment the verifier scans:
def test_valid_login_succeeds(self):
# forge-tdd: TC-001 (manual-test-cases.csv)
# forge-tdd: step-login (semantic-automation.csv)
...
# forge-tdd:). Parenthetical hints are optional.Required column on the manual CSV (yes / true / 1 / y) means at least one scanned test must include # forge-tdd: <that Id>.~/forge/brain/prds/<task-id>/: all test_*.py and *_test.py, or only paths/globs listed in qa/tdd-scan-paths.txt (one entry per line, relative to the task dir).python3 tools/verify/verify_forge_task.py --verify-tdd-csv-trace fails the build when markers reference unknown ids or required rows lack markers.forge_drift_check.py adds these # forge-tdd: lines to the success-criteria haystack by default (use --skip-tdd-marker-hay to omit them).Write minimal code to make test pass
Run the test
Success Criterion: Test passes. Only minimal code added.
Refactor the implementation (not the test)
Run tests again
Stop refactoring
Success Criterion: Tests still pass, code is cleaner, scope unchanged.
Example:
# TASK: Add method to validate email format
# BAD TEST: Tests that function exists and returns something
def test_validate_email():
assert email_validator.validate_email("test@example.com") is not None
# GOOD TEST: Tests the specific behavior
def test_validate_email_accepts_valid_address():
assert email_validator.validate_email("test@example.com") == True
def test_validate_email_rejects_invalid_address():
assert email_validator.validate_email("invalid") == False
def test_validate_email_rejects_missing_at_symbol():
assert email_validator.validate_email("testexample.com") == False
# Run your new test
$ npm test -- test_email_validator.js
# MUST see: Test FAILED / Red
Do not proceed until you see the test fail.
# MINIMAL implementation
def validate_email(email: str) -> bool:
return "@" in email and "." in email.split("@")[1]
$ npm test -- test_email_validator.js
# MUST see: Test PASSED / Green
Do not proceed until the test passes.
$ npm test # All tests, not just the new one
# MUST see: All tests still pass
If existing tests now fail, you broke something. Fix it before proceeding.
Only after tests pass:
If yes → refactor. Then re-run all tests to verify.
Read existing tests in the project. Copy the pattern. Ask for clarification if needed.
Report BLOCKED: Test infrastructure broken [details]. Do not attempt workarounds. Escalate.
These are typically not TDD-able. But: if possible, write a test that verifies the logging/docs exist and are correct. If not possible, report NEEDS_CONTEXT: Task not TDD-compatible [reason].
That's normal and correct. Complex behavior requires complex tests. Tests are not overhead; they're part of the implementation.
Stop. Report NEEDS_CONTEXT: Unclear acceptance criteria [details]. Don't guess.
You probably wrote too much code. Roll back. Write LESS code. Make the first test pass. Then write the next test.
Don't. Refactor ONLY the code you touched. Out-of-scope refactoring is not part of the task.
Create a test file. Use standard test framework for the language. Start with one test. But: if test infrastructure is fundamentally broken, report BLOCKED.
✅ PASS:
❌ BLOCKED:
Before claiming task is done, verify:
# 1. New test exists
$ grep -r "def test_" <test_file> # New test present?
# 2. Test passes
$ npm test -- <test_file> # All tests in file pass?
# 3. All tests pass
$ npm test # No regressions?
# 4. Code is minimal
$ git diff <file> # Only necessary changes? No extra features?
# 5. Code is clear
# Read the code. Is it obvious what it does?
If all 5 pass → done. Otherwise → iterate.
Situation: Test is valid and correct, but takes 15+ seconds to run. Each RED-GREEN cycle takes minutes instead of seconds.
Example: Database integration test that creates 1000 records and validates queries. Valid test, but too slow for fast feedback loop.
Do NOT: Skip the test or reduce test scope because it's slow. Slow tests catch real issues.
Action:
Situation: Test passes sometimes, fails sometimes. No clear pattern (timing, state, environment).
Example: Test that polls for eventual consistency; sometimes data appears in 10ms, sometimes 500ms. Test has hard-coded 50ms wait.
Do NOT: Accept flakiness or increase timeouts. Flakiness is a real bug in code or test.
Action:
Situation: Test is valid, but requires external service that's not running or accessible.
Example: Test for payment gateway integration requires live API connection. API service is down.
Do NOT: Skip the test or mock the service permanently. Integration tests must eventually test real integration.
Action:
Situation: Code to test is tightly coupled to global state, static calls, or framework internals. Cannot unit test without refactoring code itself.
Example: Class that calls Database.getInstance().query() globally; Database is a singleton with no way to inject a test double.
Do NOT: Skip TDD or write test after code. Tight coupling is the problem.
Action:
// BEFORE: tightly coupled
class UserRepository {
def getUser(id) { Database.getInstance().query(...) }
}
// AFTER: injectable
class UserRepository {
constructor(database) { this.db = database }
def getUser(id) { this.db.query(...) }
}
Situation: Behavior cannot be verified with unit test alone. Service boundary requires integration test (multiple services, eventual consistency, network behavior).
Example: Feature: "Cache invalidated when user data changes". Requires backend write → cache invalidation → frontend read. One service cannot test alone.
Do NOT: Force a unit test for inherently distributed behavior. Integration tests are valid TDD.
Action:
Use this tree to decide which test to write first:
START: I need to test behavior X
Q1: Does X require multiple services/processes?
├─ YES → Q2
└─ NO → UNIT TEST (test single component in isolation)
Q2: Does X depend on eventual consistency, timing, or network?
├─ YES → INTEGRATION TEST (test end-to-end, multiple services)
└─ NO → Could be unit test with mocks (see Q3)
Q3: Is the external service/boundary testable in isolation?
├─ NO → INTEGRATION TEST (no way around it)
├─ YES, easy to mock → UNIT TEST (mock the boundary)
└─ YES, but mocking loses important behavior → INTEGRATION TEST (test real behavior)
Q4: Is the integration test too slow (>5 sec)?
├─ YES → Split: UNIT TEST (fast path) + INTEGRATION TEST (slow path, run separately)
└─ NO → Single INTEGRATION TEST suffices
DECISION RULE:
- Unit test for single component logic (validation, calculation, formatting)
- Integration test for multi-component behavior (contracts, APIs, eventual consistency)
- Always write test first (RED), regardless of type
- Fast feedback: prefer unit tests for development
- Final verification: integration tests before merge
Output: TDD PASS (test first, minimal code, all tests pass — log [P4.0-TDD-RED] task_id=<id> repo=<repo> test_files=<list> red_confirmed=yes to conductor.log after RED phase is confirmed) or BLOCKED (test infrastructure broken, untestable legacy code, infrastructure unavailable after attempts to restore)
TDD is not about writing tests. It's about:
TDD feels slow at first. After the RED phase (writing the test), you're 60% done. The code is the easy 40%.
This skill is RIGID: type=rigid. Do not bend it.
TDD is the foundation. Every other discipline depends on it.
Note: This version includes edge cases and decision tree for complex testing scenarios (slow tests, flaky tests, infrastructure dependencies, legacy code, integration vs. unit testing).
test_<subject>_<condition>_<expected_outcome> pattern (no single-word names).After all tests pass (GREEN phase complete and REFACTOR done):
forge-verification — TDD proves unit/integration correctness; forge-verification confirms the integrated system behaves correctly end-to-end before the task is marked ready for review.forge-verification passing.