Skip to content

Commit ddbea0b

Browse files
feat(test): add LLM test support abstraction for graceful skipping
Add a new test support package with utilities for handling LLM-dependent tests: - @RequiresLlm annotation: marks tests that need LLM configuration - Supports chat and embedding requirements independently - Tests are skipped (not failed) when LLM is not configured - Works at class or method level - LlmAvailabilityCondition: JUnit 5 ExecutionCondition - Checks DEEPSQL_CHAT_* and DEEPSQL_EMBEDDING_* environment variables - Uses the same resolution logic as production LlmConfigResolver - LlmTestSupport: Spring component for integration tests - Uses actual LlmConfigResolver to check both env vars and DB config - Provides requireChat(), requireEmbedding() methods for test assertions - Includes diagnostic describeConfiguration() method Updated BaseIntegrationTest: - Autowires LlmTestSupport for integration tests - Adds convenience methods: isChatLlmAvailable(), requireChatLlm(), etc. Updated ChatPromptIntegrationTest: - Added @RequiresLlm(chat = true) annotation - All 23 tests now skip gracefully when LLM is not configured - Improved Javadoc documentation This allows the test suite to run cleanly in environments without LLM credentials, while still validating LLM-dependent functionality when credentials are available. Co-authored-by: Venkat SF <venkatesh.sakamuri@stayflexi.com>
1 parent cd2b2ca commit ddbea0b

6 files changed

Lines changed: 485 additions & 15 deletions

File tree

backend/src/test/java/com/dbaagent/integration/BaseIntegrationTest.java

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.dbaagent.model.DatabaseConnection;
44
import com.dbaagent.service.CredentialService;
5+
import com.dbaagent.support.LlmTestSupport;
56
import org.opentest4j.TestAbortedException;
67
import org.junit.jupiter.api.BeforeEach;
78
import org.springframework.beans.factory.annotation.Autowired;
@@ -15,6 +16,19 @@
1516
/**
1617
* Base class for integration tests that test actual controllers
1718
* with real database connections (no mocking).
19+
*
20+
* <p>Provides utilities for:
21+
* <ul>
22+
* <li>Finding and requiring test database connections</li>
23+
* <li>Checking and requiring LLM configuration via {@link LlmTestSupport}</li>
24+
* <li>Building API paths with the correct context path</li>
25+
* </ul>
26+
*
27+
* <p>For tests that require LLM, use either:
28+
* <ul>
29+
* <li>{@code @RequiresLlm} annotation at class or method level (skips before Spring context loads)</li>
30+
* <li>{@code requireChatLlm()} / {@code requireEmbeddingLlm()} methods (checks after context loads)</li>
31+
* </ul>
1832
*/
1933
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
2034
@ActiveProfiles("test")
@@ -27,17 +41,17 @@ public abstract class BaseIntegrationTest {
2741
@Autowired
2842
protected CredentialService credentialService;
2943

44+
@Autowired
45+
protected LlmTestSupport llmTestSupport;
46+
3047
protected MockMvc mockMvc;
3148

3249
@Value("${test.connection.id}")
3350
protected String testConnectionId;
3451

3552
@BeforeEach
3653
void setUp() {
37-
// Configure MockMvc
3854
mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build();
39-
40-
// Subclasses can override this method to add custom setup
4155
}
4256

4357
protected String getTestConnectionId() {
@@ -84,4 +98,54 @@ protected String apiPath(String path) {
8498
}
8599
return path.startsWith(CONTEXT_PATH) ? path : CONTEXT_PATH + path;
86100
}
101+
102+
/**
103+
* Checks if chat LLM is configured and available.
104+
* Uses the actual {@link com.dbaagent.llm.LlmConfigResolver} which checks
105+
* both environment variables and database configuration.
106+
*
107+
* @return true if chat LLM credentials are configured
108+
*/
109+
protected boolean isChatLlmAvailable() {
110+
return llmTestSupport.isChatAvailable();
111+
}
112+
113+
/**
114+
* Checks if embedding LLM is configured and available.
115+
* Uses the actual {@link com.dbaagent.llm.LlmConfigResolver} which checks
116+
* both environment variables and database configuration.
117+
*
118+
* @return true if embedding LLM credentials are configured
119+
*/
120+
protected boolean isEmbeddingLlmAvailable() {
121+
return llmTestSupport.isEmbeddingAvailable();
122+
}
123+
124+
/**
125+
* Aborts the test if chat LLM is not configured.
126+
*
127+
* @param purpose description of what the test needs chat LLM for
128+
* @throws TestAbortedException if chat LLM is not configured
129+
*/
130+
protected void requireChatLlm(String purpose) {
131+
llmTestSupport.requireChat(purpose);
132+
}
133+
134+
/**
135+
* Aborts the test if embedding LLM is not configured.
136+
*
137+
* @param purpose description of what the test needs embedding LLM for
138+
* @throws TestAbortedException if embedding LLM is not configured
139+
*/
140+
protected void requireEmbeddingLlm(String purpose) {
141+
llmTestSupport.requireEmbedding(purpose);
142+
}
143+
144+
/**
145+
* Returns a human-readable description of the LLM configuration status.
146+
* Useful for diagnostic output in tests.
147+
*/
148+
protected String describeLlmConfiguration() {
149+
return llmTestSupport.describeConfiguration();
150+
}
87151
}

backend/src/test/java/com/dbaagent/integration/ChatPromptIntegrationTest.java

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
import com.dbaagent.model.ChatRequest;
44
import com.dbaagent.model.ChatResponse;
5+
import com.dbaagent.support.RequiresLlm;
56
import com.fasterxml.jackson.databind.ObjectMapper;
7+
import org.junit.jupiter.api.BeforeEach;
68
import org.junit.jupiter.api.DisplayName;
79
import org.junit.jupiter.api.Test;
810
import org.springframework.beans.factory.annotation.Autowired;
@@ -20,22 +22,33 @@
2022
/**
2123
* Integration tests for Chat prompts testing accuracy and speed.
2224
*
23-
* Tests various DBA question categories:
24-
* - Schema/Structure questions (fast-path)
25-
* - Slow query questions (fast-path)
26-
* - Index recommendation questions (fast-path)
27-
* - Workload type questions (fast-path)
28-
* - LLM-routed questions (complex queries requiring AI)
25+
* <p>Tests various DBA question categories:
26+
* <ul>
27+
* <li>Schema/Structure questions (fast-path)</li>
28+
* <li>Slow query questions (fast-path)</li>
29+
* <li>Index recommendation questions (fast-path)</li>
30+
* <li>Workload type questions (fast-path)</li>
31+
* <li>LLM-routed questions (complex queries requiring AI)</li>
32+
* </ul>
2933
*
30-
* Configuration:
31-
* - Set TEST_CONNECTION_ID environment variable to a valid connection ID
32-
* - Or update test.connection.id in application-test.properties
34+
* <p>Configuration requirements:
35+
* <ul>
36+
* <li>Database connection: Set {@code TEST_CONNECTION_ID} environment variable or
37+
* configure {@code test.connection.id} in application-test.properties</li>
38+
* <li>LLM credentials: Set {@code DEEPSQL_CHAT_PROVIDER} and {@code DEEPSQL_CHAT_API_KEY}
39+
* environment variables, or configure via the setup wizard</li>
40+
* </ul>
3341
*
34-
* Performance targets:
35-
* - Fast-path questions: < 500ms (server-side, excluding test overhead)
36-
* - LLM questions: < 30s (depends on Azure OpenAI)
42+
* <p>Performance targets:
43+
* <ul>
44+
* <li>Fast-path questions: &lt; 500ms (server-side, excluding test overhead)</li>
45+
* <li>LLM questions: &lt; 30s (depends on LLM provider)</li>
46+
* </ul>
47+
*
48+
* <p>Tests in this class require LLM configuration and will be skipped if not available.
3749
*/
3850
@DisplayName("Chat Prompt Integration Tests")
51+
@RequiresLlm(chat = true)
3952
class ChatPromptIntegrationTest extends BaseIntegrationTest {
4053

4154
@Autowired
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package com.dbaagent.support;
2+
3+
import org.junit.jupiter.api.extension.ConditionEvaluationResult;
4+
import org.junit.jupiter.api.extension.ExecutionCondition;
5+
import org.junit.jupiter.api.extension.ExtensionContext;
6+
import org.junit.platform.commons.support.AnnotationSupport;
7+
8+
import java.util.Optional;
9+
10+
/**
11+
* JUnit 5 ExecutionCondition that checks whether LLM configuration is available.
12+
*
13+
* <p>This condition checks the {@code DEEPSQL_CHAT_*} and {@code DEEPSQL_EMBEDDING_*}
14+
* environment variables to determine if LLM is configured. It uses the same resolution
15+
* logic as {@code LlmConfigResolver.fromEnvironment()}.
16+
*
17+
* <p>For integration tests running within a Spring context, the actual
18+
* {@code LlmConfigResolver} bean should be used via {@link LlmTestSupport} for
19+
* more accurate checks that include database-stored configuration.
20+
*/
21+
public class LlmAvailabilityCondition implements ExecutionCondition {
22+
23+
private static final String CHAT_PROVIDER_ENV = "DEEPSQL_CHAT_PROVIDER";
24+
private static final String CHAT_API_KEY_ENV = "DEEPSQL_CHAT_API_KEY";
25+
private static final String EMBEDDING_PROVIDER_ENV = "DEEPSQL_EMBEDDING_PROVIDER";
26+
private static final String EMBEDDING_API_KEY_ENV = "DEEPSQL_EMBEDDING_API_KEY";
27+
28+
@Override
29+
public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) {
30+
Optional<RequiresLlm> annotation = findAnnotation(context);
31+
32+
if (annotation.isEmpty()) {
33+
return ConditionEvaluationResult.enabled("No @RequiresLlm annotation present");
34+
}
35+
36+
RequiresLlm requiresLlm = annotation.get();
37+
boolean requiresChat = requiresLlm.chat();
38+
boolean requiresEmbedding = requiresLlm.embedding();
39+
40+
if (requiresChat && !isChatConfigured()) {
41+
return ConditionEvaluationResult.disabled(
42+
"Chat LLM not configured. Set DEEPSQL_CHAT_PROVIDER and DEEPSQL_CHAT_API_KEY "
43+
+ "environment variables, or configure via the setup wizard.");
44+
}
45+
46+
if (requiresEmbedding && !isEmbeddingConfigured()) {
47+
return ConditionEvaluationResult.disabled(
48+
"Embedding LLM not configured. Set DEEPSQL_EMBEDDING_PROVIDER and "
49+
+ "DEEPSQL_EMBEDDING_API_KEY environment variables, or configure via the setup wizard.");
50+
}
51+
52+
return ConditionEvaluationResult.enabled("LLM configuration available");
53+
}
54+
55+
private Optional<RequiresLlm> findAnnotation(ExtensionContext context) {
56+
Optional<RequiresLlm> methodAnnotation = context.getElement()
57+
.flatMap(element -> AnnotationSupport.findAnnotation(element, RequiresLlm.class));
58+
59+
if (methodAnnotation.isPresent()) {
60+
return methodAnnotation;
61+
}
62+
63+
return context.getTestClass()
64+
.flatMap(clazz -> AnnotationSupport.findAnnotation(clazz, RequiresLlm.class));
65+
}
66+
67+
/**
68+
* Checks if chat LLM is configured via environment variables.
69+
*
70+
* <p>This mirrors the logic in {@code LlmConfigResolver.fromEnvironment("CHAT")}.
71+
*/
72+
public static boolean isChatConfigured() {
73+
String provider = System.getenv(CHAT_PROVIDER_ENV);
74+
String apiKey = System.getenv(CHAT_API_KEY_ENV);
75+
return isConfigured(provider, apiKey);
76+
}
77+
78+
/**
79+
* Checks if embedding LLM is configured via environment variables.
80+
*
81+
* <p>This mirrors the logic in {@code LlmConfigResolver.fromEnvironment("EMBEDDING")}.
82+
*/
83+
public static boolean isEmbeddingConfigured() {
84+
String provider = System.getenv(EMBEDDING_PROVIDER_ENV);
85+
String apiKey = System.getenv(EMBEDDING_API_KEY_ENV);
86+
return isConfigured(provider, apiKey);
87+
}
88+
89+
private static boolean isConfigured(String provider, String apiKey) {
90+
return provider != null && !provider.isBlank()
91+
&& apiKey != null && !apiKey.isBlank();
92+
}
93+
94+
/**
95+
* Returns a human-readable description of the current LLM configuration status.
96+
* Useful for test diagnostic output.
97+
*/
98+
public static String describeConfiguration() {
99+
StringBuilder sb = new StringBuilder();
100+
sb.append("LLM Configuration Status:\n");
101+
sb.append(" Chat: ").append(isChatConfigured() ? "CONFIGURED" : "NOT CONFIGURED");
102+
if (!isChatConfigured()) {
103+
sb.append(" (set DEEPSQL_CHAT_PROVIDER and DEEPSQL_CHAT_API_KEY)");
104+
}
105+
sb.append("\n");
106+
sb.append(" Embedding: ").append(isEmbeddingConfigured() ? "CONFIGURED" : "NOT CONFIGURED");
107+
if (!isEmbeddingConfigured()) {
108+
sb.append(" (set DEEPSQL_EMBEDDING_PROVIDER and DEEPSQL_EMBEDDING_API_KEY)");
109+
}
110+
return sb.toString();
111+
}
112+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package com.dbaagent.support;
2+
3+
import org.junit.jupiter.api.Test;
4+
5+
import static org.assertj.core.api.Assertions.assertThat;
6+
7+
/**
8+
* Unit tests for {@link LlmAvailabilityCondition}.
9+
*
10+
* <p>These tests verify the static check methods work correctly.
11+
* The actual annotation behavior is tested by the skipped tests
12+
* in classes like {@code ChatPromptIntegrationTest}.
13+
*/
14+
class LlmAvailabilityConditionTest {
15+
16+
@Test
17+
void describeConfigurationReturnsReadableOutput() {
18+
String description = LlmAvailabilityCondition.describeConfiguration();
19+
20+
assertThat(description)
21+
.contains("LLM Configuration Status")
22+
.contains("Chat:")
23+
.contains("Embedding:");
24+
}
25+
26+
@Test
27+
void isChatConfiguredReturnsFalseWhenEnvNotSet() {
28+
// This test verifies the check method works when env vars are not set.
29+
// If DEEPSQL_CHAT_PROVIDER and DEEPSQL_CHAT_API_KEY are set in the
30+
// test environment, this would return true - that's correct behavior.
31+
boolean configured = LlmAvailabilityCondition.isChatConfigured();
32+
33+
// We can't assert false here because the test might run in an env with LLM configured.
34+
// Instead, verify the method returns a boolean without throwing.
35+
assertThat(configured).isIn(true, false);
36+
}
37+
38+
@Test
39+
void isEmbeddingConfiguredReturnsFalseWhenEnvNotSet() {
40+
boolean configured = LlmAvailabilityCondition.isEmbeddingConfigured();
41+
42+
assertThat(configured).isIn(true, false);
43+
}
44+
45+
@Test
46+
void describeConfigurationShowsNotConfiguredWhenMissing() {
47+
// Without mocking System.getenv, we verify the output format is correct
48+
String description = LlmAvailabilityCondition.describeConfiguration();
49+
50+
// Should contain configuration hints
51+
assertThat(description).containsAnyOf(
52+
"CONFIGURED",
53+
"NOT CONFIGURED"
54+
);
55+
}
56+
}

0 commit comments

Comments
 (0)