Skip to content

Commit 1978fd8

Browse files
authored
tests: add integration test suite (llm-d#177)
* tests: add integration test suite Signed-off-by: Vishwanath Suresh <vsuresh@redhat.com> * fix lint issues Signed-off-by: Vishwanath Suresh <vsuresh@redhat.com> * modify some integration tests Signed-off-by: Vishwanath Suresh <vsuresh@redhat.com> --------- Signed-off-by: Vishwanath Suresh <vsuresh@redhat.com>
1 parent 068584e commit 1978fd8

10 files changed

Lines changed: 943 additions & 0 deletions

.github/workflows/ci-pr-checks.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ jobs:
5858
- name: Test
5959
run: make test
6060

61+
- name: Integration tests
62+
run: make test-integration
63+
6164
# Python: lint (only if Python files exist)
6265
python-lint:
6366
runs-on: ubuntu-latest

CONTRIBUTING.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,32 @@ We use three tiers of testing:
8888

8989
Strong e2e coverage is required for deployed systems to prevent performance regression. Appropriate test coverage is an important part of code review.
9090

91+
### Running Tests Locally
92+
93+
```bash
94+
# Unit tests (default, no external dependencies)
95+
make test
96+
97+
# Integration tests (each test spawns its own in-process mock server)
98+
make test-integration
99+
100+
# Both unit and integration tests
101+
make test-all
102+
103+
# E2E tests (requires docker/podman, kind, helm, kubectl)
104+
make test-e2e
105+
```
106+
107+
Integration tests use the `//go:build integration` build tag and live in `test/integration/`. They are excluded from `make test` and only run when the tag is explicitly passed via `make test-integration`. Each test spawns its own mock server (miniredis, `httptest.Server`) — no external services are required.
108+
109+
To add a new integration test, create a `*_test.go` file in `test/integration/` with the build tag:
110+
111+
```go
112+
//go:build integration
113+
114+
package integration_test
115+
```
116+
91117
## Security
92118

93119
See [SECURITY.md](SECURITY.md) for our vulnerability disclosure process.

Makefile

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,16 @@ test-e2e: ## Run e2e integration tests against a Kind cluster
177177
$(if $(FOCUS),-ginkgo.focus="$(FOCUS)",) \
178178
$(if $(SKIP),-ginkgo.skip="$(SKIP)",)
179179

180+
.PHONY: test-integration
181+
test-integration: ## Run integration tests (each test spawns its own mock server)
182+
@echo "Running integration tests..."
183+
@go test -v -tags=integration ./test/integration/ || \
184+
(echo "❌ Integration tests failed" && exit 1)
185+
@echo "✅ Integration tests passed!"
186+
187+
.PHONY: test-all
188+
test-all: test test-integration ## Run all tests (unit + integration)
189+
180190
.PHONY: check-dco
181191
check-dco: ## Check that all commits since main have a DCO Signed-off-by trailer
182192
@scripts/check-dco.sh
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
//go:build integration
2+
3+
package integration_test
4+
5+
import (
6+
"context"
7+
"testing"
8+
9+
"github.com/alicebob/miniredis/v2"
10+
"github.com/llm-d-incubation/llm-d-async/pipeline"
11+
"github.com/llm-d-incubation/llm-d-async/pkg/async/inference/flowcontrol"
12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
14+
)
15+
16+
// TestGateFactory_RedisQuota_ConcurrencyParsing validates that GateFactory
17+
// correctly parses "redis-quota" params and produces a working AttributeGate.
18+
// Packages exercised: flowcontrol (GateFactory) -> pkg/redis (RedisQuotaGate).
19+
func TestGateFactory_RedisQuota_ConcurrencyParsing(t *testing.T) {
20+
s := miniredis.RunT(t)
21+
factory := flowcontrol.NewGateFactory("")
22+
23+
gate, err := factory.CreateGate("redis-quota", map[string]string{
24+
"address": s.Addr(),
25+
"attribute": "model",
26+
"mode": "concurrency",
27+
"limit": "2",
28+
"window": "30s",
29+
"prefix": "test:",
30+
})
31+
require.NoError(t, err)
32+
require.NotNil(t, gate)
33+
34+
// Budget always returns 1.0 for quota gates.
35+
assert.Equal(t, 1.0, gate.Budget(context.Background()))
36+
37+
attrGate, ok := gate.(pipeline.AttributeGate)
38+
require.True(t, ok, "redis-quota gate should implement AttributeGate")
39+
40+
ctx := context.Background()
41+
attrs := map[string]string{"model": "gpt-4"}
42+
43+
// Acquire twice (limit=2) — both should succeed.
44+
ok1, rel1, err := attrGate.Acquire(ctx, attrs)
45+
require.NoError(t, err)
46+
assert.True(t, ok1)
47+
48+
ok2, rel2, err := attrGate.Acquire(ctx, attrs)
49+
require.NoError(t, err)
50+
assert.True(t, ok2)
51+
52+
// Third acquire — should fail.
53+
ok3, _, err := attrGate.Acquire(ctx, attrs)
54+
require.NoError(t, err)
55+
assert.False(t, ok3, "Third acquire should be denied (limit=2)")
56+
57+
// Release one and retry.
58+
rel1()
59+
ok4, rel4, err := attrGate.Acquire(ctx, attrs)
60+
require.NoError(t, err)
61+
assert.True(t, ok4, "Should succeed after release")
62+
63+
// Cleanup.
64+
rel2()
65+
if rel4 != nil {
66+
rel4()
67+
}
68+
}
69+
70+
// TestGateFactory_RedisQuota_RateLimitParsing validates rate-limit mode parsing.
71+
func TestGateFactory_RedisQuota_RateLimitParsing(t *testing.T) {
72+
s := miniredis.RunT(t)
73+
factory := flowcontrol.NewGateFactory("")
74+
75+
gate, err := factory.CreateGate("redis-quota", map[string]string{
76+
"address": s.Addr(),
77+
"mode": "rate-limit",
78+
"limit": "3",
79+
"window": "1m",
80+
})
81+
require.NoError(t, err)
82+
require.NotNil(t, gate)
83+
84+
attrGate, ok := gate.(pipeline.AttributeGate)
85+
require.True(t, ok)
86+
87+
ctx := context.Background()
88+
attrs := map[string]string{"userid": "alice"}
89+
90+
for i := 0; i < 3; i++ {
91+
ok, _, err := attrGate.Acquire(ctx, attrs)
92+
require.NoError(t, err)
93+
assert.True(t, ok, "Request %d should be allowed", i+1)
94+
}
95+
96+
// Fourth should be rate limited.
97+
ok4, _, err := attrGate.Acquire(ctx, attrs)
98+
require.NoError(t, err)
99+
assert.False(t, ok4, "Fourth request should be rate limited")
100+
}
101+
102+
// TestGateFactory_RedisQuota_MissingParams validates error handling for missing
103+
// required parameters.
104+
func TestGateFactory_RedisQuota_MissingParams(t *testing.T) {
105+
factory := flowcontrol.NewGateFactory("")
106+
107+
_, err := factory.CreateGate("redis-quota", map[string]string{
108+
"limit": "5",
109+
})
110+
assert.Error(t, err, "Should fail when address is missing")
111+
112+
s := miniredis.RunT(t)
113+
_, err = factory.CreateGate("redis-quota", map[string]string{
114+
"address": s.Addr(),
115+
})
116+
assert.Error(t, err, "Should fail when limit is missing")
117+
}
118+
119+
// TestGateFactory_RedisQuota_DefaultParams verifies that omitted optional params
120+
// fall back to documented defaults (attribute=userid, mode=rate-limit, etc.).
121+
func TestGateFactory_RedisQuota_DefaultParams(t *testing.T) {
122+
s := miniredis.RunT(t)
123+
factory := flowcontrol.NewGateFactory("")
124+
125+
gate, err := factory.CreateGate("redis-quota", map[string]string{
126+
"address": s.Addr(),
127+
"limit": "1",
128+
})
129+
require.NoError(t, err)
130+
131+
attrGate, ok := gate.(pipeline.AttributeGate)
132+
require.True(t, ok)
133+
134+
// Default attribute is "userid", default mode is "rate-limit".
135+
ctx := context.Background()
136+
ok1, _, err := attrGate.Acquire(ctx, map[string]string{"userid": "bob"})
137+
require.NoError(t, err)
138+
assert.True(t, ok1)
139+
140+
ok2, _, err := attrGate.Acquire(ctx, map[string]string{"userid": "bob"})
141+
require.NoError(t, err)
142+
assert.False(t, ok2, "Second acquire should be rate limited with default params")
143+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
//go:build integration
2+
3+
package integration_test
4+
5+
import (
6+
"sync"
7+
"testing"
8+
"time"
9+
10+
asyncapi "github.com/llm-d-incubation/llm-d-async/api"
11+
"github.com/llm-d-incubation/llm-d-async/pipeline"
12+
ap "github.com/llm-d-incubation/llm-d-async/pkg/async"
13+
"github.com/stretchr/testify/assert"
14+
)
15+
16+
// TestRandomRobinPolicy_ConcurrentProducers validates that RandomRobinPolicy
17+
// correctly merges messages from multiple channels when multiple goroutines
18+
// are producing concurrently. Every message sent must appear exactly once
19+
// on the merged output channel.
20+
func TestRandomRobinPolicy_ConcurrentProducers(t *testing.T) {
21+
const numChannels = 4
22+
const msgsPerChannel = 25
23+
24+
channels := make([]pipeline.RequestChannel, numChannels)
25+
for i := range numChannels {
26+
channels[i] = pipeline.RequestChannel{
27+
Channel: make(chan *asyncapi.InternalRequest, msgsPerChannel),
28+
IGWBaseURL: "http://localhost:8080",
29+
InferenceObjective: "latency",
30+
RequestPathURL: "/v1/completions",
31+
}
32+
}
33+
34+
policy := ap.NewRandomRobinPolicy()
35+
merged := policy.MergeRequestChannels(channels)
36+
37+
// Produce messages concurrently on all channels.
38+
var wg sync.WaitGroup
39+
for chIdx := range numChannels {
40+
wg.Add(1)
41+
go func(idx int) {
42+
defer wg.Done()
43+
for m := range msgsPerChannel {
44+
ir := asyncapi.NewInternalRequest(
45+
asyncapi.InternalRouting{},
46+
&asyncapi.RequestMessage{
47+
ID: msgID(idx, m),
48+
Created: time.Now().Unix(),
49+
Deadline: time.Now().Add(time.Minute).Unix(),
50+
Payload: map[string]any{"model": "test"},
51+
},
52+
)
53+
channels[idx].Channel <- ir
54+
}
55+
close(channels[idx].Channel)
56+
}(chIdx)
57+
}
58+
59+
// Consume all messages from the merged channel.
60+
received := make(map[string]bool)
61+
done := make(chan struct{})
62+
go func() {
63+
for msg := range merged.Channel {
64+
received[msg.InternalRequest.PublicRequest.ReqID()] = true
65+
}
66+
close(done)
67+
}()
68+
69+
wg.Wait()
70+
select {
71+
case <-done:
72+
case <-time.After(10 * time.Second):
73+
t.Fatal("Timed out waiting for merged channel to close")
74+
}
75+
76+
expected := numChannels * msgsPerChannel
77+
assert.Equal(t, expected, len(received), "Expected %d unique messages, got %d", expected, len(received))
78+
79+
// Verify every expected message was received.
80+
for chIdx := range numChannels {
81+
for m := range msgsPerChannel {
82+
id := msgID(chIdx, m)
83+
assert.True(t, received[id], "Missing message %s", id)
84+
}
85+
}
86+
}
87+
88+
func msgID(channelIdx, msgIdx int) string {
89+
return "ch" + itoa(channelIdx) + "-msg" + itoa(msgIdx)
90+
}
91+
92+
func itoa(i int) string {
93+
const digits = "0123456789"
94+
if i < 10 {
95+
return string(digits[i])
96+
}
97+
return itoa(i/10) + string(digits[i%10])
98+
}

0 commit comments

Comments
 (0)