Updated: 2026-03-28
# Run all tests
cd backend && go test ./... -count=1
# Run with verbose output
go test ./... -v -count=1
# Run tests for a specific package
go test ./internal/vex/ -v -count=1
# Run a single test by name
go test ./internal/vex/ -run TestNormalizeVulnID -v
# Run with race detection (used in CI)
go test ./... -count=1 -race
# Check coverage
go test ./... -coverprofile=coverage.out
go tool cover -html=coverage.outTests live next to the code they test, using Go's _test.go convention:
backend/internal/
├── config/
│ ├── config.go
│ └── config_test.go ← tests for config.Load()
├── github/
│ ├── purl.go
│ ├── purl_test.go ← tests for ExtractGitHubRepo (19 PURL patterns incl. well-known Go module mappings)
│ ├── resolver.go
│ └── resolver_test.go ← tests for Resolve, ResolveWithMetadata, cache, well-known overrides (httptest mock)
├── license/
│ ├── checker.go
│ ├── checker_test.go ← tests for Categorize, Check, LoadPolicy, etc.
│ └── exceptions.go
├── osv/
│ ├── client.go
│ └── client_test.go ← tests for QueryBatch (with httptest mock)
├── osvutil/
│ ├── osvutil.go
│ └── osvutil_test.go ← tests for ClassifySeverity, ParseCVSSScore, ExtractFixedVersion, ExtractAffectedVersions
├── repo/
│ ├── scanner.go
│ └── scanner_test.go ← tests for Scan (with t.TempDir())
├── s3/
│ ├── client.go
│ └── client_test.go ← tests for ClassifyKey, ParseURI, ObjectInfo
├── spdx/
│ ├── parser.go
│ └── parser_test.go ← tests for Parse (plain SPDX + in-toto attestation envelope)
└── vex/
├── parser.go
└── parser_test.go ← tests for Parse, normalizeVulnID
These packages contain only data types (structs) with no logic:
pkg/models/– SBOM, Vulnerability, VEXStatement structspkg/dto/– API response DTOs
| Package | Top-Level | Subtests | What's Covered |
|---|---|---|---|
config |
7 | 0 | Default values, custom env vars, S3 buckets JSON, single S3 bucket, shared S3 credentials, invalid S3 JSON, S3BucketNames |
github/purl |
2 | 22 | ExtractGitHubRepo (19 PURL patterns: golang github.com, subpath, pkg:github scheme, well-known Go module mappings for golang.org/x/crypto, gopkg.in/yaml.v3, go.uber.org/zap, k8s.io/client-go, oras.land/oras-go/v2 with version suffix stripping, dario.cat/mergo, go.yaml.in/yaml/v4, unknown non-github, npm, empty, qualifiers, fragments, missing repo, azure submodule, hamba v2), RepoKey (4 patterns) |
github/resolver |
11 | 0 | Resolve (happy path, cache hit, non-GitHub PURL, well-known mapping resolves golang.org/x/crypto), ResolveWithMetadata (archived repo, not-found, non-GitHub, cache hit), PreloadCache, PreloadMetadataCache, CacheEntries, MetadataCacheEntries |
license |
24 | 20 | Categorize (14 SPDX IDs incl. BSD-3-Clause, ISC, 0BSD, NOASSERTION, NONE), Check, CheckWithExceptions (blanket + package + prefix), LoadPolicy, LoadExceptions, LoadExceptionsWithFallback (4 scenarios), BuildIndex (empty, All CNCF Projects promoted to blanket, compound OR, compound AND), IsExempt substring matching (package+license, package-any), SplitLicenses (7 patterns), GoTempNamesFiltered, edge cases |
osv |
6 | 0 | Empty input, mock server, server error, context cancellation, no-vulns response, HydrateVulns cache |
osvutil |
5 | 35 | ClassifySeverity (16 CVSS scenarios incl. vector strings, database-specific fallback), ParseCVSSScore (8 inputs incl. vectors), ComputeCVSSv3BaseScore (4 scenarios), ExtractFixedVersion (4 scenarios), ExtractAffectedVersions (3 scenarios) |
repo |
5 | 0 | File scanning (SBOM + VEX detection), empty dir, nested dirs, SHA256 consistency, nonexistent dir |
s3 |
4 | 15 | ClassifyKey (9 patterns incl. _spdx.json, case-insensitive), ParseURI (6 patterns), BucketConfig defaults, ObjectInfo URI |
spdx |
8 | 7 | Full parse, in-toto attestation envelope unwrapping, invalid JSON, empty packages, deterministic SBOM ID, license fallback, GoTempModuleName, CleanPackageName (8 patterns) |
vex |
5 | 8 | Full parse, invalid JSON, empty doc, normalizeVulnID (9 URL patterns), URL-based vuln @id |
| Total | 77 | 107 | 184 test invocations |
// Test function: Test<FunctionName>_<Scenario>
func TestParse_InvalidJSON(t *testing.T) { ... }
func TestQueryBatch_ServerError(t *testing.T) { ... }
func TestScanner_Scan_EmptyDir(t *testing.T) { ... }
// Table-driven subtests: use t.Run()
func TestCategorize(t *testing.T) {
tests := []struct {
input string
expected Category
}{
{"MIT", CategoryPermissive},
{"GPL-3.0-only", CategoryCopyleft},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := Categorize(tt.input)
if got != tt.expected {
t.Errorf("Categorize(%q) = %q, want %q", tt.input, got, tt.expected)
}
})
}
}Used in: license/checker_test.go, vex/parser_test.go
func TestNormalizeVulnID(t *testing.T) {
tests := []struct {
input string
want string
}{
{"CVE-2024-1234", "CVE-2024-1234"},
{"https://pkg.go.dev/vuln/GO-2025-4188", "GO-2025-4188"},
{"", ""},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := normalizeVulnID(tt.input)
if got != tt.want {
t.Errorf("normalizeVulnID(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}Used in: spdx/parser_test.go, vex/parser_test.go
const testSPDXJSON = `{
"spdxVersion": "SPDX-2.3",
"name": "test-document",
...
}`
func TestParse(t *testing.T) {
result, err := Parse(strings.NewReader(testSPDXJSON), "test.spdx.json", "abc123")
if err != nil {
t.Fatalf("Parse() returned error: %v", err)
}
// Assert fields...
}Used in: osv/client_test.go
func TestQueryBatch_MockServer(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"results": [{"vulns": [...]}]}`))
}))
defer server.Close()
client := &Client{
baseURL: server.URL,
httpClient: server.Client(),
limiter: getGlobalLimiter(),
}
resp, err := client.QueryBatch(context.Background(), []string{"pkg:npm/test@1.0.0"})
// Assert...
}Used in: repo/scanner_test.go, license/checker_test.go
func TestScanner_Scan(t *testing.T) {
tmpDir := t.TempDir() // Automatically cleaned up after test
os.WriteFile(filepath.Join(tmpDir, "test.spdx.json"), []byte(`{...}`), 0644)
os.WriteFile(filepath.Join(tmpDir, "cve.openvex.json"), []byte(`{...}`), 0644)
scanner := NewScanner(tmpDir)
files, err := scanner.Scan()
// Assert file count, types, hashes...
}Used in: config/config_test.go
func TestLoad_CustomEnv(t *testing.T) {
t.Setenv("CLICKHOUSE_HOST", "ch-prod") // Automatically restored after test
t.Setenv("SKIP_OSV", "true")
cfg, err := Load()
// Assert config values...
}For each function, write tests for:
| Scenario | Example |
|---|---|
| Happy path | TestParse – valid SPDX JSON → correct result |
| Invalid input | TestParse_InvalidJSON – malformed JSON → error |
| Empty input | TestCheck_EmptyInput – nil slices → no panic, empty result |
| Edge cases | TestNormalizeVulnID – URLs, empty string, plain IDs |
| Error conditions | TestLoadPolicy_MissingFile – file not found → error |
| Determinism | TestParse_DeterministicSBOMID – same input → same output |
- No external dependencies. Tests must not require a running ClickHouse, network access, or Docker. Use
httptestfor HTTP,t.TempDir()for files,t.Setenv()for env vars. - No test order dependency. Each test must be self-contained and pass in isolation.
- Use
t.Fatalffor setup failures. If a precondition fails, stop immediately. Uset.Errorffor assertion failures (allows multiple failures per test). - Use
t.Run()for subtests. Table-driven tests must use subtests for clear output. - No
init()in test files. All setup happens inside the test function. - Prefer
strings.NewReaderover files for parser tests (faster, no I/O). - Race-safe. All tests must pass with
-raceflag.
Tests run automatically on every push/PR via GitHub Actions (.github/workflows/ci.yml):
- name: Test
working-directory: backend
run: go test ./... -count=1 -raceThe -race flag enables Go's race detector – this catches concurrent access bugs in the workers/queue code.
When adding a new feature, follow this checklist:
- Create
<package>_test.gonext to the source file - Write at minimum:
- One happy-path test
- One error/invalid-input test
- One edge-case test
- Run locally:
go test ./internal/<package>/ -v -count=1 - Check coverage:
go test ./internal/<package>/ -coverprofile=c.out && go tool cover -func=c.out - Run full suite:
go test ./... -count=1 -race - CI will enforce: all tests must pass before merge
The internal/clickhouse/ package (client, queue, insert, queries) contains no unit tests because it requires a running ClickHouse instance. Future work:
- Option A: Integration tests with testcontainers-go spinning up a ClickHouse container
- Option B: Interface-based mocking of the ClickHouse client for query logic tests
The cmd/ packages (main functions) are tested implicitly through integration via Docker Compose but have no unit tests. These are thin orchestration layers that wire together the tested internal packages.