Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,8 @@ jobs:
- name: Test
run: make test

- name: Integration test
run: make test-integration

- name: Check coverage
run: make check-coverage
24 changes: 14 additions & 10 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,16 @@ backup/
## Build and Test

```sh
make build # Build to dist/backup
make test # go test -race ./... -v
make lint # golangci-lint run ./...
make lint-fix # Auto-fix lint issues
make format # go fmt ./...
make tidy # gofmt -s + go mod tidy
make sanity-check # format + clean + tidy
make check-coverage # Fail if coverage < 90%
make report-coverage # Generate HTML coverage report
make build # Build to dist/backup
make test # go test -race ./... -v
make test-integration # go test -race -tags=integration ./... -v
make lint # golangci-lint run ./...
make lint-fix # Auto-fix lint issues
make format # go fmt ./...
make tidy # gofmt -s + go mod tidy
make sanity-check # format + clean + tidy
make check-coverage # Fail if coverage < 98%
make report-coverage # Generate HTML coverage report
```

## Testing Conventions
Expand All @@ -71,16 +72,19 @@ make report-coverage # Generate HTML coverage report
- Prefer table-driven tests for multiple input scenarios
- Use `afero.NewMemMapFs()` in tests — never hit the real filesystem
- Use `bytes.Buffer` or `io.Discard` for output capture in tests
- Integration tests use `//go:build integration` tag and run real rsync on temp directories
- CI enforces coverage threshold via `make check-coverage`

## CI Pipeline

CI runs on every push/PR to `main` (`.github/workflows/go.yml`):

1. Sanity check (format + clean + mod tidy)
2. Lint (golangci-lint)
3. Build
4. Test (with `-race` flag)
5. Coverage threshold enforcement (90%)
5. Integration test (with real rsync, `-tags=integration`)
6. Coverage threshold enforcement (98%)

## Conventions

Expand Down
17 changes: 9 additions & 8 deletions MOCKERY_INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,15 @@ The project uses `.mockery.yml` to control mock generation:

```yaml
all: false
dir: '{{.InterfaceDir}}/test'
dir: "{{.InterfaceDir}}/test"
filename: mock_{{.InterfaceName | lower}}_test.go
force-file-write: true
formatter: goimports
generate: true
include-auto-generated: false
log-level: info
structname: 'Mock{{.InterfaceName}}'
pkgname: 'internal_test'
structname: "Mock{{.InterfaceName}}"
pkgname: "internal_test"
recursive: false
template: testify
packages:
Expand All @@ -33,6 +33,7 @@ packages:
```

Key points:

- **Output directory**: `<InterfaceDir>/test/` (alongside other test files)
- **Filename**: `mock_<interface>_test.go`
- **Struct naming**: `Mock<Interface>` (e.g., `MockExec`, `MockJobCommand`)
Expand All @@ -41,10 +42,10 @@ Key points:

## Generated Mocks

| Mock | Source Interface | File |
|---|---|---|
| `MockExec` | `Exec` | `backup/internal/test/mock_exec_test.go` |
| `MockJobCommand` | `JobCommand` | `backup/internal/test/mock_jobcommand_test.go` |
| Mock | Source Interface | File |
| ---------------- | ---------------- | ---------------------------------------------- |
| `MockExec` | `Exec` | `backup/internal/test/mock_exec_test.go` |
| `MockJobCommand` | `JobCommand` | `backup/internal/test/mock_jobcommand_test.go` |

## Usage Examples

Expand Down Expand Up @@ -111,4 +112,4 @@ When interfaces change, regenerate with:
mockery
```

This updates all mocks according to `.mockery.yml`. Generated files are committed to the repository.
This updates all mocks according to `.mockery.yml`. Generated files are committed to the repository.
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ BUILD_CMD = CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -tags=prod
PACKAGE = ./backup/main.go
COVERAGE_THRESHOLD = 98

.PHONY: build clean test lint tidy checksums release sanity-check check-mod-tidy lint-config-check lint-fix format check-clean check-coverage
.PHONY: build clean test test-integration lint tidy checksums release sanity-check check-mod-tidy lint-config-check lint-fix format check-clean check-coverage

format:
go fmt ./...
Expand Down Expand Up @@ -36,6 +36,9 @@ sanity-check: format check-clean check-mod-tidy
test:
go test -race ./... -v

test-integration:
go test -race -tags=integration ./... -v

tidy:
gofmt -s -w .
go mod tidy
Expand Down
94 changes: 80 additions & 14 deletions TESTING_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ All tests use dependency injection — no global state mutation. Key patterns:
```
backup/
cmd/test/
commands_test.go # CLI integration tests (all commands)
root_test.go # Root command help output
commands_test.go # CLI command tests (all commands, stubbed exec)
integration_test.go # Integration tests with real rsync (build tag: integration)
root_test.go # Root command help output
internal/test/
check_test.go # CoverageChecker tests (afero-based)
config_test.go # Config loading, validation, Apply
Expand All @@ -30,14 +31,14 @@ backup/

## Dependency Injection Points

| Dependency | Interface/Type | Real | Test |
|---|---|---|---|
| Command execution | `internal.Exec` | `OsExec` | `MockExec` or `stubExec` |
| Job runner | `internal.JobCommand` | `ListCommand`, `SyncCommand`, `SimulateCommand` | `MockJobCommand` |
| Filesystem | `afero.Fs` | `afero.NewOsFs()` | `afero.NewMemMapFs()` |
| Output | `io.Writer` | `os.Stdout` / `cmd.OutOrStdout()` | `bytes.Buffer` |
| Logging | `*log.Logger` | File-backed logger | `log.New(&buf, "", 0)` |
| Time | `time.Time` | `time.Now()` | Fixed `time.Date(...)` |
| Dependency | Interface/Type | Real | Test |
| ----------------- | --------------------- | ----------------------------------------------- | ------------------------ |
| Command execution | `internal.Exec` | `OsExec` | `MockExec` or `stubExec` |
| Job runner | `internal.JobCommand` | `ListCommand`, `SyncCommand`, `SimulateCommand` | `MockJobCommand` |
| Filesystem | `afero.Fs` | `afero.NewOsFs()` | `afero.NewMemMapFs()` |
| Output | `io.Writer` | `os.Stdout` / `cmd.OutOrStdout()` | `bytes.Buffer` |
| Logging | `*log.Logger` | File-backed logger | `log.New(&buf, "", 0)` |
| Time | `time.Time` | `time.Now()` | Fixed `time.Date(...)` |

## Command-Level Tests (cmd/test/)

Expand Down Expand Up @@ -83,6 +84,7 @@ func TestRun_ValidConfig(t *testing.T) {
```

Three builder levels available:

- `BuildRootCommand()` — production defaults (real OS filesystem, real exec)
- `BuildRootCommandWithFs(fs)` — custom filesystem, real exec
- `BuildRootCommandWithDeps(fs, shell)` — full control for testing
Expand Down Expand Up @@ -143,12 +145,76 @@ func TestCreateMainLogger_DeterministicLogPath(t *testing.T) {
}
```

## Integration Tests

Integration tests live in `cmd/test/integration_test.go` behind the `//go:build integration` tag. They exercise the full CLI with **real rsync** against temp directories — no mocks or stubs.

### Build Tag

```go
//go:build integration
```

Tests are excluded from `make test` and `make check-coverage`. Run them separately:

```sh
make test-integration # go test -race -tags=integration ./... -v
```

### Design Principles

- **Real rsync** — uses `/usr/bin/rsync` via `BuildRootCommand()` (production defaults)
- **Real filesystem** — creates temp directories via `t.TempDir()`, cleaned up automatically
- **Reproducible** — each test sets up its own isolated source/target directory pair
- **No mocks** — validates actual rsync behavior (file transfer, deletion, exclusions)

### Scenarios Covered

| Category | Tests | What's Verified |
| -------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| **run — basic** | `BasicSync`, `IdempotentSync`, `PartialChanges`, `EmptySource`, `DeepHierarchy` | Files are synced correctly; re-sync is idempotent; only modified files transfer |
| **run — delete** | `DeleteRemovesExtraFiles`, `NoDeletePreservesExtraFiles` | `--delete` flag removes stale files; omitting it preserves them |
| **run — exclusions** | `Exclusions` | `--exclude` patterns prevent syncing of matching paths |
| **run — jobs** | `DisabledJobSkipped`, `MultipleJobs`, `MixedJobsSummary`, `VariableSubstitution` | Multi-job orchestration, enabled/disabled, `${var}` resolution |
| **simulate** | `NoChanges`, `ShowsChanges`, `SimulateThenRun` | Dry-run produces no side effects; subsequent run works normally |
| **list** | `ShowsCommands` | Prints rsync commands without executing them |
| **check-coverage** | `FullCoverage`, `IncompleteCoverage` | Coverage checker on real directory trees |
| **config** | `ConfigShow`, `ConfigValidate_Valid`, `ConfigValidate_OverlappingSources` | End-to-end config parsing and validation |
| **version** | `Version` | Real rsync version output |

### Example

```go
func TestIntegration_Run_BasicSync(t *testing.T) {
src, dst := setupDirs(t)
writeFile(t, filepath.Join(src, "hello.txt"), "hello world")

cfgPath := writeIntegrationConfig(t, `
sources:
- path: "`+src+`"
targets:
- path: "`+dst+`"
jobs:
- name: "basic"
source: "`+src+`/"
target: "`+dst+`/"
delete: false
`)

stdout, err := executeIntegrationCommand(t, "run", "--config", cfgPath)
require.NoError(t, err)
assert.Contains(t, stdout, "Status [basic]: SUCCESS")
assert.Equal(t, "hello world", readFileContent(t, filepath.Join(dst, "hello.txt")))
}
```

## Running Tests

```sh
make test # go test -race ./... -v
make check-coverage # Fail if coverage < 90%
make report-coverage # Generate HTML coverage report
make test # go test -race ./... -v (unit tests only)
make test-integration # go test -race -tags=integration ./... -v (includes integration)
make check-coverage # Fail if coverage < threshold (unit tests only)
make report-coverage # Generate HTML coverage report
```

## Key Principles
Expand All @@ -158,4 +224,4 @@ make report-coverage # Generate HTML coverage report
3. **Use `require` for errors, `assert` for values** — `require` stops the test on failure
4. **Table-driven tests** for multiple input/output scenarios
5. **Scope mocks to individual tests** — each test creates its own mock instance
6. **Defer cleanup** — `CreateMainLogger` returns a cleanup function; always `defer` it
6. **Defer cleanup** — `CreateMainLogger` returns a cleanup function; always `defer` it
Loading
Loading