Skip to content

Commit 1d2f22a

Browse files
committed
feat: add aibtest package for testing utilities
This creates a proper testing package to address the tech debt identified in issue #73. The package consolidates the scattered test helpers and provides well-structured components that reduce boilerplate and improve test readability. New components: - MockUpstreamServer: Simulates LLM provider API responses using txtar fixtures. Supports streaming/non-streaming responses and response mutation for multi-turn conversations. - MockMCPServer: Provides a mock MCP server with integrated call tracking. Combines the server, HTTP endpoint, and call accumulator into a single struct, eliminating the need for separate callAccumulator management. - MockRecorder: Implements aibridge.Recorder for testing with convenient accessor methods (Interceptions, TokenUsages, ToolUsages, etc.) and verification helpers (VerifyAllInterceptionsEnded, TotalInputTokens). - TestBridge: Combines bridge, HTTP server, recorder, and optional MCP server into a single easy-to-use struct with DoAnthropicRequest and DoOpenAIRequest methods. Helper functions: - FilesMap, SetStreamingInRequest, CreateAnthropicMessagesRequest, CreateOpenAIChatCompletionsRequest, AnthropicConfig, OpenAIConfig This addresses the issues mentioned in #73: - callAccumulator is now integrated into MockMCPServer - setupMCPServerProxiesForTest return values are consolidated into MockMCPServer - Common test patterns are abstracted into TestBridge Resolves #73
1 parent b202549 commit 1d2f22a

File tree

9 files changed

+1914
-0
lines changed

9 files changed

+1914
-0
lines changed

aibtest/aibtest_test.go

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
package aibtest_test
2+
3+
import (
4+
"context"
5+
_ "embed"
6+
"encoding/json"
7+
"io"
8+
"net/http"
9+
"testing"
10+
"time"
11+
12+
"github.com/coder/aibridge"
13+
"github.com/coder/aibridge/aibtest"
14+
"github.com/stretchr/testify/assert"
15+
"github.com/stretchr/testify/require"
16+
"go.uber.org/goleak"
17+
)
18+
19+
//go:embed testdata/anthropic_simple.txtar
20+
var anthropicSimple []byte
21+
22+
//go:embed testdata/openai_simple.txtar
23+
var openaiSimple []byte
24+
25+
func TestMain(m *testing.M) {
26+
goleak.VerifyTestMain(m)
27+
}
28+
29+
func TestMockUpstreamServer(t *testing.T) {
30+
t.Parallel()
31+
32+
ctx, cancel := context.WithTimeout(t.Context(), time.Second*30)
33+
t.Cleanup(cancel)
34+
35+
t.Run("serves streaming response", func(t *testing.T) {
36+
t.Parallel()
37+
38+
srv := aibtest.NewMockUpstreamServer(t, ctx, anthropicSimple)
39+
40+
reqBody := srv.Files()[aibtest.FixtureRequest]
41+
reqBody = aibtest.SetStreamingInRequest(t, reqBody, true)
42+
43+
req := aibtest.CreateAnthropicMessagesRequest(t, srv.URL, reqBody)
44+
resp, err := http.DefaultClient.Do(req)
45+
require.NoError(t, err)
46+
defer resp.Body.Close()
47+
48+
require.Equal(t, http.StatusOK, resp.StatusCode)
49+
require.Equal(t, "text/event-stream", resp.Header.Get("Content-Type"))
50+
require.EqualValues(t, 1, srv.CallCount())
51+
})
52+
53+
t.Run("serves non-streaming response", func(t *testing.T) {
54+
t.Parallel()
55+
56+
srv := aibtest.NewMockUpstreamServer(t, ctx, anthropicSimple)
57+
58+
reqBody := srv.Files()[aibtest.FixtureRequest]
59+
reqBody = aibtest.SetStreamingInRequest(t, reqBody, false)
60+
61+
req := aibtest.CreateAnthropicMessagesRequest(t, srv.URL, reqBody)
62+
resp, err := http.DefaultClient.Do(req)
63+
require.NoError(t, err)
64+
defer resp.Body.Close()
65+
66+
require.Equal(t, http.StatusOK, resp.StatusCode)
67+
require.Equal(t, "application/json", resp.Header.Get("Content-Type"))
68+
require.EqualValues(t, 1, srv.CallCount())
69+
})
70+
71+
t.Run("custom status code", func(t *testing.T) {
72+
t.Parallel()
73+
74+
srv := aibtest.NewMockUpstreamServer(t, ctx, anthropicSimple, aibtest.WithStatusCode(http.StatusInternalServerError))
75+
76+
reqBody := srv.Files()[aibtest.FixtureRequest]
77+
req := aibtest.CreateAnthropicMessagesRequest(t, srv.URL, reqBody)
78+
resp, err := http.DefaultClient.Do(req)
79+
require.NoError(t, err)
80+
defer resp.Body.Close()
81+
82+
require.Equal(t, http.StatusInternalServerError, resp.StatusCode)
83+
})
84+
}
85+
86+
func TestMockMCPServer(t *testing.T) {
87+
t.Parallel()
88+
89+
ctx, cancel := context.WithTimeout(t.Context(), time.Second*30)
90+
t.Cleanup(cancel)
91+
92+
t.Run("tracks tool calls", func(t *testing.T) {
93+
t.Parallel()
94+
95+
mcpServer := aibtest.NewMockMCPServer(t, aibtest.TestTracer(),
96+
aibtest.WithToolNames([]string{"test_tool"}),
97+
)
98+
99+
require.NoError(t, mcpServer.Init(ctx))
100+
101+
tools := mcpServer.Proxy.ListTools()
102+
require.Len(t, tools, 1)
103+
require.Equal(t, "test_tool", tools[0].Name)
104+
105+
// Call the tool using the tool ID (which is different from the name)
106+
toolID := tools[0].ID
107+
_, err := mcpServer.Proxy.CallTool(ctx, toolID, map[string]any{"arg": "value"})
108+
require.NoError(t, err)
109+
110+
// GetToolCalls uses tool name
111+
calls := mcpServer.GetToolCalls("test_tool")
112+
require.Len(t, calls, 1)
113+
require.Equal(t, 1, mcpServer.TotalCallCount())
114+
})
115+
116+
t.Run("creates MCP manager", func(t *testing.T) {
117+
t.Parallel()
118+
119+
mcpServer := aibtest.NewMockMCPServer(t, aibtest.TestTracer())
120+
mgr := mcpServer.NewMCPManager(ctx, t, aibtest.TestTracer())
121+
122+
tools := mgr.ListTools()
123+
require.NotEmpty(t, tools)
124+
})
125+
}
126+
127+
func TestMockRecorder(t *testing.T) {
128+
t.Parallel()
129+
130+
ctx := t.Context()
131+
132+
t.Run("records interceptions", func(t *testing.T) {
133+
t.Parallel()
134+
135+
recorder := aibtest.NewMockRecorder()
136+
137+
err := recorder.RecordInterception(ctx, &aibridge.InterceptionRecord{
138+
ID: "test-id",
139+
Model: "test-model",
140+
})
141+
require.NoError(t, err)
142+
143+
interceptions := recorder.Interceptions()
144+
require.Len(t, interceptions, 1)
145+
assert.Equal(t, "test-id", interceptions[0].ID)
146+
})
147+
148+
t.Run("records token usage", func(t *testing.T) {
149+
t.Parallel()
150+
151+
recorder := aibtest.NewMockRecorder()
152+
153+
_ = recorder.RecordTokenUsage(ctx, &aibridge.TokenUsageRecord{Input: 10, Output: 20})
154+
_ = recorder.RecordTokenUsage(ctx, &aibridge.TokenUsageRecord{Input: 5, Output: 15})
155+
156+
assert.EqualValues(t, 15, recorder.TotalInputTokens())
157+
assert.EqualValues(t, 35, recorder.TotalOutputTokens())
158+
})
159+
160+
t.Run("verifies interceptions ended", func(t *testing.T) {
161+
t.Parallel()
162+
163+
recorder := aibtest.NewMockRecorder()
164+
165+
_ = recorder.RecordInterception(ctx, &aibridge.InterceptionRecord{ID: "test-id"})
166+
_ = recorder.RecordInterceptionEnded(ctx, &aibridge.InterceptionRecordEnded{
167+
ID: "test-id",
168+
EndedAt: time.Now(),
169+
})
170+
171+
recorder.VerifyAllInterceptionsEnded(t)
172+
})
173+
}
174+
175+
func TestTestBridge(t *testing.T) {
176+
t.Parallel()
177+
178+
ctx, cancel := context.WithTimeout(t.Context(), time.Second*30)
179+
t.Cleanup(cancel)
180+
181+
t.Run("anthropic request", func(t *testing.T) {
182+
t.Parallel()
183+
184+
upstream := aibtest.NewMockUpstreamServer(t, ctx, anthropicSimple)
185+
186+
bridge := aibtest.NewTestBridge(t, ctx, aibtest.TestBridgeOptions{
187+
Provider: aibridge.NewAnthropicProvider(aibtest.AnthropicConfig(upstream.URL, aibtest.DefaultAPIKey), nil),
188+
})
189+
190+
reqBody := upstream.Files()[aibtest.FixtureRequest]
191+
resp := bridge.DoAnthropicRequest(t, reqBody)
192+
defer resp.Body.Close()
193+
194+
require.Equal(t, http.StatusOK, resp.StatusCode)
195+
196+
body, err := io.ReadAll(resp.Body)
197+
require.NoError(t, err)
198+
199+
// Verify it's valid JSON
200+
var result map[string]any
201+
require.NoError(t, json.Unmarshal(body, &result))
202+
203+
// Verify recorder captured the interception
204+
bridge.Recorder.VerifyAllInterceptionsEnded(t)
205+
})
206+
207+
t.Run("openai request", func(t *testing.T) {
208+
t.Parallel()
209+
210+
upstream := aibtest.NewMockUpstreamServer(t, ctx, openaiSimple)
211+
212+
bridge := aibtest.NewTestBridge(t, ctx, aibtest.TestBridgeOptions{
213+
Provider: aibridge.NewOpenAIProvider(aibtest.OpenAIConfig(upstream.URL, aibtest.DefaultAPIKey)),
214+
})
215+
216+
reqBody := upstream.Files()[aibtest.FixtureRequest]
217+
resp := bridge.DoOpenAIRequest(t, reqBody)
218+
defer resp.Body.Close()
219+
220+
require.Equal(t, http.StatusOK, resp.StatusCode)
221+
222+
body, err := io.ReadAll(resp.Body)
223+
require.NoError(t, err)
224+
225+
// Verify it's valid JSON
226+
var result map[string]any
227+
require.NoError(t, json.Unmarshal(body, &result))
228+
229+
// Verify recorder captured the interception
230+
bridge.Recorder.VerifyAllInterceptionsEnded(t)
231+
})
232+
}

0 commit comments

Comments
 (0)