Skip to content

Commit 8313b05

Browse files
committed
feat(demo): add Spring AI VCR demo project
Adds a complete demo project showing VCR usage with Spring AI: - SpringAIVCRDemoTest: demonstrates embedding and chat model recording - Pre-recorded cassettes in src/test/resources/vcr-data/ - README with usage instructions and best practices Demo tests: - Single and batch text embedding - Chat completion with various prompts - Combined RAG-style workflow (embed + generate) Run without API key using pre-recorded cassettes: ./gradlew :demos:spring-ai-vcr:test
1 parent c760fd8 commit 8313b05

File tree

7 files changed

+1300
-0
lines changed

7 files changed

+1300
-0
lines changed

demos/spring-ai-vcr/README.md

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
# Spring AI VCR Demo
2+
3+
This demo shows how to use the VCR (Video Cassette Recorder) test system with Spring AI models. VCR records LLM/embedding API responses to Redis and replays them in subsequent test runs, enabling fast, deterministic, and cost-effective testing.
4+
5+
## Features
6+
7+
- Record and replay Spring AI `EmbeddingModel` responses
8+
- Record and replay Spring AI `ChatModel` responses
9+
- Declarative `@VCRTest` and `@VCRModel` annotations
10+
- Automatic model wrapping via JUnit 5 extension
11+
- Redis-backed persistence with automatic test isolation
12+
13+
## Quick Start
14+
15+
### 1. Annotate Your Test Class
16+
17+
```java
18+
import com.redis.vl.test.vcr.VCRMode;
19+
import com.redis.vl.test.vcr.VCRModel;
20+
import com.redis.vl.test.vcr.VCRTest;
21+
22+
@VCRTest(mode = VCRMode.PLAYBACK_OR_RECORD)
23+
class MySpringAITest {
24+
25+
@VCRModel(modelName = "text-embedding-3-small")
26+
private EmbeddingModel embeddingModel = createEmbeddingModel();
27+
28+
@VCRModel
29+
private ChatModel chatModel = createChatModel();
30+
31+
// Models must be initialized at field declaration time,
32+
// not in @BeforeEach (VCR wrapping happens before @BeforeEach)
33+
}
34+
```
35+
36+
### 2. Use Models Normally
37+
38+
```java
39+
@Test
40+
void shouldEmbedText() {
41+
// First run: calls real API and records response
42+
// Subsequent runs: replays from Redis cassette
43+
EmbeddingResponse response = embeddingModel.embedForResponse(
44+
List.of("What is Redis?")
45+
);
46+
47+
assertNotNull(response.getResults().get(0));
48+
}
49+
50+
@Test
51+
void shouldGenerateResponse() {
52+
String response = chatModel.call("Explain Redis in one sentence.");
53+
54+
assertNotNull(response);
55+
}
56+
```
57+
58+
## VCR Modes
59+
60+
| Mode | Description | API Key Required |
61+
|------|-------------|------------------|
62+
| `PLAYBACK` | Only use recorded cassettes. Fails if cassette missing. | No |
63+
| `PLAYBACK_OR_RECORD` | Use cassette if available, record if not. | Only for first run |
64+
| `RECORD` | Always call real API and record response. | Yes |
65+
| `OFF` | Bypass VCR, always call real API. | Yes |
66+
67+
### Setting Mode via Environment Variable
68+
69+
Override the annotation mode at runtime without changing code:
70+
71+
```bash
72+
# Record new cassettes
73+
VCR_MODE=RECORD ./gradlew :demos:spring-ai-vcr:test
74+
75+
# Playback only (CI/CD, no API key needed)
76+
VCR_MODE=PLAYBACK ./gradlew :demos:spring-ai-vcr:test
77+
78+
# Default behavior from annotation
79+
./gradlew :demos:spring-ai-vcr:test
80+
```
81+
82+
## Running the Demo
83+
84+
### With Pre-recorded Cassettes (No API Key)
85+
86+
The demo includes pre-recorded cassettes in `src/test/resources/vcr-data/`. Run tests without an API key:
87+
88+
```bash
89+
./gradlew :demos:spring-ai-vcr:test
90+
```
91+
92+
### Recording New Cassettes
93+
94+
To record fresh cassettes, set your OpenAI API key:
95+
96+
```bash
97+
OPENAI_API_KEY=your-key VCR_MODE=RECORD ./gradlew :demos:spring-ai-vcr:test
98+
```
99+
100+
## How It Works
101+
102+
1. **Test Setup**: `@VCRTest` annotation triggers the VCR JUnit 5 extension
103+
2. **Container Start**: A Redis Stack container is started with persistence enabled
104+
3. **Model Wrapping**: Fields annotated with `@VCRModel` are wrapped with VCR proxies
105+
4. **Recording**: When a model is called, VCR checks for existing cassette:
106+
- **Cache hit**: Returns recorded response
107+
- **Cache miss**: Calls real API, stores response as cassette
108+
5. **Persistence**: Cassettes are saved to `vcr-data/` directory via Redis persistence
109+
6. **Cleanup**: Container stops, data persists for next run
110+
111+
## Cassette Storage
112+
113+
Cassettes are stored in Redis JSON format with keys like:
114+
115+
```
116+
vcr:embedding:MyTest.testMethod:0001
117+
vcr:chat:MyTest.testMethod:0001
118+
```
119+
120+
Data persists to `src/test/resources/vcr-data/` via Redis AOF/RDB.
121+
122+
## Test Structure
123+
124+
```
125+
demos/spring-ai-vcr/
126+
├── src/test/java/
127+
│ └── com/redis/vl/demo/vcr/
128+
│ └── SpringAIVCRDemoTest.java
129+
└── src/test/resources/
130+
└── vcr-data/ # Persisted cassettes
131+
├── appendonly.aof
132+
└── dump.rdb
133+
```
134+
135+
## Configuration Options
136+
137+
### @VCRTest Annotation
138+
139+
| Parameter | Default | Description |
140+
|-----------|---------|-------------|
141+
| `mode` | `PLAYBACK_OR_RECORD` | VCR operating mode |
142+
| `dataDir` | `src/test/resources/vcr-data` | Cassette storage directory |
143+
| `redisImage` | `redis/redis-stack:latest` | Redis Docker image |
144+
145+
### @VCRModel Annotation
146+
147+
| Parameter | Default | Description |
148+
|-----------|---------|-------------|
149+
| `modelName` | `""` | Optional model identifier for logging |
150+
151+
## Spring AI Specifics
152+
153+
### Supported Model Types
154+
155+
- `org.springframework.ai.embedding.EmbeddingModel`
156+
- `org.springframework.ai.chat.model.ChatModel`
157+
158+
### Creating Models for VCR
159+
160+
```java
161+
private static String getApiKey() {
162+
String key = System.getenv("OPENAI_API_KEY");
163+
// In PLAYBACK mode, use a dummy key (VCR will use cached responses)
164+
return (key == null || key.isEmpty()) ? "vcr-playback-mode" : key;
165+
}
166+
167+
private static EmbeddingModel createEmbeddingModel() {
168+
OpenAiApi api = OpenAiApi.builder().apiKey(getApiKey()).build();
169+
OpenAiEmbeddingOptions options = OpenAiEmbeddingOptions.builder()
170+
.model("text-embedding-3-small")
171+
.build();
172+
return new OpenAiEmbeddingModel(api, MetadataMode.EMBED, options,
173+
RetryUtils.DEFAULT_RETRY_TEMPLATE);
174+
}
175+
176+
private static ChatModel createChatModel() {
177+
OpenAiApi api = OpenAiApi.builder().apiKey(getApiKey()).build();
178+
OpenAiChatOptions options = OpenAiChatOptions.builder()
179+
.model("gpt-4o-mini")
180+
.temperature(0.0)
181+
.build();
182+
return OpenAiChatModel.builder()
183+
.openAiApi(api)
184+
.defaultOptions(options)
185+
.build();
186+
}
187+
```
188+
189+
## Best Practices
190+
191+
1. **Initialize models at field declaration** - Not in `@BeforeEach`
192+
2. **Use dummy API key in PLAYBACK mode** - VCR will use cached responses
193+
3. **Commit cassettes to version control** - Enables reproducible tests
194+
4. **Use specific test names** - Cassette keys include test class and method names
195+
5. **Re-record periodically** - API responses may change over time
196+
6. **Set temperature to 0** - For deterministic LLM responses during recording
197+
198+
## Troubleshooting
199+
200+
### Tests fail with "Cassette missing"
201+
202+
- Ensure cassettes exist in `src/test/resources/vcr-data/`
203+
- Run once with `VCR_MODE=RECORD` and API key to generate cassettes
204+
205+
### API key required error
206+
207+
- In `PLAYBACK` mode, use a dummy key: `"vcr-playback-mode"`
208+
- VCR won't call the real API when cassettes exist
209+
210+
### Tests pass but call real API
211+
212+
- Verify models are initialized at field declaration, not `@BeforeEach`
213+
- Check that `@VCRModel` annotation is present on model fields
214+
215+
### Spring AI version compatibility
216+
217+
- VCR wrappers implement Spring AI interfaces
218+
- Test with your specific Spring AI version for compatibility
219+
220+
## See Also
221+
222+
- [LangChain4J VCR Demo](../langchain4j-vcr/README.md)
223+
- [VCR Test System Documentation](../../README.md#-experimental-vcr-test-system)
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
plugins {
2+
java
3+
}
4+
5+
group = "com.redis.vl.demo"
6+
version = "0.12.0"
7+
8+
java {
9+
sourceCompatibility = JavaVersion.VERSION_21
10+
targetCompatibility = JavaVersion.VERSION_21
11+
toolchain {
12+
languageVersion = JavaLanguageVersion.of(21)
13+
}
14+
}
15+
16+
repositories {
17+
mavenCentral()
18+
maven {
19+
url = uri("https://repo.spring.io/milestone")
20+
}
21+
}
22+
23+
dependencies {
24+
// RedisVL Core (includes VCR support)
25+
implementation(project(":core"))
26+
27+
// SpotBugs annotations
28+
compileOnly("com.github.spotbugs:spotbugs-annotations:4.8.3")
29+
30+
// Spring AI 1.1.0
31+
implementation(platform("org.springframework.ai:spring-ai-bom:1.1.0"))
32+
implementation("org.springframework.ai:spring-ai-openai")
33+
34+
// Redis
35+
implementation("redis.clients:jedis:5.2.0")
36+
37+
// Logging
38+
implementation("org.slf4j:slf4j-api:2.0.16")
39+
runtimeOnly("ch.qos.logback:logback-classic:1.5.15")
40+
41+
// Testing
42+
testImplementation("org.junit.jupiter:junit-jupiter:5.11.4")
43+
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
44+
testCompileOnly("com.github.spotbugs:spotbugs-annotations:4.8.3")
45+
46+
// TestContainers for integration tests
47+
testImplementation("org.testcontainers:testcontainers:1.19.3")
48+
testImplementation("org.testcontainers:junit-jupiter:1.19.3")
49+
}
50+
51+
tasks.withType<JavaCompile> {
52+
options.encoding = "UTF-8"
53+
options.compilerArgs.addAll(listOf(
54+
"-parameters",
55+
"-Xlint:all",
56+
"-Xlint:-processing"
57+
))
58+
}
59+
60+
tasks.withType<Test> {
61+
useJUnitPlatform()
62+
testLogging {
63+
events("passed", "skipped", "failed")
64+
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
65+
}
66+
// Pass environment variables to tests
67+
environment("OPENAI_API_KEY", System.getenv("OPENAI_API_KEY") ?: "")
68+
}

0 commit comments

Comments
 (0)