Testing Spring AI Applications
ChatClient at the unit test level and reserve real LLM calls for explicit integration / evaluation tests.
The Testing Challenge
| Challenge | Impact | Solution |
|---|---|---|
| LLM calls are slow | Test suite takes hours | Mock ChatClient — return deterministic string |
| Non-deterministic output | Tests fail randomly | Mock returns the same string every time |
| Requires API keys | CI/CD cannot run without secrets | Mocks need no keys |
| Redis required for cache | Tests need Redis running | NoOpSemanticCacheService with @Profile("test") |
| Qdrant + PostgreSQL required | Tests need databases | Testcontainers — spin up real containers per test run |
Mocking ChatClient with Mockito
ChatClient uses a fluent builder chain. To mock it, you must mock each step in the chain separately:
@ExtendWith(MockitoExtension.class)
class RagServiceTest {
@Mock private ChatClient mockChatClient;
@Mock private ChatClient.ChatClientRequestSpec mockSpec;
@Mock private ChatClient.CallResponseSpec mockCallSpec;
@Mock private ImageGenerationService imageGenerationService;
@BeforeEach
void setup() {
// Chain the mock
when(mockChatClient.prompt()).thenReturn(mockSpec);
when(mockSpec.user(anyString())).thenReturn(mockSpec);
when(mockSpec.call()).thenReturn(mockCallSpec);
when(mockCallSpec.content()).thenReturn("Mocked LLM answer");
// Disable guardrails and image generation
lenient().when(imageGenerationService
.isImageGenerationRequest(anyString())).thenReturn(false);
}
}
Why each step must be mocked
chatClient.prompt() returns a ChatClientRequestSpec. spec.user() returns another ChatClientRequestSpec. spec.call() returns a CallResponseSpec. callSpec.content() returns a String. Each interface must be mocked separately, and the return values chained.
NoOpSemanticCacheService
The @Profile("test") bean from Topic 18 activates automatically when tests set spring.profiles.active=test. No Redis is needed — every cache lookup returns empty, and stores are no-ops.
Testcontainers for Integration Tests
When you need to test actual database interactions (Flyway migrations, FTS queries, vector indexing), Testcontainers spins up real Docker containers for the test run:
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
@Container
static GenericContainer<?> qdrant =
new GenericContainer<>("qdrant/qdrant:latest")
.withExposedPorts(6334);
Testcontainers starts the containers before the Spring context is created, passes the dynamic ports to Spring via @DynamicPropertySource, and stops them after all tests complete.
Test Coverage Requirements
Power RAG enforces coverage thresholds via JaCoCo in the Maven build. The build fails if coverage drops below these levels — see Topic 26 for the JaCoCo configuration.
- Line coverage: ≥ 80%
- Branch coverage: ≥ 75%