This document explains how the test suite is organised, what each group of tests covers, how to run tests, and — most importantly — how to add new tests when the code changes.
- How to run the tests
- Test file location and package choice
- Test helpers
- Test groups and what they cover
- How to add a new unit test
- How to add a new integration test
- Working with time in tests
- Test naming convention
- What is not tested and why
# Run all tests once (recommended)
go test -count=1 ./...
# Verbose: show each individual test name and PASS/FAIL
go test -v -count=1 ./...
# Run a single test by name (partial match, case-sensitive)
go test -v -count=1 -run TestCleanupFullBackups ./cmd/backup-cleanup/
# Run all tests whose names contain "Weekly"
go test -v -count=1 -run Weekly ./cmd/backup-cleanup/
# Using make (runs go test ./...)
make test-count=1 disables the Go test cache so every run re-executes the tests. This is important for tests that create files on disk, because a cached "PASS" result might hide a regression.
| File | Package | Reason |
|---|---|---|
cmd/backup-cleanup/main.go |
package main |
Application code |
cmd/backup-cleanup/main_test.go |
package main |
Tests (same package) |
Tests are in the same package as the code (package main, not package main_test). This is called "white-box testing" in Go. It allows tests to call unexported (lowercase) functions directly:
// Works because both files are in package main:
got := weekNumberMondayFirst(someDate)
got := extractDateFromFilename("/path/to/file.bak")
got := getWeekdayNumber("Sunday")If the tests were in package main_test (the Go "black-box" style), only exported symbols starting with a capital letter would be accessible — which would either force everything to be exported, or require a large exported test facade.
All helpers are defined at the top of main_test.go, before the first test function.
Returns midnight of the given time in the local timezone. Used to create dates that are comparable to the dates embedded in FULL backup filenames (which also have midnight as their time component after parseYYYYMMDDLocal).
Returns midnight of n calendar days before today.
file := makeFullBakFile(t, dir, daysAgo(3)) // file dated 3 days ago
file := makeFullBakFile(t, dir, daysAgo(400)) // file dated ~13 months agoCreates an empty FULL .bak file in dir with the date embedded in the filename:
testdb_YYYYMMDD_000000.bak
Returns the absolute path.
path := makeFullBakFile(t, dir, daysAgo(2))
// Creates: /tmp/.../testdb_20260514_000000.bakCreates an empty file at path and sets its modification time to mtime. Used for DIFF and LOG backup tests where retention is based on mtime, not the filename.
old := makeFileWithMtime(t, filepath.Join(diffDir, "old.bak"), daysAgo(30))
new := makeFileWithMtime(t, filepath.Join(diffDir, "new.bak"), daysAgo(1))Constructs an App with the given Config and zeroed Totals. This is the standard way to set up the subject-under-test for all App method tests.
app := newTestApp(Config{
FullDailyRetentionDays: 7,
FullWeeklyRetentionWeeks: 4,
FullWeeklyDay: "Sunday",
FullMonthlyRetentionMonths: 12,
})Returns a Config pre-filled with the default GFS retention values. Use this when you want a standard FULL backup configuration without listing every field.
Fails the test if path does not exist on disk.
assertExists(t, weeklyFile, "weekly anchor backup")Fails the test if path still exists on disk (i.e., was not deleted when it should have been).
assertNotExists(t, oldFile, "old FULL backup outside all retention windows")The tests are divided into sections by a comment header like:
// ---------------------------------------------------------------------------
// cleanupFullBackups
// ---------------------------------------------------------------------------Verifies that weekday names ("Sunday", "sun", "Monday", etc.) map to the correct numbers following the GNU date +%w convention (0 = Sunday, 6 = Saturday). An unknown name must return 0 (Sunday), matching the Bash case fallback.
Verifies that weekNumberMondayFirst reproduces GNU date +%W output for known dates:
2024-01-01(Monday) → week 12025-01-01(Wednesday before first Monday) → week 0- Year-boundary cases (Jan 1 on Sunday, Jan 1 on Monday)
Verifies date parsing for valid input and rejects strings that are too short, too long, or have out-of-range month/day values.
Verifies the two-step date extraction strategy:
- Regex match extracts
YYYYMMDDfrom a properly-named.bakfile. - Fallback to file
mtimefor files whose names don't match the pattern. - Empty string returned for a missing file with a non-matching name.
Verifies the config helper functions:
- Empty string uses the supplied default.
- Non-empty string is returned as-is (or parsed as int).
- Non-numeric strings are rejected.
- Negative integers are rejected.
- Zero is accepted (disables the retention tier).
Verifies that JSON config parsing:
- Accepts strings, numbers, and booleans.
- Converts numeric values into decimal strings for downstream integer parsing.
- Ignores nested/complex values.
- Returns a clear error for invalid JSON.
Verifies that a KEY=value slice is correctly converted to a map:
- Values may contain additional
=signs (only the first is the separator). - Entries without
=are silently skipped. - Empty values (
KEY=) produce an empty-string map entry.
Tests the exclusion pattern matching:
- No patterns → never excluded.
- Single pattern → excluded when path contains it.
- Multiple space-separated patterns → excluded when any matches.
Low-level filesystem helpers:
fileExistsreturns false for directories.dirExistsreturns false for regular files.touchcreates a new file and updates an existing file's mtime.
- Normal deletion removes the file.
ErrNotExistlogs a warning but does not return an error.- Dry-run mode does not actually delete.
Coverage includes:
- Default values applied when no config and no env vars.
BACKUP_PATHenv var overrides the default.--backup-pathCLI flag overrides everything.--dry-runsetsDryRun = 1.--debugsetsDebug = 1.--versionsetsexitNow = true,exitCode = 0.-h/--helpsetsexitNow = true,exitCode = 0.- Unknown flag sets
exitNow = true,exitCode = 1. - Missing value for
--backup-pathreturns an error. - Invalid and negative integer config values fail early.
DRY_RUN=1in env/config does not enable dry-run unless--dry-runis passed.- JSON config files are loaded and can be overridden by CLI flags.
- Shell config sourcing (
.conf) is exercised vialoadConfigAndParseArgs; the test skips whenbashis unavailable.
Ten tests that create real files on disk in a t.TempDir() and verify which files survive after cleanupFullBackups runs:
| Test | Scenario |
|---|---|
_KeepsDailyFiles |
Files within daily window are kept |
_DeletesOldDailyFiles |
Files outside daily window are deleted |
_KeepsWeeklyAnchor |
File on FULL_WEEKLY_DAY within weekly window is kept |
_DeletesNonAnchorWeekly |
File on a non-anchor weekday within weekly window is deleted |
_KeepsMonthlyOldest |
Oldest file per month within monthly window is kept |
_DeletesNewerMonthly |
Newer file in the same month is deleted |
_ExcludedFilesNotDeleted |
Files matching EXCLUDE_PATTERNS survive |
_DryRunKeepsAll |
Dry-run mode deletes nothing |
_ZeroDailyRetention |
FULL_DAILY_RETENTION_DAYS=0 disables the daily tier |
_MultipleFilesInWindow |
Multiple files in the daily window are all kept |
Four and three tests, respectively, verifying mtime-based age deletion:
- Old files (beyond retention) are deleted.
- New files (within retention) are kept.
- Dry-run mode keeps all files.
.trnextension (LOG backups) is correctly handled.
Five tests that create a multi-database directory tree:
- Only directories are processed (files at the root level are skipped).
- A database that has already been processed (
.cleanup_processedmarker exists) is skipped. - Markers are removed at the end of the run.
DatabasesProcessedcounter is incremented correctly.- Non-existent
BackupPathreturns an error.
Four end-to-end tests that build a complete backup tree on disk:
CLEANUP_ENABLED=0skips all processing.- A full tree with old and new files produces correct delete and keep counts.
- Dry-run produces correct would-be-delete counts without touching files.
- A completely missing
BackupPathreturns an error.
A unit test tests a single function in isolation. Here is a worked example: adding a test for a new helper called sanitizeLogTag.
Find the section comment for the function you are testing, or add a new one:
// ---------------------------------------------------------------------------
// sanitizeLogTag
// ---------------------------------------------------------------------------func TestSanitizeLogTag_ReplacesSpaces(t *testing.T) {
got := sanitizeLogTag("my tag")
if got != "my-tag" {
t.Fatalf("got %q, want %q", got, "my-tag")
}
}
func TestSanitizeLogTag_EmptyReturnsDefault(t *testing.T) {
got := sanitizeLogTag("")
if got != "backup-cleanup" {
t.Fatalf("got %q, want %q", got, "backup-cleanup")
}
}go test -v -count=1 -run TestSanitizeLogTag ./cmd/backup-cleanup/- One test per logical case (happy path, empty input, error input, boundary value).
- Test name describes the scenario:
TestFunctionName_Scenario. - Use
t.Fatalf(stops the test immediately) for preconditions, andt.Errorf(continues) for independent assertions. - No global state is modified.
- No real filesystem access unless the function under test requires it.
An integration test creates real files on disk and calls an App method that reads and deletes them. The pattern is consistent across all integration tests in the file.
Here is a template for a new cleanupDiffBackups scenario:
func TestCleanupDiffBackups_ExcludedFileIsKept(t *testing.T) {
// 1. Create a temp directory that acts as the database DIFF folder.
dir := t.TempDir()
// 2. Create files with controlled mtimes using the helper.
old := makeFileWithMtime(t,
filepath.Join(dir, "excluded_old.bak"),
daysAgo(30), // older than the retention window
)
// 3. Build an App with a Config that matches the test scenario.
app := newTestApp(Config{
DiffRetentionDays: 14,
ExcludePatterns: "excluded_",
})
// 4. Call the method under test.
if err := app.cleanupDiffBackups(dir); err != nil {
t.Fatalf("unexpected error: %v", err)
}
// 5. Assert expected file system state.
assertExists(t, old, "excluded file should survive despite age")
}- Always use
t.TempDir()— Go automatically deletes it when the test finishes. - Set
mtimeexplicitly withmakeFileWithMtime. Never rely ontime.Now()directly in a test; the test would become fragile as retention windows approach the file age. - Keep the
Configminimal: only set the fields relevant to the scenario. - Add
t.Helper()to any helper you introduce so that failure line numbers point to the test, not the helper.
Retention logic compares file ages against time.Now(). If a test creates a file with mtime = daysAgo(8) and the retention is 7 days, the test will always work. But if the retention were 7 days and the file were dated exactly 7 days ago, the test might flip between pass and fail depending on the time of day.
- Always use
daysAgo(n)to create file dates. The helper truncates to midnight, making the boundary predictable. - Keep a clear margin from the boundary. If the retention is 7 days, use
daysAgo(3)for "inside" anddaysAgo(14)for "outside". Never usedaysAgo(7)when the boundary is exactly 7 days. - For FULL backup tests, file dates come from the filename (
YYYYMMDD), not from the file'smtime. UsemakeFullBakFile(t, dir, daysAgo(n))to embed the date. - For DIFF/LOG tests, file dates come from
mtime. UsemakeFileWithMtime(t, path, daysAgo(n)).
The GFS monthly window is FULL_MONTHLY_RETENTION_MONTHS × 30 × 86400. At 12 months, that is 360 days. If you create a "very old" test file with daysAgo(100), it will still be within the 360-day monthly window and will be kept — appearing to be a bug. Use daysAgo(400) or more to ensure a file is outside all three GFS tiers.
All test functions follow the pattern:
Test<FunctionOrType>_<Scenario>
Examples:
TestCleanupFullBackups_KeepsDailyFiles
TestCleanupFullBackups_DeletesOldDailyFiles
TestLoadConfigAndParseArgs_BackupPathFromEnv
TestLoadConfigAndParseArgs_VersionFlagExitsZero
TestIntValueOrDefault_NegativeRejected
TestFindAndProcessBackups_SkipsProcessedMarker
This makes -run filtering intuitive:
# All tests for cleanupFullBackups:
go test -v -run TestCleanupFullBackups ./cmd/backup-cleanup/
# All tests for config loading:
go test -v -run TestLoadConfigAndParseArgs ./cmd/backup-cleanup/This function shells out to bash. It is exercised through loadConfigAndParseArgs with a temporary .conf file, and the test skips gracefully when bash is not installed.
The send-pulse external command is absent from the development and CI environment. The code already handles its absence gracefully (exec.LookPath → skip). Tests verify that the cleanup logic runs correctly; verifying the telemetry calls would require either mocking exec.Command (complex) or installing send-pulse (environment-dependent).
Syslog writes go to the system's log daemon. Verifying exact syslog messages in tests would require either intercepting the syslog socket or reading the system log file, which is platform-specific. The log content is verified informally during development with --debug.
Integration tests create files in t.TempDir(), not in /mnt/backup01/remote. Running against a real backup tree in CI would be both slow and dangerous.