|
| 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) |
0 commit comments