Skip to content

Commit 2382553

Browse files
committed
docs(design): add testing specification for TDD workflow
1 parent b013e1c commit 2382553

File tree

2 files changed

+279
-0
lines changed

2 files changed

+279
-0
lines changed

docs/design/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Design documentation for `lnk`, an opinionated symlink manager for dotfiles.
1818
| [output.md](output.md) | Output system: verbosity, color, piped format |
1919
| [internals.md](internals.md) | Internal helpers: `FindManagedLinks`, `CreateSymlink`, `MoveFile`, etc. |
2020
| [stdlib.md](stdlib.md) | Standard library usage: which packages/functions to use and why |
21+
| [testing.md](testing.md) | Testing strategy: TDD workflow, test levels, conventions, helpers |
2122

2223
## Glossary
2324

docs/design/testing.md

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
# Testing Specification
2+
3+
---
4+
5+
## 1. Overview
6+
7+
### Purpose
8+
9+
This spec defines how tests are written for `lnk`. It supports a TDD workflow where
10+
design specs are translated into tests before implementation code is written.
11+
12+
### Goals
13+
14+
- **TDD-first**: design specs drive test cases before implementation
15+
- **Two test levels**: unit tests in `lnk/` and e2e tests in `test/` with clear boundaries
16+
- **Stdlib only**: pure `testing` package, no external test frameworks
17+
- **Reuse over reinvention**: documented helpers prevent duplicate test utilities
18+
19+
### Non-Goals
20+
21+
- Fuzz testing or property-based testing
22+
- Benchmark tests
23+
- Test mocking frameworks or dependency injection
24+
25+
---
26+
27+
## 2. TDD Workflow
28+
29+
### From Spec to Tests
30+
31+
Each design spec contains the information needed to write tests before implementation.
32+
The mapping is:
33+
34+
| Spec element | Test case |
35+
| --------------------------------------- | --------------------------------------------------- |
36+
| Numbered behavior step | Unit test case verifying that step's outcome |
37+
| Error-type mapping table row | Unit test case triggering that error condition |
38+
| Go function signature | `Test<Function>` with table-driven cases |
39+
| CLI usage example | E2e test running the binary and checking output |
40+
| Output format (terminal and piped) | Output test asserting both formats |
41+
42+
### Process
43+
44+
1. **Read the design spec** — identify Go function signatures, input/output contracts,
45+
error types, and output formats
46+
2. **Write unit tests first** — for every Go function in the spec, write table-driven
47+
tests covering: happy path, each error condition from the error-mapping table, and
48+
boundary cases
49+
3. **Write e2e tests second** — for every CLI usage example in the spec, write a test
50+
that runs the compiled binary and asserts exit code, stdout, and stderr
51+
4. **Implement until tests pass**
52+
53+
### Example: Mapping `create.md` to Tests
54+
55+
`create.md` specifies three phases:
56+
57+
- **Phase 1 (Collect)**: walk source dir, apply `PatternMatcher` → test that ignored
58+
files are excluded, non-ignored files are collected
59+
- **Phase 2 (Validate)**: call `ValidateSymlinkCreation` for each file, all-or-nothing
60+
→ test that any validation failure aborts before filesystem changes
61+
- **Phase 3 (Execute)**: call `CreateSymlink`, continue-on-failure → test that one
62+
failure does not prevent remaining symlinks from being created, failure count is
63+
correct, aggregate error is returned
64+
65+
Each phase becomes a group of test cases in `TestCreateLinks`.
66+
67+
---
68+
69+
## 3. Test Levels and Boundaries
70+
71+
### Unit Tests (`lnk/*_test.go`)
72+
73+
- Same package (`package lnk`) — access to exported and unexported functions
74+
- Direct function calls — no binary compilation
75+
- `t.TempDir()` for filesystem isolation — no shared state between tests
76+
- **Decision rule**: if a design spec defines a Go function, that function gets a unit test
77+
78+
### E2E Tests (`test/*_test.go`)
79+
80+
- Separate package (`package test`) — compiled binary execution via `exec.Command`
81+
- Tests CLI behavior from a user's perspective — flag parsing, exit codes, output content
82+
- Fixture-based environment via `setupTestEnv(t)`
83+
- **Decision rule**: if a design spec defines CLI usage or examples, those get e2e tests
84+
85+
### Boundary Table
86+
87+
| Spec | Unit tests | E2E tests |
88+
| ------------------- | --------------------------------------------------------------------- | --------------------------------------------------- |
89+
| `create.md` | `CreateLinks` phases, ignore filtering, idempotency | `lnk create .`, dry-run output, error exit codes |
90+
| `remove.md` | `RemoveLinks`, symlink verification, empty dir cleanup | `lnk remove .`, dry-run, already-removed cases |
91+
| `status.md` | `Status` categorization of active/broken links | `lnk status .`, piped output format |
92+
| `prune.md` | `PruneLinks`, broken-only filtering | `lnk prune .`, dry-run, nothing-to-prune cases |
93+
| `adopt.md` | `Adopt` validation, move, symlink creation, rollback | `lnk adopt . ~/.bashrc`, error cases |
94+
| `orphan.md` | `Orphan` validation, symlink removal, file move, rollback | `lnk orphan . ~/.bashrc`, error cases |
95+
| `config.md` | `LoadConfig`, `LoadIgnoreFile`, pattern merging | `--ignore` flag integration |
96+
| `error-handling.md` | Error constructors, `Error()` format, `GetErrorHint`, `errors.As/Is` | Error display format, hint presence, exit codes |
97+
| `output.md` | Print functions, color toggle, verbosity gating | Piped output prefixes, stderr separation |
98+
| `internals.md` | `FindManagedLinks`, `CreateSymlink`, `MoveFile`, `CleanEmptyDirs` | Covered indirectly through command e2e tests |
99+
| `cli.md` | `suggestCommand`, `levenshteinDistance` | Flag parsing, help output, version, unknown commands |
100+
101+
---
102+
103+
## 4. Conventions
104+
105+
### Test Naming
106+
107+
- Test functions: `Test<FunctionName>` (e.g., `TestCreateLinks`, `TestPathError`)
108+
- Subtests: lowercase descriptive phrases via `t.Run` (e.g., `"single source directory"`,
109+
`"path error with hint"`)
110+
111+
### Table-Driven Tests
112+
113+
All tests with two or more cases use the table-driven pattern:
114+
115+
```go
116+
tests := []struct {
117+
name string
118+
setup func(t *testing.T, tmpDir string)
119+
wantErr bool
120+
checkResult func(t *testing.T, tmpDir string)
121+
}{
122+
{
123+
name: "descriptive case name",
124+
setup: func(t *testing.T, tmpDir string) {
125+
// arrange
126+
},
127+
checkResult: func(t *testing.T, tmpDir string) {
128+
// assert
129+
},
130+
},
131+
}
132+
133+
for _, tt := range tests {
134+
t.Run(tt.name, func(t *testing.T) {
135+
tmpDir := t.TempDir()
136+
tt.setup(t, tmpDir)
137+
// act
138+
tt.checkResult(t, tmpDir)
139+
})
140+
}
141+
```
142+
143+
- `name string` is always the first field
144+
- Range variable is `tt`
145+
- Each case runs in a subtest via `t.Run(tt.name, ...)`
146+
147+
### Assertions
148+
149+
- All custom assertion functions call `t.Helper()` as the first line
150+
- `t.Errorf` for non-fatal assertions (test continues to check remaining conditions)
151+
- `t.Fatalf` only for setup failures that make the rest of the test meaningless
152+
- Never use `t.Fatal` inside a goroutine
153+
154+
---
155+
156+
## 5. Test Helpers
157+
158+
Reuse these helpers. Do not create duplicates.
159+
160+
### Unit Test Helpers (`lnk/testutil_test.go`)
161+
162+
| Helper | Signature | Use when |
163+
| ------------------- | ---------------------------------- | ---------------------------------------------- |
164+
| `CaptureOutput` | `(t, fn) string` | Testing stdout content from print functions |
165+
| `ContainsOutput` | `(t, output, expected...)` | Asserting output includes specific strings |
166+
| `NotContainsOutput` | `(t, output, notExpected...)` | Asserting output excludes specific strings |
167+
| `createTestFile` | `(t, path, content)` | Creating source files with parent directories |
168+
| `assertSymlink` | `(t, link, expectedTarget)` | Verifying symlink exists and points correctly |
169+
| `assertNotExists` | `(t, path)` | Verifying file or directory was removed |
170+
| `assertDirExists` | `(t, path)` | Verifying directory was created |
171+
172+
### E2E Test Helpers (`test/helpers_test.go`)
173+
174+
| Helper | Signature | Use when |
175+
| ------------------- | ---------------------------------- | ---------------------------------------------- |
176+
| `buildBinary` | `(t) string` | Called automatically by `runCommand` |
177+
| `runCommand` | `(t, args...) commandResult` | Running any `lnk` CLI invocation |
178+
| `setupTestEnv` | `(t) func()` | Setting up fixture-based test environment |
179+
| `assertContains` | `(t, output, expected...)` | Checking CLI output strings |
180+
| `assertNotContains` | `(t, output, notExpected...)` | Checking CLI output excludes strings |
181+
| `assertExitCode` | `(t, result, expected)` | Verifying exit code |
182+
| `assertSymlink` | `(t, linkPath, expectedTarget)` | Verifying symlink after CLI operation |
183+
| `assertNoSymlink` | `(t, path)` | Verifying symlink was removed |
184+
185+
`commandResult` has three fields: `Stdout`, `Stderr`, `ExitCode`.
186+
187+
---
188+
189+
## 6. Filesystem Setup
190+
191+
### Unit Tests
192+
193+
- Always use `t.TempDir()` — never `os.MkdirTemp` with manual cleanup
194+
- Create source and target directories inside the temp dir
195+
- Use `createTestFile(t, path, content)` for source files
196+
- Use `os.Symlink` directly when testing against pre-existing symlinks
197+
- Each test case gets its own temp dir (no shared mutable state)
198+
199+
### E2E Tests
200+
201+
- Call `setupTestEnv(t)` which runs `scripts/setup-testdata.sh` once per test session
202+
- Fixture structure: `test/testdata/dotfiles/home/` (source),
203+
`test/testdata/target/` (target, cleaned between tests)
204+
- Always `defer cleanup()` — the cleanup function removes test-created content
205+
from the target directory while preserving source fixtures
206+
207+
---
208+
209+
## 7. Output Testing
210+
211+
### Unit Tests
212+
213+
- Use `CaptureOutput(t, fn)` to capture stdout from print functions
214+
- Test both terminal and piped formats by toggling `ShouldSimplifyOutput` state
215+
- Test color by toggling `SetNoColor`
216+
- Test verbosity gating by toggling `SetVerbosity`
217+
218+
### E2E Tests
219+
220+
- E2E tests always run in piped mode (binary stdout goes to `bytes.Buffer`, not a TTY)
221+
- Assert piped-format prefixes: `"success "`, `"error: "`, `"warning: "`, `"skip "`,
222+
`"dry-run: "`
223+
- Use `result.Stdout` for normal output, `result.Stderr` for errors and warnings
224+
- Command headers are suppressed in piped mode — do not assert them in e2e tests
225+
226+
---
227+
228+
## 8. Error Testing
229+
230+
### Unit Tests
231+
232+
- Test `Error()` string format for each error type (`PathError`, `LinkError`,
233+
`ValidationError`, `HintedError`)
234+
- Test `Unwrap()` chain with `errors.Is` and `errors.As`
235+
- Test `GetErrorHint()` extraction from each error type
236+
- Test constructor functions produce correct field values
237+
- Test sentinel errors (`ErrNotSymlink`, `ErrAlreadyAdopted`) via `errors.Is`
238+
- Each row in the error-type mapping tables ([error-handling.md](error-handling.md)
239+
Section 11) maps to a test case in the corresponding operation's test file
240+
241+
### E2E Tests
242+
243+
- Assert `result.ExitCode` matches expected code (0, 1, or 2)
244+
- Assert `result.Stderr` contains the error message
245+
- Assert `result.Stderr` contains `"hint: "` when a hint is expected
246+
- Assert `result.Stdout` is empty on error (piped mode suppresses command headers)
247+
248+
---
249+
250+
## 9. Coverage
251+
252+
### Targets
253+
254+
| Component | Target |
255+
| ------------------------------------ | ------ |
256+
| Overall | 80% |
257+
| Error types and constructors | 100% |
258+
| Core operations (`Create`, `Remove`, etc.) | 90% |
259+
| Output functions | 80% |
260+
| CLI parsing (`main.go`) | Covered by e2e tests |
261+
262+
### Running Coverage
263+
264+
```bash
265+
make test-coverage # generates coverage.html
266+
```
267+
268+
Review uncovered lines before considering a feature complete.
269+
270+
---
271+
272+
## 10. Related Specifications
273+
274+
- [error-handling.md](error-handling.md) — Error type mapping tables that drive error test cases
275+
- [output.md](output.md) — Output function contracts that drive output test cases
276+
- [cli.md](cli.md) — CLI usage and exit codes that drive e2e test cases
277+
- [internals.md](internals.md) — Internal function signatures that drive unit test cases
278+
- [stdlib.md](stdlib.md) — Stdlib-only constraint (no external test frameworks)

0 commit comments

Comments
 (0)