From 22e8f3548a72c78deccedc5a65c1cba029f52e6d Mon Sep 17 00:00:00 2001 From: Stefan Majewsky Date: Mon, 17 Aug 2026 22:56:47 +0200 Subject: add testing/microprom, initial parity with promhttp --- testing/go.mod | 14 +++++- testing/go.sum | 40 ++++++++++++++- testing/microprom/handler_test.go | 101 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 152 insertions(+), 3 deletions(-) create mode 100644 testing/microprom/handler_test.go (limited to 'testing') diff --git a/testing/go.mod b/testing/go.mod index 9206521..9c371c2 100644 --- a/testing/go.mod +++ b/testing/go.mod @@ -4,5 +4,17 @@ go 1.26 require ( github.com/lib/pq v1.12.3 - go.xyrillian.de/gg v1.13.0 + go.xyrillian.de/gg v1.13.4-0.20260817144159-a48f97c25d42 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_golang v1.24.1 + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.70.1 // indirect + github.com/prometheus/procfs v0.21.1 // indirect + golang.org/x/sys v0.47.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/testing/go.sum b/testing/go.sum index df1974e..6a62175 100644 --- a/testing/go.sum +++ b/testing/go.sum @@ -1,4 +1,40 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= -go.xyrillian.de/gg v1.12.1-0.20260731210839-e26a214de395 h1:nA6DhnjgGw1sg+Piv4xZupuoD0dM+x4Fyd91Gi7XgOo= -go.xyrillian.de/gg v1.12.1-0.20260731210839-e26a214de395/go.mod h1:DoO4fQSWIrBRlNlCjVyrYM0kAEBt/Jg2GkMH+cGRZ0k= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= +github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= +github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= +github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= +github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.xyrillian.de/gg v1.13.4-0.20260817144159-a48f97c25d42 h1:QACQsLeI3fAWQBbZZ2/VCXBjGeeY9v+vsXnzsl0hJig= +go.xyrillian.de/gg v1.13.4-0.20260817144159-a48f97c25d42/go.mod h1:DoO4fQSWIrBRlNlCjVyrYM0kAEBt/Jg2GkMH+cGRZ0k= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/testing/microprom/handler_test.go b/testing/microprom/handler_test.go new file mode 100644 index 0000000..6aa60f0 --- /dev/null +++ b/testing/microprom/handler_test.go @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: 2026 Stefan Majewsky +// SPDX-License-Identifier: Apache-2.0 + +package microprom_test + +import ( + "context" + "fmt" + "io" + "maps" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" + "go.xyrillian.de/gg/assert" + "go.xyrillian.de/gg/microprom" +) + +func TestHandlerFunctionallyIdenticalToPromhttp(t *testing.T) { + // build a microprom.Handler rendering two metric families + h1 := microprom.Handler{ + Families: map[microprom.MetricFamilyName]microprom.MetricFamilyInfo{ + "events": { + Type: microprom.MetricTypeCounter, + Help: "Counts events that happened.", + }, + "memory_usage_bytes": { + Type: microprom.MetricTypeGauge, + Help: "How much memory is currently used.", + }, + }, + SortOutput: true, + Collect: func(ctx context.Context, ms *microprom.MetricSet) error { + labelNames := microprom.NewLabelNames("shard", "type") + for idx := range 5 { + labels := ms.FormatLabels(labelNames, fmt.Sprintf("node%d", idx), "update") + ms.Add("events", labels, float64(10*idx)) + } + ms.Add("memory_usage_bytes", "", 42<<20) + return nil + }, + } + + // build a promhttp.Handler rendering the same metric families + eventsCounter := prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "events_total", + Help: "Counts events that happened.", + }, []string{"type", "shard"}) + for idx := range 5 { + eventsCounter.With(prometheus.Labels{ + "type": "update", + "shard": fmt.Sprintf("node%d", idx), + }).Add(float64(10 * idx)) + } + memoryUsageBytesGauge := prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "memory_usage_bytes", + Help: "How much memory is currently used.", + }) + memoryUsageBytesGauge.Set(42 << 20) + r := prometheus.NewRegistry() + r.MustRegister(eventsCounter) + r.MustRegister(memoryUsageBytesGauge) + h2 := promhttp.HandlerFor(r, promhttp.HandlerOpts{EnableOpenMetrics: true}) + + // test identical behavior for Prometheus Text Format + body1, headers1 := getMetrics(t, h1, nil) + body2, headers2 := getMetrics(t, h2, nil) + assert.Equal(t, strings.Split(body1, "\n"), strings.Split(body2, "\n")) + assert.Equal(t, headers1, headers2) + + // test identical behavior for OpenMetrics 1.0 text format + body1, headers1 = getMetrics(t, h1, http.Header{"Accept": {"application/openmetrics-text; version=1.0.0"}}) + body2, headers2 = getMetrics(t, h2, http.Header{"Accept": {"application/openmetrics-text; version=1.0.0"}}) + assert.Equal(t, strings.Split(body1, "\n"), strings.Split(body2, "\n")) + assert.Equal(t, headers1, headers2) + + // test invalid Accept header + body1, headers1 = getMetrics(t, h1, http.Header{"Accept": {"image/*"}}) + body2, headers2 = getMetrics(t, h2, http.Header{"Accept": {"image/*"}}) + assert.Equal(t, strings.Split(body1, "\n"), strings.Split(body2, "\n")) + assert.Equal(t, headers1, headers2) +} + +func getMetrics(t *testing.T, h http.Handler, requestHeaders http.Header) (responseBody string, responseHeaders http.Header) { + t.Helper() + r := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/metrics", nil) + maps.Copy(r.Header, requestHeaders) + + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + resp := w.Result() + + buf, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err.Error()) + } + return string(buf), resp.Header +} -- cgit v1.3.1 From a3ccdd9f491c20771cfda343ebebd4b5d0ea6602 Mon Sep 17 00:00:00 2001 From: Stefan Majewsky Date: Tue, 18 Aug 2026 13:40:20 +0200 Subject: test coverage for microprom --- assert/panic.go | 2 +- assert/panic_test.go | 2 +- internal/accept/accept.go | 7 +- internal/accept/accept_test.go | 13 ++++ microprom/handler_test.go | 142 ++++++++++++++++++++++++++++++++++++++ microprom/labels.go | 2 +- microprom/microprom.go | 2 +- testing/microprom/handler_test.go | 6 -- 8 files changed, 165 insertions(+), 11 deletions(-) create mode 100644 microprom/handler_test.go (limited to 'testing') diff --git a/assert/panic.go b/assert/panic.go index 69828a4..e19b815 100644 --- a/assert/panic.go +++ b/assert/panic.go @@ -26,7 +26,7 @@ func PanicsWith[T any](t TestingTB, action func()) T { value, ok := result.Panic.(T) if !ok { var zero T - t.Fatalf("panicked with incorrect type: expected %T, but got %#v", zero, result.Panic) + t.Fatalf("panicked with incorrect type: expected %T, but got %T: %#v", zero, result.Panic, result.Panic) } return value } diff --git a/assert/panic_test.go b/assert/panic_test.go index 135ce18..a3d6877 100644 --- a/assert/panic_test.go +++ b/assert/panic_test.go @@ -30,6 +30,6 @@ func TestPanics(t *testing.T) { }) assert.Equal(t, tc.Outcome, testcapture.OutcomeFailed) assert.Equal(t, tc.Messages, []testcapture.Message{ - testcapture.Log("panicked with incorrect type: expected string, but got 42"), + testcapture.Log("panicked with incorrect type: expected string, but got int: 42"), }) } diff --git a/internal/accept/accept.go b/internal/accept/accept.go index 5ed6e66..c23905a 100644 --- a/internal/accept/accept.go +++ b/internal/accept/accept.go @@ -103,6 +103,11 @@ func (h Header) Negotiate(mediaTypes ...string) Option[string] { return None[string]() } + // if nothing was offered, we default to our own preferred option + if len(h.options) == 0 { + return Some(offers[0].OriginalValue) + } + // NOTE: ParseHeader() sorts options by descending weight, so the first match wins. for _, opt := range h.options { MEDIATYPE: @@ -128,5 +133,5 @@ func (h Header) Negotiate(mediaTypes ...string) Option[string] { } } - return Some(offers[0].OriginalValue) + return None[string]() } diff --git a/internal/accept/accept_test.go b/internal/accept/accept_test.go index 90b6934..edfef42 100644 --- a/internal/accept/accept_test.go +++ b/internal/accept/accept_test.go @@ -12,6 +12,7 @@ import ( ) func TestAcceptWithHeader(t *testing.T) { + // asking for a wide range of formats, including wildcard matches h := accept.ParseHeader([]string{"text/*;q=0.3, text/plain;format=flowed, text/plain;format=fixed;q=0.4, */*;q=0.5"}) assert.Equal(t, h.Negotiate( @@ -38,6 +39,18 @@ func TestAcceptWithHeader(t *testing.T) { "text/markdown", // matches with q=0.3 "text/plain", // matches with q=0.3 (but first wins) ), Some("text/markdown")) + + // asking for specific formats only + h = accept.ParseHeader([]string{"image/png, image/jpeg"}) + + assert.Equal(t, h.Negotiate( + "text/plain", + "image/png", + ), Some("image/png")) + + assert.Equal(t, h.Negotiate( + "text/plain", + ), None[string]()) } func TestAcceptWithoutHeader(t *testing.T) { diff --git a/microprom/handler_test.go b/microprom/handler_test.go new file mode 100644 index 0000000..7278945 --- /dev/null +++ b/microprom/handler_test.go @@ -0,0 +1,142 @@ +// SPDX-FileCopyrightText: 2026 Stefan Majewsky +// SPDX-License-Identifier: Apache-2.0 + +package microprom_test + +import ( + "context" + "errors" + "io" + "maps" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "go.xyrillian.de/gg/assert" + "go.xyrillian.de/gg/microprom" +) + +func TestHandlerBasic(t *testing.T) { + // NOTE: Most happy path coverage is in `./testing/microprom`. + // This only covers the SortOutput = false case. + + h := microprom.Handler{ + Families: map[microprom.MetricFamilyName]microprom.MetricFamilyInfo{ + "process": { + Type: microprom.MetricTypeInfo, + Help: "Information about this process.", + }, + "foo": { + Type: microprom.MetricTypeGauge, + Help: "This metric family will not have any collected metrics and thus go unreported.", + }, + }, + Collect: func(ctx context.Context, ms *microprom.MetricSet) error { + names := microprom.NewLabelNames("version") + labels := ms.FormatLabels(names, "1.2.3") + ms.Add("process", labels, 1.0) + return nil + }, + } + + // test normal behavior + status, body, headers := getMetrics(t, h, nil) + assert.Equal(t, status, http.StatusOK) + assert.Equal(t, headers, http.Header{ + "Content-Type": {"text/plain; version=0.0.4; charset=utf-8; escaping=underscores"}, + }) + assert.Equal(t, body, strings.TrimSpace(` +# HELP process_info Information about this process. +# TYPE process_info info +process_info{version="1.2.3"} 1 + `)+"\n") +} + +func TestHandlerErrors(t *testing.T) { + h := microprom.Handler{ + Families: map[microprom.MetricFamilyName]microprom.MetricFamilyInfo{ + "process": { + Type: microprom.MetricTypeInfo, + Help: "Information about this process.", + }, + }, + Collect: func(ctx context.Context, ms *microprom.MetricSet) error { + return errors.New("kaboom") + }, + } + + // test unacceptable content negotiation + status, body, headers := getMetrics(t, h, http.Header{"Accept": {"application/json"}}) + assert.Equal(t, status, http.StatusNotAcceptable) + assert.Equal(t, headers.Get("Content-Type"), "text/plain; charset=utf-8") + assert.Equal(t, body, "supported formats are text/plain and application/openmetrics-text\n") + + // test error during h.Collect() + status, body, headers = getMetrics(t, h, nil) + assert.Equal(t, status, http.StatusInternalServerError) + assert.Equal(t, headers.Get("Content-Type"), "text/plain; charset=utf-8") + assert.Equal(t, body, "kaboom\n") + + // test panic from invalid metric family name + h.Families["what is this?"] = microprom.MetricFamilyInfo{ + Type: microprom.MetricTypeGauge, + Help: "invalid metric family name", + } + msg := assert.PanicsWith[string](t, func() { getMetrics(t, h, nil) }) + assert.Equal(t, msg, `in family "what is this?": invalid family name (does not match /^[a-zA-Z_:][a-zA-Z0-9_:]*$/)`) + delete(h.Families, "what is this?") + + // test panic from invalid metric type + h.Families["invalid"] = microprom.MetricFamilyInfo{ + Type: 100, + Help: "invalid metric type", + } + msg = assert.PanicsWith[string](t, func() { getMetrics(t, h, nil) }) + assert.Equal(t, msg, `in family "invalid": invalid value for microprom.MetricType: 100`) + delete(h.Families, "invalid") + + // test panic from invalid label name + h.Collect = func(ctx context.Context, ms *microprom.MetricSet) error { + names := microprom.NewLabelNames("app:version") + labels := ms.FormatLabels(names, "1.2.3") + ms.Add("process", labels, 1.0) + return nil + } + msg = assert.PanicsWith[string](t, func() { getMetrics(t, h, nil) }) + assert.Equal(t, msg, `invalid label name: "app:version"`) + + // test panic from wrong number of label values + h.Collect = func(ctx context.Context, ms *microprom.MetricSet) error { + names := microprom.NewLabelNames("version", "build_date") + labels := ms.FormatLabels(names, "1.2.3") // forgot build_date + ms.Add("process", labels, 1.0) + return nil + } + msg = assert.PanicsWith[string](t, func() { getMetrics(t, h, nil) }) + assert.Equal(t, msg, `expected 2 label values, but got 1`) + + // test panic from using an undeclared metric family + h.Collect = func(ctx context.Context, ms *microprom.MetricSet) error { + ms.Add("invalid", "", 1.0) + return nil + } + msg = assert.PanicsWith[string](t, func() { getMetrics(t, h, nil) }) + assert.Equal(t, msg, `no such family: invalid`) +} + +func getMetrics(t *testing.T, h http.Handler, requestHeaders http.Header) (status int, responseBody string, responseHeaders http.Header) { + t.Helper() + r := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/metrics", nil) + maps.Copy(r.Header, requestHeaders) + + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + resp := w.Result() + + buf, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err.Error()) + } + return resp.StatusCode, string(buf), resp.Header +} diff --git a/microprom/labels.go b/microprom/labels.go index 51cdbb1..5b56c9c 100644 --- a/microprom/labels.go +++ b/microprom/labels.go @@ -54,7 +54,7 @@ func (ms *MetricSet) FormatLabels(n LabelNames, values ...string) Labels { // NOTE on API structure: This is not part of ms.Add() to allow reusing label sets for multiple metrics. if len(n.names) != len(values) { - panic("arguments are not of equal length") + panic(fmt.Sprintf("expected %d label values, but got %d", len(n.names), len(values))) } if len(n.names) == 0 { return "" diff --git a/microprom/microprom.go b/microprom/microprom.go index 86a5419..4055c8b 100644 --- a/microprom/microprom.go +++ b/microprom/microprom.go @@ -135,7 +135,7 @@ func NewMetricSet(syntax Syntax, families map[MetricFamilyName]MetricFamilyInfo) func (ms *MetricSet) Add(name MetricFamilyName, labels Labels, value float64) { _, ok := ms.metrics[name] if !ok { - panic("no such family: " + name) + panic("no such family: " + string(name)) } ms.metrics[name] = append(ms.metrics[name], metric{labels, value}) } diff --git a/testing/microprom/handler_test.go b/testing/microprom/handler_test.go index 6aa60f0..93027e3 100644 --- a/testing/microprom/handler_test.go +++ b/testing/microprom/handler_test.go @@ -76,12 +76,6 @@ func TestHandlerFunctionallyIdenticalToPromhttp(t *testing.T) { body2, headers2 = getMetrics(t, h2, http.Header{"Accept": {"application/openmetrics-text; version=1.0.0"}}) assert.Equal(t, strings.Split(body1, "\n"), strings.Split(body2, "\n")) assert.Equal(t, headers1, headers2) - - // test invalid Accept header - body1, headers1 = getMetrics(t, h1, http.Header{"Accept": {"image/*"}}) - body2, headers2 = getMetrics(t, h2, http.Header{"Accept": {"image/*"}}) - assert.Equal(t, strings.Split(body1, "\n"), strings.Split(body2, "\n")) - assert.Equal(t, headers1, headers2) } func getMetrics(t *testing.T, h http.Handler, requestHeaders http.Header) (responseBody string, responseHeaders http.Header) { -- cgit v1.3.1