Skip to content

Commit d46a66c

Browse files
authored
Merge pull request ghostunnel#662 from ghostunnel/cs/better-tests
Parallelize integration test runner and handle timeouts better
2 parents 32e551b + cdb0ce3 commit d46a66c

76 files changed

Lines changed: 627 additions & 485 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CONTRIBUTING.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@ run checks on a live instance. If you are adding new features or changing
5252
existing behavior, please add/update the integration tests in the `tests/`
5353
directory accordingly. The tests use the `tests/common.py` helper module.
5454

55+
Integration tests run in parallel by default (up to `NumCPU`, capped at 16 by default).
56+
Set `GHOSTUNNEL_TEST_PARALLEL` to control the number of concurrent tests (may exceed the default cap):
57+
58+
```bash
59+
GHOSTUNNEL_TEST_PARALLEL=4 mage test:integration
60+
```
61+
5562
Test certificates are generated in `test-keys/` via `mage test:keys`.
5663

5764
## Architecture Overview

magefile.go

Lines changed: 96 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"os/exec"
1313
"path/filepath"
1414
"runtime"
15+
"strconv"
1516
"strings"
1617
"time"
1718

@@ -375,7 +376,8 @@ func (Test) Unit(ctx context.Context) error {
375376
return sh.Run("go", "test", "-v", "-covermode=count", "-coverprofile=coverage/unit-test.profile", "./...")
376377
}
377378

378-
// Integration runs the integration tests.
379+
// Integration runs the integration tests in parallel.
380+
// Set GHOSTUNNEL_TEST_PARALLEL to control concurrency (default: NumCPU, max 16).
379381
func (Test) Integration(ctx context.Context) error {
380382
mg.CtxDeps(ctx, Test.build)
381383

@@ -395,47 +397,93 @@ func (Test) Integration(ctx context.Context) error {
395397
return fmt.Errorf("failed to find test files: %w", err)
396398
}
397399

398-
// Run each integration test directly
399-
printf("Running integration tests...\n")
400-
for _, testFile := range testFiles {
401-
if err := ctx.Err(); err != nil {
402-
return fmt.Errorf("context cancelled: %w", err)
400+
// Determine parallelism
401+
parallel := runtime.NumCPU()
402+
if parallel > 16 {
403+
parallel = 16
404+
}
405+
if envVal := os.Getenv("GHOSTUNNEL_TEST_PARALLEL"); envVal != "" {
406+
if n, err := strconv.Atoi(envVal); err == nil && n > 0 {
407+
parallel = n
403408
}
409+
}
404410

405-
testName := strings.TrimSuffix(filepath.Base(testFile), ".py")
406-
printf("=== RUN %s\n", testName)
407-
408-
// Run the Python test file directly from tests directory
409-
start := time.Now()
410-
testFileName := filepath.Base(testFile)
411-
cmd := exec.CommandContext(ctx, "python3", testFileName)
412-
cmd.Dir = "tests"
411+
printf("Running %d integration tests with parallelism=%d...\n", len(testFiles), parallel)
413412

414-
// Capture stdout and stderr
415-
var stdout, stderr bytes.Buffer
416-
cmd.Stdout = &stdout
417-
cmd.Stderr = &stderr
413+
type testResult struct {
414+
name string
415+
stdout []byte
416+
stderr []byte
417+
err error
418+
duration time.Duration
419+
}
418420

419-
err := cmd.Run()
420-
duration := time.Since(start)
421-
elapsed := duration.Seconds()
421+
// Channel-based semaphore for limiting concurrency
422+
sem := make(chan struct{}, parallel)
423+
results := make(chan testResult, len(testFiles))
422424

423-
if err == nil {
424-
printf("=== PASS: %s (%.2fs)\n", testName, elapsed)
425-
continue
425+
// Launch all tests as goroutines
426+
for _, testFile := range testFiles {
427+
go func() {
428+
// Check for context cancellation before acquiring semaphore
429+
select {
430+
case <-ctx.Done():
431+
results <- testResult{
432+
name: strings.TrimSuffix(filepath.Base(testFile), ".py"),
433+
err: ctx.Err(),
434+
}
435+
return
436+
case sem <- struct{}{}: // acquire
437+
}
438+
defer func() { <-sem }() // release
439+
440+
testName := strings.TrimSuffix(filepath.Base(testFile), ".py")
441+
442+
start := time.Now()
443+
testFileName := filepath.Base(testFile)
444+
cmd := exec.CommandContext(ctx, "python3", testFileName)
445+
cmd.Dir = "tests"
446+
447+
var stdout, stderr bytes.Buffer
448+
cmd.Stdout = &stdout
449+
cmd.Stderr = &stderr
450+
451+
err := cmd.Run()
452+
duration := time.Since(start)
453+
454+
results <- testResult{
455+
name: testName,
456+
stdout: stdout.Bytes(),
457+
stderr: stderr.Bytes(),
458+
err: err,
459+
duration: duration,
460+
}
461+
}()
462+
}
463+
464+
// Collect results
465+
var failed []testResult
466+
for i := 0; i < len(testFiles); i++ {
467+
r := <-results
468+
if r.err == nil {
469+
printf("=== PASS: %s (%.2fs)\n", r.name, r.duration.Seconds())
470+
} else {
471+
fmt.Printf("=== FAIL: %s (%.2fs)\n", r.name, r.duration.Seconds())
472+
failed = append(failed, r)
426473
}
474+
}
427475

428-
// Test failed - output captured stdout/stderr and failure message
429-
os.Stdout.Write(stdout.Bytes())
430-
os.Stderr.Write(stderr.Bytes())
431-
printf("=== FAIL: %s (%.2fs)\n", testName, elapsed)
432-
433-
// Get exit code if available
434-
if exitError, ok := err.(*exec.ExitError); ok {
435-
return fmt.Errorf("integration test %s failed with exit code %d", testName, exitError.ExitCode())
476+
// Report failures
477+
if len(failed) > 0 {
478+
fmt.Printf("\n--- FAILURES ---\n")
479+
for _, r := range failed {
480+
fmt.Printf("\n=== FAIL: %s (%.2fs)\n", r.name, r.duration.Seconds())
481+
fmt.Printf("--- stdout ---\n")
482+
os.Stdout.Write(r.stdout)
483+
fmt.Printf("--- stderr ---\n")
484+
os.Stdout.Write(r.stderr)
436485
}
437-
438-
return fmt.Errorf("integration test %s failed: %w", testName, err)
486+
return fmt.Errorf("%d integration test(s) failed", len(failed))
439487
}
440488

441489
return nil
@@ -498,8 +546,10 @@ func (Test) Single(ctx context.Context, name string) error {
498546

499547
// On failure, show captured output if not already streaming
500548
if !mg.Verbose() {
549+
fmt.Printf("--- stdout ---\n")
501550
os.Stdout.Write(stdout.Bytes())
502-
os.Stderr.Write(stderr.Bytes())
551+
fmt.Printf("--- stderr ---\n")
552+
os.Stdout.Write(stderr.Bytes())
503553
}
504554
fmt.Printf("=== FAIL: %s (%.2fs)\n", name, elapsed)
505555
if exitError, ok := err.(*exec.ExitError); ok {
@@ -680,12 +730,21 @@ func (Test) Docker(ctx context.Context) error {
680730
return fmt.Errorf("failed to get current directory: %w", err)
681731
}
682732

683-
args = []string{"run", "-v", fmt.Sprintf("%s:/go/src/github.com/ghostunnel/ghostunnel", pwd), "ghostunnel/ghostunnel-test", "--"}
733+
containerName := fmt.Sprintf("ghostunnel-test-%d", os.Getpid())
734+
args = []string{"run", "--rm", "--name", containerName, "-v", fmt.Sprintf("%s:/go/src/github.com/ghostunnel/ghostunnel", pwd), "ghostunnel/ghostunnel-test", "--"}
684735
if mg.Verbose() {
685736
args = append(args, "-v")
686737
}
687738
args = append(args, "test:softhsmimport", "test:all")
688-
return sh.Run("docker", args...)
739+
740+
defer func() {
741+
exec.Command("docker", "rm", "-f", containerName).Run()
742+
}()
743+
744+
cmd := exec.CommandContext(ctx, "docker", args...)
745+
cmd.Stdout = os.Stdout
746+
cmd.Stderr = os.Stderr
747+
return cmd.Run()
689748
}
690749

691750
// Build builds and tags all Docker containers.

main.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -773,10 +773,9 @@ func (env *Environment) serveStatus() error {
773773
}
774774

775775
logger.Printf("shutdown was requested via status endpoint")
776+
w.WriteHeader(http.StatusOK)
776777

777778
env.shutdownChannel <- true
778-
779-
w.WriteHeader(http.StatusOK)
780779
})
781780
}
782781

0 commit comments

Comments
 (0)