|
| 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