Files
certctl/docs/test-gap-prompt.md
T
shankar0123 03472072b8 test + docs: close 12 test gaps (~250 new tests) and expand testing guide to 34 parts
Implements all P0-P2 test gaps from docs/test-gap-prompt.md:
- Deployment service tests (20), target service tests (18), scheduler tests (8)
- Agent binary tests (48), CSR renewal tests (8), short-lived cert tests (7)
- Domain model tests (25), context cancellation tests (9), concurrency tests (7)
- Handler negative-path tests (23 across 5 files)
- Frontend error handling tests (86) and API client tests (7)

Expands testing-guide.md from 28 to 34 parts covering certificate export,
S/MIME/EKU, OCSP/DER CRL, body size limits, Apache/HAProxy connectors,
and sub-CA mode. Fixes stale profile count (4->5) and updates sign-off table.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 17:57:25 -04:00

23 KiB

certctl Test Gap Attack Prompt

Purpose: Self-contained prompt for a future Claude session to systematically close all identified test gaps. Copy this entire document into a new session along with CLAUDE.md.

Estimated effort: 250-350 new test functions across 12-15 new/modified test files.


Context

You are working on certctl, a self-hosted certificate lifecycle platform. The project has ~1100 tests but a comprehensive audit identified 12 gaps across 4 priority tiers. Your job is to close ALL of them in order (P0 first, then P1, then P2). After each file you create or modify, run the specific test file to verify it passes, then run go vet ./... to catch issues early.

Key conventions:

  • Package-level tests (e.g., package service not package service_test) so you can access unexported fields
  • Mock repositories use function-field injection pattern (see internal/service/testutil_test.go for all mocks)
  • Mocks available: mockCertRepo, mockJobRepo, mockNotifRepo, mockAuditRepo, mockPolicyRepo, mockRenewalPolicyRepo, mockAgentRepo, mockTargetRepo, mockIssuerConnector, mockIssuerRepository, mockRevocationRepo, mockNotifier
  • Constructor helpers: newMockCertificateRepository(), newMockJobRepository(), etc.
  • Test naming: TestServiceName_MethodName_Scenario (e.g., TestDeploymentService_CreateDeploymentJobs_Success)
  • All tests use context.Background() unless testing cancellation
  • The generateID(prefix) function exists in the service package for creating IDs

P0-1: internal/service/deployment_test.go (NEW FILE)

File to test: internal/service/deployment.go

Create internal/service/deployment_test.go in package service.

DeploymentService struct dependencies:

type DeploymentService struct {
    jobRepo         repository.JobRepository      // mockJobRepo
    targetRepo      repository.TargetRepository    // mockTargetRepo
    agentRepo       repository.AgentRepository     // mockAgentRepo
    certRepo        repository.CertificateRepository // mockCertRepo
    auditService    *AuditService                  // real AuditService with mockAuditRepo
    notificationSvc *NotificationService           // real NotificationService with mockNotifRepo + mockNotifier
}

Setup helper:

func newTestDeploymentService() (*DeploymentService, *mockJobRepo, *mockTargetRepo, *mockAgentRepo, *mockCertRepo, *mockAuditRepo) {
    jobRepo := newMockJobRepository()
    targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
    agentRepo := newMockAgentRepository()
    certRepo := newMockCertificateRepository()
    auditRepo := newMockAuditRepository()
    auditSvc := NewAuditService(auditRepo)
    notifRepo := newMockNotificationRepository()
    notifier := newMockNotifier()
    notifSvc := NewNotificationService(notifRepo, auditSvc)
    notifSvc.RegisterNotifier(notifier)

    svc := NewDeploymentService(jobRepo, targetRepo, agentRepo, certRepo, auditSvc, notifSvc)
    return svc, jobRepo, targetRepo, agentRepo, certRepo, auditRepo
}

Required tests (~20 functions):

CreateDeploymentJobs:

  1. TestDeploymentService_CreateDeploymentJobs_Success — 2 targets for cert, verify 2 jobs created with correct CertificateID, Type=Deployment, Status=Pending, TargetID set
  2. TestDeploymentService_CreateDeploymentJobs_NoTargets — empty targets list, expect error "no targets found"
  3. TestDeploymentService_CreateDeploymentJobs_TargetListError — targetRepo.ListByCertErr set, expect wrapped error
  4. TestDeploymentService_CreateDeploymentJobs_AllJobCreationsFail — jobRepo.CreateErr set, expect error "failed to create any deployment jobs"
  5. TestDeploymentService_CreateDeploymentJobs_PartialFailure — first job create fails (use a counter-based mock or accept that current mock fails all), verify at least error handling
  6. TestDeploymentService_CreateDeploymentJobs_AuditEvent — verify auditRepo.Events contains "deployment_jobs_created" event with target_count and job_count

ProcessDeploymentJob: 7. TestDeploymentService_ProcessDeploymentJob_Success — job with TargetID, target has AgentID, agent has recent heartbeat. Verify job status updated to Running, audit event recorded 8. TestDeploymentService_ProcessDeploymentJob_CertNotFound — certRepo.GetErr set, verify job marked Failed 9. TestDeploymentService_ProcessDeploymentJob_NoTargetID — job.TargetID is nil, verify job marked Failed with "target_id not found" 10. TestDeploymentService_ProcessDeploymentJob_TargetNotFound — targetRepo.GetErr set, verify job marked Failed 11. TestDeploymentService_ProcessDeploymentJob_AgentNotFound — agentRepo.GetErr set, verify job marked Failed 12. TestDeploymentService_ProcessDeploymentJob_AgentOffline — agent.LastHeartbeatAt is 10 minutes ago, verify job marked Failed with "agent is offline", notification sent

ValidateDeployment: 13. TestDeploymentService_ValidateDeployment_Completed — deployment job exists with Status=Completed, expect (true, nil) 14. TestDeploymentService_ValidateDeployment_Failed — deployment job with Status=Failed and LastError, expect (false, error with message) 15. TestDeploymentService_ValidateDeployment_InProgress — deployment job with Status=Running, expect (false, "deployment in progress") 16. TestDeploymentService_ValidateDeployment_NoJob — no matching deployment job, expect (false, "no deployment job found") 17. TestDeploymentService_ValidateDeployment_ListError — jobRepo returns error

MarkDeploymentComplete: 18. TestDeploymentService_MarkDeploymentComplete_Success — verify job status -> Completed, notification sent (success=true), audit event 19. TestDeploymentService_MarkDeploymentComplete_JobNotFound — jobRepo.GetErr set 20. TestDeploymentService_MarkDeploymentComplete_NoTargetID — job.TargetID is nil, still completes without notification

MarkDeploymentFailed: 21. TestDeploymentService_MarkDeploymentFailed_Success — verify job status -> Failed, error message stored, notification sent (success=false), audit event 22. TestDeploymentService_MarkDeploymentFailed_JobNotFound — jobRepo.GetErr set


P0-2: internal/service/target_test.go (NEW FILE)

File to test: internal/service/target.go

Setup:

func newTestTargetService() (*TargetService, *mockTargetRepo, *mockAuditRepo) {
    targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
    auditRepo := newMockAuditRepository()
    auditSvc := NewAuditService(auditRepo)
    return NewTargetService(targetRepo, auditSvc), targetRepo, auditRepo
}

Required tests (~15 functions):

Context-aware methods (List, Get, Create, Update, Delete):

  1. TestTargetService_List_Success — 3 targets, page=1 perPage=2, expect 2 returned with total=3
  2. TestTargetService_List_DefaultPagination — page=0 perPage=0, expect defaults to 1/50
  3. TestTargetService_List_EmptyPage — page=2 perPage=10 with only 3 targets, expect empty slice, total=3
  4. TestTargetService_List_RepoError — ListErr set
  5. TestTargetService_Get_Success — target exists
  6. TestTargetService_Get_NotFound — target doesn't exist
  7. TestTargetService_Create_Success — verify target stored, ID generated, timestamps set, audit event
  8. TestTargetService_Create_MissingName — empty name, expect error
  9. TestTargetService_Create_RepoError — CreateErr set
  10. TestTargetService_Update_Success — verify target updated, audit event
  11. TestTargetService_Update_MissingName — empty name, expect error
  12. TestTargetService_Delete_Success — verify target removed, audit event
  13. TestTargetService_Delete_RepoError — DeleteErr set

Legacy handler interface methods: 14. TestTargetService_ListTargets_Success — verify returns dereferenced targets 15. TestTargetService_GetTarget_Success 16. TestTargetService_CreateTarget_Success — verify ID generation 17. TestTargetService_UpdateTarget_Success 18. TestTargetService_DeleteTarget_Success


P0-3: Scheduler Loop Execution Tests

File to modify: internal/scheduler/scheduler_test.go

The existing tests cover idempotency and graceful shutdown. Add tests that verify each loop actually calls its service method.

Required tests (~8 functions):

  1. TestSchedulerRenewalLoopCallsService — start scheduler with 50ms interval, wait 150ms, verify renewalMock.callCount >= 1
  2. TestSchedulerJobProcessorLoopCallsService — same pattern for jobMock
  3. TestSchedulerAgentHealthCheckLoopCallsService — same for agentMock
  4. TestSchedulerNotificationLoopCallsService — same for notificationMock
  5. TestSchedulerNetworkScanLoopCallsService — same for networkMock
  6. TestSchedulerShortLivedExpiryLoopCallsService — verify ExpireShortLivedCertificates is called (need to add callCount tracking to mockRenewalService.ExpireShortLivedCertificates)
  7. TestSchedulerLoopErrorRecovery — set shouldError=true on renewalMock, verify scheduler continues (doesn't crash), subsequent calls still happen
  8. TestSchedulerLoopContextCancellation — cancel context mid-execution, verify no panics, WaitForCompletion succeeds

Note: You'll need to add expireCallCount and expireCallTimes fields to mockRenewalService and track calls in ExpireShortLivedCertificates.


P0-4: Agent Binary Tests

File to create: cmd/agent/agent_test.go (NEW FILE, package main)

This is the hardest gap. The agent binary's methods (executeCSRJob, executeDeploymentJob, heartbeat loop, discovery loop) need a mock HTTP server.

Setup:

func newTestServer(t *testing.T) *httptest.Server {
    mux := http.NewServeMux()
    // Register mock endpoints
    mux.HandleFunc("/api/v1/agents/", func(w http.ResponseWriter, r *http.Request) {
        // Handle heartbeat (POST /agents/{id}/heartbeat), work (GET /agents/{id}/work),
        // CSR submission (POST /agents/{id}/csr), job status (POST /agents/{id}/jobs/{job_id}/status),
        // discoveries (POST /agents/{id}/discoveries)
    })
    mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
        json.NewEncoder(w).Encode(map[string]string{"status": "healthy"})
    })
    return httptest.NewServer(mux)
}

Required tests (~10 functions):

  1. TestAgentHeartbeat_Success — mock server returns 200, verify request has correct headers
  2. TestAgentHeartbeat_ServerDown — connection refused, verify error handling (no panic)
  3. TestAgentCSRGeneration — verify ECDSA P-256 key generation, CSR contains correct CN and SANs
  4. TestAgentCSRGeneration_EmailSAN — verify email SANs route to EmailAddresses (not DNSNames)
  5. TestAgentWorkPolling_NoWork — server returns empty work list
  6. TestAgentWorkPolling_DeploymentJob — server returns deployment work item
  7. TestAgentWorkPolling_CSRJob — server returns AwaitingCSR work item
  8. TestAgentKeyStorage — verify keys written to temp dir with 0600 permissions
  9. TestAgentDiscoveryScan — scan a temp directory with a test PEM file, verify correct extraction
  10. TestAgentDiscoveryScan_EmptyDir — scan empty directory, verify empty results (no error)

Important: The agent code uses global variables and main() package patterns. You may need to extract testable functions or use TestMain for setup. If the agent's methods are on a struct, mock the HTTP client. If they're standalone functions, use httptest.


P1-1: CompleteAgentCSRRenewal Tests

File to modify: internal/service/renewal_test.go

Required tests (~8 functions):

The method signature is:

func (s *RenewalService) CompleteAgentCSRRenewal(ctx context.Context, job *domain.Job, cert *domain.ManagedCertificate, csrPEM string) error

You need a RenewalService with: certRepo, jobRepo, auditService, notificationSvc, issuerConnector (mock), profileRepo (mock), keygenMode="agent".

  1. TestCompleteAgentCSRRenewal_Success — valid job (AwaitingCSR), valid cert, valid CSR PEM. Verify: issuer.IssueCertificate called, cert version created, job status -> Completed, deployment jobs created
  2. TestCompleteAgentCSRRenewal_IssuerError — issuerConnector.Err set, verify job status -> Failed
  3. TestCompleteAgentCSRRenewal_InvalidCSR — garbage CSR PEM, verify error
  4. TestCompleteAgentCSRRenewal_WithEKUs — cert has certificate_profile_id, profile has allowed_ekus=["emailProtection"], verify EKUs forwarded to issuer
  5. TestCompleteAgentCSRRenewal_NoProfile — cert has no profile ID, verify default EKUs (nil)
  6. TestCompleteAgentCSRRenewal_CreateVersionError — certRepo.CreateVersionErr set
  7. TestCompleteAgentCSRRenewal_AuditRecorded — verify audit event with correct details
  8. TestCompleteAgentCSRRenewal_DeploymentJobsCreated — after successful signing, verify deployment jobs exist in jobRepo

Note: You'll need a mockProfileRepo if one doesn't exist in testutil_test.go. Check if internal/repository/interfaces.go has ProfileRepository and create a mock.


P1-2: ExpireShortLivedCertificates Tests

File to modify: internal/service/renewal_test.go

func (s *RenewalService) ExpireShortLivedCertificates(ctx context.Context) error
  1. TestExpireShortLivedCertificates_NoShortLived — no active certs with short-lived profiles, no changes
  2. TestExpireShortLivedCertificates_ExpiresActiveCert — cert with profile TTL < 1h, cert active, cert's NotAfter is in the past. Verify status -> Expired
  3. TestExpireShortLivedCertificates_SkipsNonExpired — cert with short-lived profile but NotAfter is in the future, no change
  4. TestExpireShortLivedCertificates_SkipsNonShortLived — cert with normal profile (TTL > 1h), even if expired. Verify not touched by this method
  5. TestExpireShortLivedCertificates_RepoError — certRepo.ListErr set

Note: This method needs access to profiles to determine TTL. Read the actual implementation to understand how it queries — it may iterate all active certs and check their profile's max_ttl.


P1-3: Domain Model Tests

internal/domain/job_test.go (NEW FILE)

package domain

import "testing"
  1. TestJobType_Constants — verify all 4 JobType constants have expected string values
  2. TestJobStatus_Constants — verify all 7 JobStatus constants
  3. TestVerificationStatus_Constants — verify all 4 VerificationStatus constants (pending, success, failed, skipped)

internal/domain/certificate_test.go (NEW FILE)

  1. TestCertificateStatus_Constants — verify all 8 CertificateStatus constants
  2. TestRenewalPolicy_EffectiveAlertThresholds_Custom — policy with custom thresholds returns them
  3. TestRenewalPolicy_EffectiveAlertThresholds_Default — policy with nil thresholds returns DefaultAlertThresholds()
  4. TestDefaultAlertThresholds — returns [30, 14, 7, 0]

internal/domain/agent_group_test.go (NEW FILE)

  1. TestAgentGroup_HasDynamicCriteria_True — group with MatchOS set
  2. TestAgentGroup_HasDynamicCriteria_False — all criteria empty
  3. TestAgentGroup_MatchesAgent_AllMatch — all 4 criteria set, agent matches all
  4. TestAgentGroup_MatchesAgent_OSMismatch — MatchOS="linux", agent.OS="windows"
  5. TestAgentGroup_MatchesAgent_ArchMismatch — MatchArchitecture="amd64", agent.Architecture="arm64"
  6. TestAgentGroup_MatchesAgent_VersionMismatch — MatchVersion="1.0", agent.Version="2.0"
  7. TestAgentGroup_MatchesAgent_IPMismatch — MatchIPCIDR doesn't match agent.IPAddress
  8. TestAgentGroup_MatchesAgent_EmptyCriteriaMatchesAll — all criteria empty, any agent matches
  9. TestAgentGroup_MatchesAgent_PartialCriteria — only MatchOS set, agent matches OS, other fields irrelevant
  10. TestAgentGroup_MatchesAgent_NilAgent — if agent is nil, should not panic (add nil guard or verify behavior)

internal/domain/notification_test.go (NEW FILE)

  1. TestNotificationType_Constants — verify all 7 types
  2. TestNotificationChannel_Constants — verify all 6 channels
  3. TestNotificationEvent_ZeroValue — default struct has empty strings, nil pointers

internal/domain/policy_test.go (NEW FILE)

  1. TestPolicyType_Constants — verify all 5 policy types
  2. TestPolicySeverity_Constants — verify all 3 severities
  3. TestPolicyViolation_Fields — create a violation, verify all fields accessible

P1-4: Handler Gap Tests

Modify internal/api/handler/agent_group_handler_test.go

Add:

  1. TestUpdateAgentGroup_Success — PUT with valid body, verify 200
  2. TestUpdateAgentGroup_InvalidJSON — malformed body, verify 400
  3. TestUpdateAgentGroup_MissingName — empty name field, verify 400
  4. TestUpdateAgentGroup_NotFound — service returns not found error, verify 404

Modify internal/api/handler/issuer_handler_test.go

Add:

  1. TestUpdateIssuer_Success — PUT with valid body, verify 200
  2. TestUpdateIssuer_InvalidJSON — verify 400
  3. TestUpdateIssuer_NotFound — verify 404

Modify internal/api/handler/network_scan_handler_test.go

Add:

  1. TestGetNetworkScanTarget_Success — GET by ID, verify 200
  2. TestGetNetworkScanTarget_NotFound — verify 404
  3. TestUpdateNetworkScanTarget_Success — PUT with valid body, verify 200
  4. TestUpdateNetworkScanTarget_InvalidJSON — verify 400
  5. TestUpdateNetworkScanTarget_NotFound — verify 404

P2-1: Frontend Error Handling Tests

File to modify: web/src/api/client.test.ts

Add error scenario tests for the 65+ API functions that lack them. Group by resource:

Pattern:

it('listCertificates handles 500 error', async () => {
  fetchMock.mockResponseOnce('', { status: 500 });
  await expect(listCertificates()).rejects.toThrow();
});

it('getCertificate handles 404 error', async () => {
  fetchMock.mockResponseOnce('', { status: 404 });
  await expect(getCertificate('nonexistent')).rejects.toThrow();
});

Required (~40 tests):

Add at minimum a 500 error test and a 404 test (where applicable) for each resource group:

  • Certificates (list 500, get 404, renew 404, revoke 404, export 404)
  • Agents (list 500, get 404)
  • Jobs (list 500, get 404, cancel 404, approve 404, reject 404)
  • Policies (list 500, get 404, create 400, update 404, delete 404)
  • Profiles (list 500, get 404, create 400)
  • Owners (list 500, get 404)
  • Teams (list 500, get 404)
  • Agent Groups (list 500, get 404)
  • Issuers (list 500, get 404)
  • Targets (list 500, get 404, create 400)
  • Discovery (list 500, claim 404, dismiss 404)
  • Network Scans (list 500, create 400, trigger 404)
  • Stats/Metrics (500 errors)
  • Health (500 error)

P2-2: Context Cancellation Tests

File to create: internal/service/context_test.go (NEW FILE)

Test that long-running service methods respect context cancellation.

Pattern:

func TestDeploymentService_CreateDeploymentJobs_ContextCancelled(t *testing.T) {
    ctx, cancel := context.WithCancel(context.Background())
    cancel() // Cancel immediately

    svc, _, targetRepo, _, _, _ := newTestDeploymentService()
    targetRepo.AddTarget(&domain.DeploymentTarget{ID: "t1", Name: "test"})

    _, err := svc.CreateDeploymentJobs(ctx, "cert-1")
    // Depending on implementation, may get context.Canceled or proceed normally
    // The key assertion: no panic, no goroutine leak
    t.Logf("result with cancelled context: %v", err)
}

Required (~8 tests):

  1. TestDeploymentService_ProcessDeploymentJob_ContextTimeout — context with 1ms timeout
  2. TestNetworkScanService_ScanAllTargets_ContextCancelled — cancel mid-scan
  3. TestDiscoveryService_ProcessDiscoveryReport_ContextCancelled
  4. TestESTService_SimpleEnroll_ContextCancelled
  5. TestExportService_ExportPKCS12_ContextCancelled
  6. TestRenewalService_ProcessRenewalJob_ContextTimeout
  7. TestCertificateService_RevokeCertificateWithActor_ContextCancelled
  8. TestVerificationService_RecordVerificationResult_ContextCancelled

P2-3: Concurrent Operation Tests

File to create: internal/service/concurrent_test.go (NEW FILE)

Use sync.WaitGroup and goroutines to test concurrent access patterns.

Required (~6 tests):

func TestConcurrentRevocation(t *testing.T) {
    // Setup service with a certificate
    // Launch 5 goroutines all trying to revoke the same cert simultaneously
    // Verify: exactly 1 succeeds (or all succeed idempotently), no panics, no data corruption
    var wg sync.WaitGroup
    errors := make([]error, 5)
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func(idx int) {
            defer wg.Done()
            errors[idx] = svc.RevokeCertificateWithActor(ctx, certID, "keyCompromise", "test-actor")
        }(i)
    }
    wg.Wait()
    // Assert at most 1 "already revoked" error
}
  1. TestConcurrentRevocation — 5 goroutines revoke same cert
  2. TestConcurrentDeploymentJobCreation — 3 goroutines create deployment jobs for same cert
  3. TestConcurrentDiscoveryReports — 3 goroutines submit discovery reports simultaneously
  4. TestConcurrentCertificateList — 10 goroutines list certificates simultaneously (no race)
  5. TestConcurrentJobStatusUpdate — 5 goroutines update same job status
  6. TestConcurrentTargetCRUD — create, update, delete targets concurrently

Execution Order

Run these in order, verifying each step:

# P0 — Critical
go test ./internal/service/ -run TestDeploymentService -v -count=1
go test ./internal/service/ -run TestTargetService -v -count=1
go test ./internal/scheduler/ -run TestScheduler -v -count=1

# P1 — High Priority
go test ./internal/service/ -run TestCompleteAgentCSR -v -count=1
go test ./internal/service/ -run TestExpireShortLived -v -count=1
go test ./internal/domain/ -v -count=1
go test ./internal/api/handler/ -run "TestUpdateAgentGroup|TestUpdateIssuer|TestGetNetworkScan|TestUpdateNetworkScan" -v -count=1

# P2 — Medium Priority
cd web && npx vitest run
go test ./internal/service/ -run TestContext -v -count=1
go test ./internal/service/ -run TestConcurrent -v -count=1

# Full suite verification
go test -race ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/scheduler/... ./internal/connector/... ./internal/domain/... ./internal/validation/... -count=1 -timeout 300s
go vet ./...
cd web && npx vitest run

Final CI Gate

After all tests pass locally, verify the full CI pipeline would pass:

# Coverage check
go test ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/integration/... ./internal/connector/issuer/... ./internal/connector/target/... ./internal/connector/notifier/... ./internal/mcp/... ./internal/cli/... ./internal/domain/... ./internal/validation/... -count=1 -cover -coverprofile=coverage.out

# Check thresholds
go tool cover -func=coverage.out | grep 'internal/service' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {printf "Service: %.1f%%\n", sum/n}'
go tool cover -func=coverage.out | grep 'internal/api/handler' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {printf "Handler: %.1f%%\n", sum/n}'
go tool cover -func=coverage.out | grep 'internal/domain' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {printf "Domain: %.1f%%\n", sum/n}'

# Targets: service >= 60%, handler >= 60%, domain >= 40%

What NOT To Do

  • Do NOT modify any production code (only test files)
  • Do NOT add new dependencies to go.mod
  • Do NOT create mocks that duplicate existing ones in testutil_test.go — reuse them
  • Do NOT use testing.Short() skips — all these tests should run in CI
  • Do NOT use time.Sleep for synchronization — use channels, WaitGroups, or atomic counters
  • Do NOT write tests that are flaky due to timing — if testing scheduler loops, use generous timeouts and verify "at least 1 call" rather than exact counts