Skip to content

Commit 18a5811

Browse files
committed
feat(vcr): add JUnit 5 annotation-based VCR integration
Implements declarative VCR support via JUnit 5 annotations: - @VCRModel: marks model fields for automatic VCR wrapping - VCRModelWrapper: wraps LangChain4J and Spring AI models automatically - VCRContext: manages Redis container, cassette store, and test state - VCRExtension: JUnit 5 extension for lifecycle management Features: - VCR_MODE environment variable support for runtime mode override - Automatic model detection (LangChain4J/Spring AI embedding/chat) - Cassette store integration for Redis persistence - Test isolation with per-test call counters - Statistics tracking across test session Example usage: @VCRTest(mode = VCRMode.PLAYBACK_OR_RECORD) class MyTest { @VCRModel private EmbeddingModel model = createModel(); }
1 parent 9aa1647 commit 18a5811

File tree

6 files changed

+419
-13
lines changed

6 files changed

+419
-13
lines changed

core/src/main/java/com/redis/vl/test/vcr/VCRContext.java

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ public class VCRContext {
3535
private GenericContainer<?> redisContainer;
3636
private JedisPooled jedis;
3737
private VCRRegistry registry;
38+
private VCRCassetteStore cassetteStore;
3839

3940
private String currentTestId;
4041
private VCRMode effectiveMode;
@@ -46,15 +47,55 @@ public class VCRContext {
4647
private final AtomicLong cacheMisses = new AtomicLong();
4748
private final AtomicLong apiCalls = new AtomicLong();
4849

50+
/** Environment variable name for overriding VCR mode. */
51+
public static final String VCR_MODE_ENV = "VCR_MODE";
52+
4953
/**
5054
* Creates a new VCR context with the given configuration.
5155
*
56+
* <p>The VCR mode can be overridden via the {@code VCR_MODE} environment variable. Valid values
57+
* are: PLAYBACK, PLAYBACK_OR_RECORD, RECORD, OFF. If the environment variable is set, it takes
58+
* precedence over the annotation's mode setting.
59+
*
5260
* @param config the VCR test configuration
5361
*/
5462
public VCRContext(VCRTest config) {
5563
this.config = config;
5664
this.dataDir = Path.of(config.dataDir());
57-
this.effectiveMode = config.mode();
65+
this.effectiveMode = resolveMode(config.mode());
66+
}
67+
68+
/**
69+
* Resolves the effective VCR mode, checking the environment variable first.
70+
*
71+
* @param annotationMode the mode specified in the annotation
72+
* @return the effective mode (env var takes precedence)
73+
*/
74+
private static VCRMode resolveMode(VCRMode annotationMode) {
75+
String envMode = System.getenv(VCR_MODE_ENV);
76+
if (envMode != null && !envMode.isEmpty()) {
77+
try {
78+
VCRMode mode = VCRMode.valueOf(envMode.toUpperCase());
79+
System.out.println(
80+
"VCR: Using mode from "
81+
+ VCR_MODE_ENV
82+
+ " environment variable: "
83+
+ mode
84+
+ " (annotation was: "
85+
+ annotationMode
86+
+ ")");
87+
return mode;
88+
} catch (IllegalArgumentException e) {
89+
System.err.println(
90+
"VCR: Invalid "
91+
+ VCR_MODE_ENV
92+
+ " value '"
93+
+ envMode
94+
+ "'. Valid values: PLAYBACK, PLAYBACK_OR_RECORD, RECORD, OFF. Using annotation value: "
95+
+ annotationMode);
96+
}
97+
}
98+
return annotationMode;
5899
}
59100

60101
/**
@@ -69,8 +110,21 @@ public void initialize() throws Exception {
69110
// Start Redis container with persistence
70111
startRedis();
71112

72-
// Initialize registry
113+
// Initialize registry and cassette store
73114
registry = new VCRRegistry(jedis);
115+
cassetteStore = new VCRCassetteStore(jedis);
116+
}
117+
118+
/**
119+
* Gets the cassette store for storing/retrieving cassettes.
120+
*
121+
* @return the cassette store
122+
*/
123+
@SuppressFBWarnings(
124+
value = "EI_EXPOSE_REP",
125+
justification = "Callers need direct access to shared cassette store")
126+
public VCRCassetteStore getCassetteStore() {
127+
return cassetteStore;
74128
}
75129

76130
/** Starts the Redis container with appropriate persistence configuration. */

core/src/main/java/com/redis/vl/test/vcr/VCRExtension.java

Lines changed: 113 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
package com.redis.vl.test.vcr;
22

3+
import java.lang.reflect.Field;
4+
import java.util.ArrayList;
5+
import java.util.List;
36
import org.junit.jupiter.api.extension.AfterAllCallback;
47
import org.junit.jupiter.api.extension.AfterEachCallback;
58
import org.junit.jupiter.api.extension.BeforeAllCallback;
69
import org.junit.jupiter.api.extension.BeforeEachCallback;
710
import org.junit.jupiter.api.extension.ExtensionContext;
811
import org.junit.jupiter.api.extension.TestWatcher;
12+
import org.slf4j.Logger;
13+
import org.slf4j.LoggerFactory;
914

1015
/**
1116
* JUnit 5 extension that provides VCR (Video Cassette Recorder) functionality for recording and
@@ -17,17 +22,33 @@
1722
* <li>Redis container lifecycle with AOF/RDB persistence
1823
* <li>Cassette storage and retrieval
1924
* <li>Test context and call counter management
20-
* <li>LLM call interception via ByteBuddy
25+
* <li>Automatic wrapping of {@code @VCRModel} annotated fields
2126
* </ul>
2227
*
23-
* <p>Usage:
28+
* <p>Usage with declarative field wrapping:
2429
*
2530
* <pre>{@code
26-
* @VCRTest(mode = VCRMode.PLAYBACK)
31+
* @VCRTest(mode = VCRMode.PLAYBACK_OR_RECORD)
2732
* class MyLLMTest {
33+
*
34+
* @VCRModel
35+
* private EmbeddingModel embeddingModel;
36+
*
37+
* @VCRModel
38+
* private ChatModel chatModel;
39+
*
40+
* @BeforeEach
41+
* void setup() {
42+
* // Initialize models normally - VCR wraps them automatically
43+
* embeddingModel = new OpenAiEmbeddingModel(...);
44+
* chatModel = new OpenAiChatModel(...);
45+
* }
46+
*
2847
* @Test
2948
* void testLLMCall() {
3049
* // LLM calls are automatically recorded/replayed
50+
* embeddingModel.embed("Hello");
51+
* chatModel.generate("What is Redis?");
3152
* }
3253
* }
3354
* }</pre>
@@ -39,6 +60,8 @@ public class VCRExtension
3960
AfterEachCallback,
4061
TestWatcher {
4162

63+
private static final Logger LOG = LoggerFactory.getLogger(VCRExtension.class);
64+
4265
private static final ExtensionContext.Namespace NAMESPACE =
4366
ExtensionContext.Namespace.create(VCRExtension.class);
4467

@@ -80,13 +103,97 @@ public void beforeEach(ExtensionContext extensionContext) throws Exception {
80103
// Check for method-level mode overrides
81104
var method = extensionContext.getRequiredTestMethod();
82105

106+
VCRMode effectiveMode;
83107
if (method.isAnnotationPresent(VCRDisabled.class)) {
84-
context.setEffectiveMode(VCRMode.OFF);
108+
effectiveMode = VCRMode.OFF;
85109
} else if (method.isAnnotationPresent(VCRRecord.class)) {
86-
context.setEffectiveMode(VCRMode.RECORD);
110+
effectiveMode = VCRMode.RECORD;
87111
} else {
88112
// Use class-level or default mode
89-
context.setEffectiveMode(context.getConfiguredMode());
113+
effectiveMode = context.getConfiguredMode();
114+
}
115+
context.setEffectiveMode(effectiveMode);
116+
117+
// Wrap @VCRModel annotated fields with cassette store
118+
wrapAnnotatedFields(extensionContext, testId, effectiveMode, context.getCassetteStore());
119+
}
120+
121+
/**
122+
* Scans the test instance for fields annotated with {@code @VCRModel} and wraps them with VCR
123+
* interceptors.
124+
*/
125+
private void wrapAnnotatedFields(
126+
ExtensionContext extensionContext,
127+
String testId,
128+
VCRMode mode,
129+
VCRCassetteStore cassetteStore) {
130+
131+
if (mode == VCRMode.OFF) {
132+
return; // Don't wrap if VCR is disabled
133+
}
134+
135+
Object testInstance = extensionContext.getRequiredTestInstance();
136+
Class<?> testClass = testInstance.getClass();
137+
138+
// Collect all fields including from parent classes
139+
List<Field> allFields = new ArrayList<>();
140+
Class<?> currentClass = testClass;
141+
while (currentClass != null && currentClass != Object.class) {
142+
for (Field field : currentClass.getDeclaredFields()) {
143+
allFields.add(field);
144+
}
145+
currentClass = currentClass.getSuperclass();
146+
}
147+
148+
// Also check fields from nested test classes
149+
Class<?> enclosingClass = testClass.getEnclosingClass();
150+
if (enclosingClass != null) {
151+
// For nested classes, we need to access the outer instance
152+
for (Field field : enclosingClass.getDeclaredFields()) {
153+
if (field.isAnnotationPresent(VCRModel.class)) {
154+
wrapFieldInEnclosingInstance(
155+
testInstance, enclosingClass, field, testId, mode, cassetteStore);
156+
}
157+
}
158+
}
159+
160+
// Wrap fields in the test instance
161+
for (Field field : allFields) {
162+
if (field.isAnnotationPresent(VCRModel.class)) {
163+
VCRModel annotation = field.getAnnotation(VCRModel.class);
164+
VCRModelWrapper.wrapField(
165+
testInstance, field, testId, mode, annotation.modelName(), cassetteStore);
166+
}
167+
}
168+
}
169+
170+
/** Wraps a field in the enclosing instance of a nested test class. */
171+
@SuppressWarnings("java:S3011") // Reflection access is intentional
172+
private void wrapFieldInEnclosingInstance(
173+
Object nestedInstance,
174+
Class<?> enclosingClass,
175+
Field field,
176+
String testId,
177+
VCRMode mode,
178+
VCRCassetteStore cassetteStore) {
179+
try {
180+
// Find the synthetic field that holds reference to the enclosing instance
181+
for (Field syntheticField : nestedInstance.getClass().getDeclaredFields()) {
182+
if (syntheticField.getName().startsWith("this$")
183+
&& syntheticField.getType().equals(enclosingClass)) {
184+
syntheticField.setAccessible(true);
185+
Object enclosingInstance = syntheticField.get(nestedInstance);
186+
187+
if (enclosingInstance != null) {
188+
VCRModel annotation = field.getAnnotation(VCRModel.class);
189+
VCRModelWrapper.wrapField(
190+
enclosingInstance, field, testId, mode, annotation.modelName(), cassetteStore);
191+
}
192+
break;
193+
}
194+
}
195+
} catch (IllegalAccessException e) {
196+
LOG.warn("Failed to access enclosing instance: {}", e.getMessage());
90197
}
91198
}
92199

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package com.redis.vl.test.vcr;
2+
3+
import java.lang.annotation.ElementType;
4+
import java.lang.annotation.Retention;
5+
import java.lang.annotation.RetentionPolicy;
6+
import java.lang.annotation.Target;
7+
8+
/**
9+
* Marks a field to be automatically wrapped with VCR recording/playback functionality.
10+
*
11+
* <p>When applied to an {@code EmbeddingModel} or {@code ChatModel} field, the VCR extension will
12+
* automatically wrap the model with the appropriate VCR wrapper after it's initialized.
13+
*
14+
* <p>Usage:
15+
*
16+
* <pre>{@code
17+
* @VCRTest(mode = VCRMode.PLAYBACK_OR_RECORD)
18+
* class MyLLMTest {
19+
*
20+
* @VCRModel
21+
* private EmbeddingModel embeddingModel;
22+
*
23+
* @VCRModel
24+
* private ChatModel chatModel;
25+
*
26+
* @BeforeEach
27+
* void setup() {
28+
* // Initialize your models normally - VCR will wrap them automatically
29+
* embeddingModel = new OpenAiEmbeddingModel(...);
30+
* chatModel = new OpenAiChatModel(...);
31+
* }
32+
*
33+
* @Test
34+
* void testEmbedding() {
35+
* // Use the model - calls are recorded/replayed transparently
36+
* float[] embedding = embeddingModel.embed("Hello").content();
37+
* }
38+
* }
39+
* }</pre>
40+
*
41+
* <p>Supported model types:
42+
*
43+
* <ul>
44+
* <li>LangChain4J: {@code dev.langchain4j.model.embedding.EmbeddingModel}, {@code
45+
* dev.langchain4j.model.chat.ChatLanguageModel}
46+
* <li>Spring AI: {@code org.springframework.ai.embedding.EmbeddingModel}, {@code
47+
* org.springframework.ai.chat.model.ChatModel}
48+
* </ul>
49+
*/
50+
@Retention(RetentionPolicy.RUNTIME)
51+
@Target(ElementType.FIELD)
52+
public @interface VCRModel {
53+
54+
/**
55+
* Optional model name for embedding cache key generation. If not specified, the field name will
56+
* be used.
57+
*/
58+
String modelName() default "";
59+
}

0 commit comments

Comments
 (0)