feat(testing): add unified quality gates and coverage baseline
This commit is contained in:
93
docs/2026-05-30-TESTING_QUALITY_UPGRADE.md
Normal file
93
docs/2026-05-30-TESTING_QUALITY_UPGRADE.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# 2026-05-30 测试能力与质量保障增强
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
当前仓库虽然已经有:
|
||||||
|
|
||||||
|
- 单元测试
|
||||||
|
- SQLite 集成测试
|
||||||
|
- portal 资产检查
|
||||||
|
- real-host 验收脚本
|
||||||
|
|
||||||
|
但在测试治理上仍有两个明显短板:
|
||||||
|
|
||||||
|
1. **缺统一入口**
|
||||||
|
- 质量门禁主要依赖开发者手动记忆若干命令
|
||||||
|
- 缺少“一键执行 + 汇总结果 + 显式门槛”的脚本化入口
|
||||||
|
|
||||||
|
2. **缺覆盖率管理**
|
||||||
|
- 覆盖率门槛写在 `AGENTS.md`
|
||||||
|
- 但没有配置文件和脚本把门槛真正执行出来
|
||||||
|
- 低覆盖包不够显性,容易在后续迭代中悄悄回落
|
||||||
|
|
||||||
|
## 本次增强
|
||||||
|
|
||||||
|
### 1. 新增统一质量门禁脚本
|
||||||
|
|
||||||
|
- [scripts/test/verify_quality_gates.sh](/home/long/project/sub2api-cn-relay-manager/scripts/test/verify_quality_gates.sh)
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 统一执行:
|
||||||
|
- `gofmt -l .`
|
||||||
|
- `go vet ./...`
|
||||||
|
- `go test -cover ./internal/...`
|
||||||
|
- `go test ./tests/integration/... -count=1`
|
||||||
|
- 生成 coverage gate 报告
|
||||||
|
- 对低覆盖包给出显式 `warn`
|
||||||
|
- 对 core 包低于门槛时直接 `fail`
|
||||||
|
|
||||||
|
### 2. 新增覆盖率阈值配置
|
||||||
|
|
||||||
|
- [tests/quality/coverage_thresholds.tsv](/home/long/project/sub2api-cn-relay-manager/tests/quality/coverage_thresholds.tsv)
|
||||||
|
|
||||||
|
当前策略分两层:
|
||||||
|
|
||||||
|
- `core`
|
||||||
|
- 当前必须硬门槛通过
|
||||||
|
- 先覆盖最关键业务包:
|
||||||
|
- `internal/access`
|
||||||
|
- `internal/pack`
|
||||||
|
- `internal/provision`
|
||||||
|
- `watch`
|
||||||
|
- 先做显式非回归治理
|
||||||
|
- 对已有低覆盖或大体量包给出可见阈值,避免继续恶化
|
||||||
|
|
||||||
|
### 3. 补充脚本自检
|
||||||
|
|
||||||
|
- `scripts/test/test_real_host_scripts.sh` 新增了对 `verify_quality_gates.sh` 和阈值配置文件的静态回归
|
||||||
|
|
||||||
|
### 4. 提升低覆盖工具包测试
|
||||||
|
|
||||||
|
- [internal/overlay/executor_test.go](/home/long/project/sub2api-cn-relay-manager/internal/overlay/executor_test.go)
|
||||||
|
|
||||||
|
新增覆盖:
|
||||||
|
|
||||||
|
- nested output dir 拒绝
|
||||||
|
- missing patch 错误路径
|
||||||
|
- metadata 写入内容
|
||||||
|
- copyTree 对 `.git` 过滤
|
||||||
|
- symlink 保留
|
||||||
|
|
||||||
|
## 建议执行方式
|
||||||
|
|
||||||
|
后续每次提交前,优先跑:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash ./scripts/test/verify_quality_gates.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
如果本次还改了 portal 资产或 real-host 支撑脚本,再补:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash ./scripts/test/test_tksea_portal_assets.sh
|
||||||
|
bash ./scripts/test/test_real_host_scripts.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## 后续建议
|
||||||
|
|
||||||
|
下一阶段如果继续增强测试能力,优先做这三件事:
|
||||||
|
|
||||||
|
1. 给 `internal/app` 增加不依赖本地监听端口的 handler 级测试,先把 coverage 稳定抬过 `70%`
|
||||||
|
2. 把 `watch` 包分批提升为 `core`
|
||||||
|
3. 给 `verify_quality_gates.sh` 增加机器可读输出,例如 JSON 汇总,方便未来接 CI 或日报
|
||||||
@@ -2,6 +2,7 @@ package overlay
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -125,3 +126,85 @@ func TestFilterOverlaysRejectsMissingOverlayID(t *testing.T) {
|
|||||||
t.Fatalf("FilterOverlays() error = %v, want missing overlay detail", err)
|
t.Fatalf("FilterOverlays() error = %v, want missing overlay detail", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApplyRejectsNestedOutputDir(t *testing.T) {
|
||||||
|
sourceDir := t.TempDir()
|
||||||
|
if err := os.MkdirAll(filepath.Join(sourceDir, "backend"), 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := Apply(context.Background(), ApplyRequest{
|
||||||
|
PackDir: t.TempDir(),
|
||||||
|
SourceDir: sourceDir,
|
||||||
|
OutputDir: filepath.Join(sourceDir, "nested-output"),
|
||||||
|
Overlays: []pack.HostOverlay{{OverlayID: "sample", PatchPath: "overlays/sample.patch"}},
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "must not be nested inside source dir") {
|
||||||
|
t.Fatalf("Apply() error = %v, want nested output rejection", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyPatchFileRejectsMissingPatch(t *testing.T) {
|
||||||
|
err := applyPatchFile(context.Background(), t.TempDir(), filepath.Join(t.TempDir(), "missing.patch"))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "stat patch file") {
|
||||||
|
t.Fatalf("applyPatchFile() error = %v, want missing patch stat error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteMetadataIncludesSourceDirAndOverlays(t *testing.T) {
|
||||||
|
metadataPath := filepath.Join(t.TempDir(), metadataFileName)
|
||||||
|
overlays := []pack.HostOverlay{{OverlayID: "sample", PatchPath: "overlays/sample.patch"}}
|
||||||
|
if err := writeMetadata(metadataPath, "/tmp/source", overlays); err != nil {
|
||||||
|
t.Fatalf("writeMetadata() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := os.ReadFile(metadataPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile() error = %v", err)
|
||||||
|
}
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal(body, &decoded); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if got, _ := decoded["source_dir"].(string); got != "/tmp/source" {
|
||||||
|
t.Fatalf("source_dir = %q, want %q", got, "/tmp/source")
|
||||||
|
}
|
||||||
|
applied, ok := decoded["applied_overlays"].([]any)
|
||||||
|
if !ok || len(applied) != 1 {
|
||||||
|
t.Fatalf("applied_overlays = %#v, want one overlay", decoded["applied_overlays"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopyTreeSkipsGitAndPreservesSymlink(t *testing.T) {
|
||||||
|
sourceDir := t.TempDir()
|
||||||
|
outputDir := filepath.Join(t.TempDir(), "output")
|
||||||
|
if err := os.MkdirAll(filepath.Join(sourceDir, ".git", "objects"), 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll(.git) error = %v", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(sourceDir, "backend"), 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll(backend) error = %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(sourceDir, ".git", "config"), []byte("ignored"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile(.git/config) error = %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(sourceDir, "backend", "hello.txt"), []byte("hello\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile(hello.txt) error = %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(filepath.Join("backend", "hello.txt"), filepath.Join(sourceDir, "hello-link")); err != nil {
|
||||||
|
t.Fatalf("Symlink() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := copyTree(sourceDir, outputDir); err != nil {
|
||||||
|
t.Fatalf("copyTree() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(outputDir, ".git", "config")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("output .git/config error = %v, want not exist", err)
|
||||||
|
}
|
||||||
|
target, err := os.Readlink(filepath.Join(outputDir, "hello-link"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Readlink() error = %v", err)
|
||||||
|
}
|
||||||
|
if target != filepath.Join("backend", "hello.txt") {
|
||||||
|
t.Fatalf("symlink target = %q, want backend/hello.txt", target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
- 例如:
|
- 例如:
|
||||||
- `test_real_host_scripts.sh`
|
- `test_real_host_scripts.sh`
|
||||||
- `test_tksea_portal_assets.sh`
|
- `test_tksea_portal_assets.sh`
|
||||||
|
- `verify_quality_gates.sh`
|
||||||
|
|
||||||
## 放置规则
|
## 放置规则
|
||||||
|
|
||||||
@@ -38,6 +39,20 @@
|
|||||||
```bash
|
```bash
|
||||||
bash ./scripts/test/test_real_host_scripts.sh
|
bash ./scripts/test/test_real_host_scripts.sh
|
||||||
bash ./scripts/test/test_tksea_portal_assets.sh
|
bash ./scripts/test/test_tksea_portal_assets.sh
|
||||||
|
bash ./scripts/test/verify_quality_gates.sh
|
||||||
scripts/deploy/build_local_image.sh
|
scripts/deploy/build_local_image.sh
|
||||||
bash ./scripts/acceptance/real_host_acceptance.sh
|
bash ./scripts/acceptance/real_host_acceptance.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 统一质量门禁
|
||||||
|
|
||||||
|
`scripts/test/verify_quality_gates.sh` 是当前推荐的一键测试入口,职责是:
|
||||||
|
|
||||||
|
- 统一执行:
|
||||||
|
- `gofmt -l .`
|
||||||
|
- `go vet ./...`
|
||||||
|
- `go test -cover ./internal/...`
|
||||||
|
- `go test ./tests/integration/... -count=1`
|
||||||
|
- 读取 `tests/quality/coverage_thresholds.tsv`
|
||||||
|
- 输出 coverage gate 报告到临时目录
|
||||||
|
- 对 core 包覆盖率做硬门槛,对 watch 包做显式告警
|
||||||
|
|||||||
@@ -262,6 +262,24 @@ EOF
|
|||||||
assert_not_contains "$upstream_headers" "Authorization:"
|
assert_not_contains "$upstream_headers" "Authorization:"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
run_test_verify_quality_gates_script() {
|
||||||
|
local script threshold_file script_contents
|
||||||
|
script="$ROOT_DIR/scripts/test/verify_quality_gates.sh"
|
||||||
|
threshold_file="$ROOT_DIR/tests/quality/coverage_thresholds.tsv"
|
||||||
|
|
||||||
|
[[ -f "$script" ]] || fail "missing $script"
|
||||||
|
[[ -f "$threshold_file" ]] || fail "missing $threshold_file"
|
||||||
|
|
||||||
|
script_contents="$(cat "$script")"
|
||||||
|
assert_contains "$script_contents" "gofmt -l ."
|
||||||
|
assert_contains "$script_contents" "go vet ./..."
|
||||||
|
assert_contains "$script_contents" "go test -cover ./internal/..."
|
||||||
|
assert_contains "$script_contents" "go test ./tests/integration/... -count=1"
|
||||||
|
assert_contains "$script_contents" "Coverage Gate Report"
|
||||||
|
assert_contains "$script_contents" "tests/quality/coverage_thresholds.tsv"
|
||||||
|
assert_contains "$script_contents" "tier_by_package"
|
||||||
|
}
|
||||||
|
|
||||||
run_test_import_remote43_provider_subscription_prep() {
|
run_test_import_remote43_provider_subscription_prep() {
|
||||||
local tmpdir fakebin artifact_dir ssh_log summary_file pack_dir
|
local tmpdir fakebin artifact_dir ssh_log summary_file pack_dir
|
||||||
tmpdir="$(mktemp -d)"
|
tmpdir="$(mktemp -d)"
|
||||||
@@ -1041,5 +1059,6 @@ run_test_verify_route_data_plane_script
|
|||||||
run_test_verify_route_health_ui_script
|
run_test_verify_route_health_ui_script
|
||||||
run_test_remote43_patched_stack_renderers
|
run_test_remote43_patched_stack_renderers
|
||||||
run_test_setup_remote43_patched_stack_dry_run
|
run_test_setup_remote43_patched_stack_dry_run
|
||||||
|
run_test_verify_quality_gates_script
|
||||||
|
|
||||||
echo "PASS: real host script regression checks"
|
echo "PASS: real host script regression checks"
|
||||||
|
|||||||
136
scripts/test/verify_quality_gates.sh
Executable file
136
scripts/test/verify_quality_gates.sh
Executable file
@@ -0,0 +1,136 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
THRESHOLD_FILE="${THRESHOLD_FILE:-$ROOT_DIR/tests/quality/coverage_thresholds.tsv}"
|
||||||
|
OUTPUT_DIR="${OUTPUT_DIR:-$(mktemp -d "/tmp/sub2api-cn-relay-manager-test-quality-XXXXXX")}"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "FAIL: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo "==> $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ -f "$THRESHOLD_FILE" ]] || fail "missing coverage threshold file: $THRESHOLD_FILE"
|
||||||
|
mkdir -p "$OUTPUT_DIR"
|
||||||
|
|
||||||
|
GOFMT_LOG="$OUTPUT_DIR/gofmt.txt"
|
||||||
|
GOVET_LOG="$OUTPUT_DIR/govet.txt"
|
||||||
|
INTEGRATION_LOG="$OUTPUT_DIR/integration.txt"
|
||||||
|
COVERAGE_LOG="$OUTPUT_DIR/coverage.txt"
|
||||||
|
COVERAGE_REPORT="$OUTPUT_DIR/coverage-report.md"
|
||||||
|
|
||||||
|
log "quality gate output dir: $OUTPUT_DIR"
|
||||||
|
|
||||||
|
log "running gofmt check"
|
||||||
|
gofmt -l . | tee "$GOFMT_LOG"
|
||||||
|
if [[ -s "$GOFMT_LOG" ]]; then
|
||||||
|
fail "gofmt reported unformatted files"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "running go vet"
|
||||||
|
go vet ./... 2>&1 | tee "$GOVET_LOG"
|
||||||
|
|
||||||
|
log "running internal coverage"
|
||||||
|
go test -cover ./internal/... 2>&1 | tee "$COVERAGE_LOG"
|
||||||
|
|
||||||
|
log "running integration tests"
|
||||||
|
set +e
|
||||||
|
go test ./tests/integration/... -count=1 2>&1 | tee "$INTEGRATION_LOG"
|
||||||
|
integration_status=${PIPESTATUS[0]}
|
||||||
|
set -e
|
||||||
|
if [[ $integration_status -ne 0 ]]; then
|
||||||
|
if grep -Eq 'socket: operation not permitted|failed to listen on a port' "$INTEGRATION_LOG"; then
|
||||||
|
if [[ "${ALLOW_BLOCKED_INTEGRATION:-0}" == "1" ]]; then
|
||||||
|
log "integration tests blocked by socket-restricted environment; continuing because ALLOW_BLOCKED_INTEGRATION=1"
|
||||||
|
else
|
||||||
|
fail "integration tests blocked by current environment socket restrictions; rerun in an unrestricted environment or set ALLOW_BLOCKED_INTEGRATION=1 for local triage"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
fail "integration tests failed"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "evaluating coverage thresholds"
|
||||||
|
|
||||||
|
awk -v threshold_file="$THRESHOLD_FILE" -v report_file="$COVERAGE_REPORT" '
|
||||||
|
BEGIN {
|
||||||
|
FS = "\t"
|
||||||
|
while ((getline line < threshold_file) > 0) {
|
||||||
|
if (line ~ /^#/ || line ~ /^[[:space:]]*$/) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
split(line, fields, "\t")
|
||||||
|
package = fields[1]
|
||||||
|
tier = fields[2]
|
||||||
|
min_coverage = fields[3] + 0
|
||||||
|
note = fields[4]
|
||||||
|
expected[package] = min_coverage
|
||||||
|
tier_by_package[package] = tier
|
||||||
|
note_by_package[package] = note
|
||||||
|
}
|
||||||
|
close(threshold_file)
|
||||||
|
}
|
||||||
|
/^ok[[:space:]]+sub2api-cn-relay-manager\/internal\// {
|
||||||
|
package = $2
|
||||||
|
sub(/^sub2api-cn-relay-manager\//, "", package)
|
||||||
|
pct = -1
|
||||||
|
if (match($0, /coverage: [0-9.]+%/)) {
|
||||||
|
pct_text = substr($0, RSTART + 10, RLENGTH - 11)
|
||||||
|
pct = pct_text + 0
|
||||||
|
}
|
||||||
|
coverage[package] = pct
|
||||||
|
}
|
||||||
|
/^\?[[:space:]]+sub2api-cn-relay-manager\/internal\// {
|
||||||
|
package = $2
|
||||||
|
sub(/^sub2api-cn-relay-manager\//, "", package)
|
||||||
|
coverage[package] = -1
|
||||||
|
}
|
||||||
|
END {
|
||||||
|
print "# Coverage Gate Report" > report_file
|
||||||
|
print "" >> report_file
|
||||||
|
print "| Package | Tier | Threshold | Actual | Result | Note |" >> report_file
|
||||||
|
print "|---|---|---:|---:|---|---|" >> report_file
|
||||||
|
|
||||||
|
failures = 0
|
||||||
|
warnings = 0
|
||||||
|
|
||||||
|
for (package in expected) {
|
||||||
|
actual = (package in coverage) ? coverage[package] : -1
|
||||||
|
result = "missing"
|
||||||
|
if (actual >= 0 && actual + 1e-9 >= expected[package]) {
|
||||||
|
result = "pass"
|
||||||
|
} else if (tier_by_package[package] == "watch") {
|
||||||
|
result = "warn"
|
||||||
|
warnings++
|
||||||
|
} else {
|
||||||
|
result = "fail"
|
||||||
|
failures++
|
||||||
|
}
|
||||||
|
|
||||||
|
actual_text = (actual >= 0) ? sprintf("%.1f", actual) : "n/a"
|
||||||
|
print "| " package " | " tier_by_package[package] " | " sprintf("%.1f", expected[package]) " | " actual_text " | " result " | " note_by_package[package] " |" >> report_file
|
||||||
|
|
||||||
|
if (result == "fail") {
|
||||||
|
printf("FAIL coverage: %s actual=%s threshold=%.1f\n", package, actual_text, expected[package]) > "/dev/stderr"
|
||||||
|
} else if (result == "warn") {
|
||||||
|
printf("WARN coverage: %s actual=%s threshold=%.1f\n", package, actual_text, expected[package]) > "/dev/stderr"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print "" >> report_file
|
||||||
|
print "- Warnings: " warnings >> report_file
|
||||||
|
print "- Failures: " failures >> report_file
|
||||||
|
|
||||||
|
if (failures > 0) {
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
' "$COVERAGE_LOG"
|
||||||
|
|
||||||
|
log "coverage report: $COVERAGE_REPORT"
|
||||||
|
cat "$COVERAGE_REPORT"
|
||||||
|
log "quality gates passed"
|
||||||
8
tests/quality/coverage_thresholds.tsv
Normal file
8
tests/quality/coverage_thresholds.tsv
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# package tier min_coverage note
|
||||||
|
internal/access core 70.0 core access closure logic
|
||||||
|
internal/pack core 70.0 pack loading and validation
|
||||||
|
internal/provision core 70.0 provider import orchestration
|
||||||
|
internal/app watch 69.5 large HTTP surface; keep explicit non-regression until more handler tests land
|
||||||
|
internal/overlay watch 70.0 utility package should stay above a healthy baseline
|
||||||
|
internal/routing watch 70.0 route resolve and sticky runtime should not regress
|
||||||
|
internal/store/sqlite watch 75.0 sqlite repo layer is high leverage and should stay well-covered
|
||||||
|
Reference in New Issue
Block a user