test(bench): add allocation benchmarks for client/transport/dispatch hot paths

Hermetic benchmarks (no network) covering Call encode+decode, webhook ServeHTTP body parse, and Router dispatch (command/regex/filter). Use Go 1.24+ b.Loop() idiom. .benchstats/baseline.txt pins the pre-optimisation numbers for benchstat comparisons.
This commit is contained in:
2026-05-10 02:31:46 +01:00
parent 9cfe193e2e
commit 728b28b0c5
4 changed files with 241 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
goos: darwin
goarch: arm64
pkg: github.com/lukaszraczylo/go-telegram/client
cpu: Apple M4 Max
BenchmarkCall_BoolResponse-16 1875306 633.9 ns/op 1957 B/op 18 allocs/op
BenchmarkCall_StructResponse-16 1805024 665.2 ns/op 2005 B/op 18 allocs/op
BenchmarkEncodeJSONBody-16 23345811 51.55 ns/op 96 B/op 2 allocs/op
BenchmarkDecodeResult_Bool-16 23832240 50.37 ns/op 80 B/op 2 allocs/op
BenchmarkDecodeResult_Struct-16 13511192 92.64 ns/op 144 B/op 2 allocs/op
pkg: github.com/lukaszraczylo/go-telegram/transport
BenchmarkWebhook_ServeHTTP-16 465798 2564 ns/op 12707 B/op 24 allocs/op
pkg: github.com/lukaszraczylo/go-telegram/dispatch
BenchmarkRouter_DispatchCommand-16 7303522 152.7 ns/op 416 B/op 5 allocs/op
BenchmarkRouter_DispatchTextRegex-16 6740305 180.5 ns/op 428 B/op 5 allocs/op
BenchmarkRouter_DispatchFilter-16 39479149 32.18 ns/op 96 B/op 2 allocs/op
BenchmarkRouter_NewContext-16 208260764 5.790 ns/op 0 B/op 0 allocs/op
BenchmarkExtractCommand-16 12988816 92.69 ns/op 0 B/op 0 allocs/op
+94
View File
@@ -0,0 +1,94 @@
package client
import (
"bytes"
"context"
"io"
"net/http"
"testing"
)
// stubDoer returns the same canned response body for every request. It
// is intentionally minimal — testify mock has its own overhead that
// would dominate the per-call cost we want to measure.
type stubDoer struct{ body []byte }
func (s *stubDoer) Do(_ *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(s.body)),
Header: http.Header{},
}, nil
}
type benchSendReq struct {
ChatID int64 `json:"chat_id"`
Text string `json:"text"`
}
type benchMsgResp struct {
MessageID int64 `json:"message_id"`
Date int64 `json:"date"`
Text string `json:"text"`
}
func BenchmarkCall_BoolResponse(b *testing.B) {
d := &stubDoer{body: []byte(`{"ok":true,"result":true}`)}
bot := New("123:abc", WithHTTPClient(d))
ctx := context.Background()
req := &benchSendReq{ChatID: 42, Text: "hi"}
b.ReportAllocs()
for b.Loop() {
if _, err := Call[*benchSendReq, bool](ctx, bot, "setMyCommands", req); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkCall_StructResponse(b *testing.B) {
d := &stubDoer{body: []byte(`{"ok":true,"result":{"message_id":1,"date":0,"text":"ok"}}`)}
bot := New("123:abc", WithHTTPClient(d))
ctx := context.Background()
req := &benchSendReq{ChatID: 42, Text: "hi"}
b.ReportAllocs()
for b.Loop() {
if _, err := Call[*benchSendReq, benchMsgResp](ctx, bot, "sendMessage", req); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkEncodeJSONBody(b *testing.B) {
codec := DefaultCodec{}
req := &benchSendReq{ChatID: 42, Text: "hello, world"}
b.ReportAllocs()
for b.Loop() {
r, err := encodeJSONBody(codec, req)
if err != nil {
b.Fatal(err)
}
_ = r
}
}
func BenchmarkDecodeResult_Bool(b *testing.B) {
codec := DefaultCodec{}
raw := []byte(`{"ok":true,"result":true}`)
b.ReportAllocs()
for b.Loop() {
if _, err := decodeResult[bool](codec, raw); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkDecodeResult_Struct(b *testing.B) {
codec := DefaultCodec{}
raw := []byte(`{"ok":true,"result":{"message_id":1,"date":0,"text":"ok"}}`)
b.ReportAllocs()
for b.Loop() {
if _, err := decodeResult[benchMsgResp](codec, raw); err != nil {
b.Fatal(err)
}
}
}
+89
View File
@@ -0,0 +1,89 @@
package dispatch
import (
"context"
"testing"
"github.com/lukaszraczylo/go-telegram/api"
"github.com/lukaszraczylo/go-telegram/client"
)
func BenchmarkRouter_DispatchCommand(b *testing.B) {
r := New(client.New("t"))
r.OnCommand("/start", func(c *Context, m *api.Message) error { return nil })
u := cmdMessage("/start hello")
ctx := context.Background()
b.ReportAllocs()
for b.Loop() {
c := NewContext(ctx, r.bot, &u)
_ = r.dispatch(c, &u)
}
}
func BenchmarkRouter_DispatchTextRegex(b *testing.B) {
r := New(client.New("t"))
r.OnText("^hello.*", func(c *Context, m *api.Message) error { return nil })
u := api.Update{
UpdateID: 1,
Message: &api.Message{
MessageID: 1, Date: 0,
Chat: api.Chat{ID: 1, Type: api.ChatTypePrivate},
Text: "hello world",
},
}
ctx := context.Background()
b.ReportAllocs()
for b.Loop() {
c := NewContext(ctx, r.bot, &u)
_ = r.dispatch(c, &u)
}
}
func BenchmarkRouter_DispatchFilter(b *testing.B) {
r := New(client.New("t"))
r.OnMessageFilter(
func(m *api.Message) bool { return m != nil && m.Text == "ping" },
func(c *Context, m *api.Message) error { return nil },
)
u := api.Update{
UpdateID: 1,
Message: &api.Message{
MessageID: 1, Date: 0,
Chat: api.Chat{ID: 1, Type: api.ChatTypePrivate},
Text: "ping",
},
}
ctx := context.Background()
b.ReportAllocs()
for b.Loop() {
c := NewContext(ctx, r.bot, &u)
_ = r.dispatch(c, &u)
}
}
func BenchmarkRouter_NewContext(b *testing.B) {
bot := client.New("t")
u := &api.Update{UpdateID: 1}
ctx := context.Background()
b.ReportAllocs()
for b.Loop() {
_ = NewContext(ctx, bot, u)
}
}
func BenchmarkExtractCommand(b *testing.B) {
text := "/start@BotName hello world"
cmdLen := len("/start@BotName")
m := &api.Message{
MessageID: 1, Date: 0,
Chat: api.Chat{ID: 1, Type: api.ChatTypePrivate},
Text: text,
Entities: []api.MessageEntity{
{Type: api.MessageEntityTypeBotCommand, Offset: 0, Length: int64(cmdLen)},
},
}
b.ReportAllocs()
for b.Loop() {
_, _, _ = extractCommand(m)
}
}
+39
View File
@@ -0,0 +1,39 @@
package transport
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
"github.com/lukaszraczylo/go-telegram/client"
)
const benchUpdateBody = `{"update_id":12345,"message":{"message_id":1,"date":1700000000,"chat":{"id":42,"type":"private"},"from":{"id":42,"is_bot":false,"first_name":"User"},"text":"hello world"}}`
func BenchmarkWebhook_ServeHTTP(b *testing.B) {
w := NewWebhookServer(client.New("t"), WithBufferSize(1024))
body := []byte(benchUpdateBody)
done := make(chan struct{})
go func() {
for {
select {
case <-w.Updates():
case <-done:
return
}
}
}()
defer close(done)
b.ReportAllocs()
for b.Loop() {
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
rec := httptest.NewRecorder()
w.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
b.Fatalf("status %d", rec.Code)
}
}
}