test(go): step 6 — integration tests covering all exit codes and output

Extract run(args, stdout, stderr) int from main() for in-process
testability. Fix TLS failure classification (tlsErr now captured from
TLSHandshakeDone hook). Add run_test.go with 14 table-driven in-process
tests and cli_test.go with TestMain + 3 subprocess smoke tests. All
servers use httptest; .invalid TLD for deterministic DNS failures.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 01:00:20 +02:00
parent c323d879d0
commit a9534ec2c1
8 changed files with 727 additions and 95 deletions

287
go/run_test.go Normal file
View File

@@ -0,0 +1,287 @@
package main
import (
"bytes"
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// ── helpers ───────────────────────────────────────────────────────────────────
// statusSrv starts a server that always replies with the given HTTP status.
func statusSrv(code int) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(code)
}))
}
// blockingSrv starts a server whose handler blocks until the client disconnects.
// Using r.Context().Done() means the handler exits cleanly when the client drops,
// so srv.Close() never hangs.
func blockingSrv() *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
}))
}
// refusedURL returns an http:// URL on a port where nothing is listening.
// It binds a listener to get a free port, closes it immediately, then hands
// back that address — so any connect attempt is immediately refused.
func refusedURL() string {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
panic("refusedURL: " + err.Error())
}
addr := l.Addr().String()
l.Close()
return "http://" + addr
}
// invoke calls run() and returns stdout, stderr, and the exit code.
func invoke(args ...string) (stdout, stderr string, code int) {
var outBuf, errBuf bytes.Buffer
code = run(args, &outBuf, &errBuf)
return outBuf.String(), errBuf.String(), code
}
// ── matrix ────────────────────────────────────────────────────────────────────
func TestRunMatrix(t *testing.T) {
ok200 := statusSrv(200)
defer ok200.Close()
ok500 := statusSrv(500)
defer ok500.Close()
ok404 := statusSrv(404)
defer ok404.Close()
tlsSrv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(200)
}))
defer tlsSrv.Close()
hangSrv := blockingSrv()
defer hangSrv.Close()
refused := refusedURL()
cases := []struct {
name string
args []string
wantCode int
wantOut []string // substrings required in stdout
wantErr []string // substrings required in stderr
}{
{
// httptest uses 127.0.0.1 — Go skips DNS for loopback, so no DNS row.
name: "success 200",
args: []string{ok200.URL},
wantCode: exitOK,
wantOut: []string{"(200)", "TCP connect", "Total"},
},
{
name: "HTTP 500 without --fail",
args: []string{ok500.URL},
wantCode: exitOK,
wantOut: []string{"(500)", "Total"},
},
{
name: "HTTP 404 with --fail",
args: []string{"--fail", ok404.URL},
wantCode: exitHTTP,
wantOut: []string{"404 ✗"},
},
{
name: "DNS failure",
args: []string{"https://this.will.never.resolve.invalid"},
wantCode: exitDNS,
wantOut: []string{"✗ dns:"},
},
{
name: "connection refused",
args: []string{refused},
wantCode: exitConnect,
wantOut: []string{"✗ connect:"},
},
{
name: "timeout",
args: []string{"--timeout", "200ms", hangSrv.URL},
wantCode: exitTimeout,
wantOut: []string{"✗ timeout:"},
},
{
name: "TLS failure (self-signed cert rejected by default client)",
args: []string{tlsSrv.URL},
wantCode: exitTLS,
wantOut: []string{"✗ tls:"},
},
{
name: "multiple URLs — highest exit code wins",
args: []string{ok200.URL, "https://no.such.host.for.test.invalid"},
wantCode: exitDNS, // 2 > 0
wantOut: []string{"(200)", "✗ dns:"},
},
{
name: "sampling -n 3 all success",
args: []string{"-n", "3", ok200.URL},
wantCode: exitOK,
wantOut: []string{"3 samples", "min", "avg", "max"},
},
{
name: "no args → usage",
args: []string{},
wantCode: exitUsage,
wantErr: []string{"Usage:"},
},
{
name: "-h → usage exit 0",
args: []string{"-h"},
wantCode: exitOK,
wantErr: []string{"Usage:"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
stdout, stderr, code := invoke(tc.args...)
if code != tc.wantCode {
t.Errorf("exit code = %d, want %d\nstdout:\n%s\nstderr:\n%s",
code, tc.wantCode, stdout, stderr)
}
for _, s := range tc.wantOut {
if !strings.Contains(stdout, s) {
t.Errorf("stdout missing %q\nstdout:\n%s", s, stdout)
}
}
for _, s := range tc.wantErr {
if !strings.Contains(stderr, s) {
t.Errorf("stderr missing %q\nstderr:\n%s", s, stderr)
}
}
})
}
}
// ── JSON-specific assertions ──────────────────────────────────────────────────
func TestJSONSuccess(t *testing.T) {
srv := statusSrv(200)
defer srv.Close()
stdout, _, code := invoke("--json", srv.URL)
if code != exitOK {
t.Fatalf("exit code = %d, want 0\nstdout: %s", code, stdout)
}
var results []struct {
URL string `json:"url"`
Status int `json:"status"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
Phases map[string]any `json:"phases"`
Errors []any `json:"errors"`
}
if err := json.Unmarshal([]byte(stdout), &results); err != nil {
t.Fatalf("invalid JSON: %v\nstdout: %s", err, stdout)
}
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
r := results[0]
if r.Status != 200 {
t.Errorf("status = %d, want 200", r.Status)
}
if r.Succeeded != 1 {
t.Errorf("succeeded = %d, want 1", r.Succeeded)
}
if r.Failed != 0 {
t.Errorf("failed = %d, want 0", r.Failed)
}
if r.Phases["total"] == nil {
t.Error("phases.total missing")
}
if len(r.Errors) > 0 {
t.Errorf("unexpected errors: %v", r.Errors)
}
}
func TestJSONDNSFailure(t *testing.T) {
stdout, _, code := invoke("--json", "https://no.such.host.json.test.invalid")
if code != exitDNS {
t.Fatalf("exit code = %d, want %d\nstdout: %s", code, exitDNS, stdout)
}
var results []struct {
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
Errors []struct {
Phase string `json:"phase"`
Count int `json:"count"`
Message string `json:"message"`
} `json:"errors"`
}
if err := json.Unmarshal([]byte(stdout), &results); err != nil {
t.Fatalf("invalid JSON: %v\nstdout: %s", err, stdout)
}
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
r := results[0]
if r.Succeeded != 0 {
t.Errorf("succeeded = %d, want 0", r.Succeeded)
}
if r.Failed != 1 {
t.Errorf("failed = %d, want 1", r.Failed)
}
if len(r.Errors) == 0 {
t.Fatal("errors array is empty")
}
if r.Errors[0].Phase != "dns" {
t.Errorf("error phase = %q, want \"dns\"", r.Errors[0].Phase)
}
if r.Errors[0].Count != 1 {
t.Errorf("error count = %d, want 1", r.Errors[0].Count)
}
}
func TestJSONSampling(t *testing.T) {
srv := statusSrv(200)
defer srv.Close()
stdout, _, code := invoke("--json", "-n", "3", srv.URL)
if code != exitOK {
t.Fatalf("exit code = %d, want 0\nstdout: %s", code, stdout)
}
var results []struct {
Succeeded int `json:"succeeded"`
Phases map[string]struct {
MinMS float64 `json:"min_ms"`
AvgMS float64 `json:"avg_ms"`
MaxMS float64 `json:"max_ms"`
} `json:"phases"`
}
if err := json.Unmarshal([]byte(stdout), &results); err != nil {
t.Fatalf("invalid JSON: %v\nstdout: %s", err, stdout)
}
r := results[0]
if r.Succeeded != 3 {
t.Errorf("succeeded = %d, want 3", r.Succeeded)
}
total, ok := r.Phases["total"]
if !ok {
t.Fatal("phases.total missing")
}
if total.MinMS <= 0 {
t.Errorf("total.min_ms = %f, want > 0", total.MinMS)
}
if total.MaxMS < total.MinMS {
t.Errorf("total.max_ms (%f) < total.min_ms (%f)", total.MaxMS, total.MinMS)
}
}