Testing Spring AI Applications

Module 8 · ~12 min read
The main challenges when testing Spring AI applications: LLM calls are slow (2–10s each), non-deterministic (the LLM may return different text each call), and require real API keys. The solution is to mock ChatClient at the unit test level and reserve real LLM calls for explicit integration / evaluation tests.

The Testing Challenge

ChallengeImpactSolution
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:

RagServiceTest.java — mocking the ChatClient chain View source ↗
@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:

TestContainersConfig.java View source ↗
@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.

Write unit tests for pipeline logic (guardrails, RRF merge, confidence scoring, context assembly) using mocked dependencies. Write integration tests for database queries and the full pipeline end-to-end using Testcontainers. Reserve real LLM calls for evaluation harnesses outside the standard test suite.