Skip to content

Commit 623c95e

Browse files
Improve ollama container reuse feature and cache model (#8)
1 parent c931c45 commit 623c95e

File tree

8 files changed

+159
-16
lines changed

8 files changed

+159
-16
lines changed

README.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# Spring-TestContainers
22

3+
[![Build Status](https://github.com/flowinquiry/spring-testcontainers/actions/workflows/gradle.yml/badge.svg)](https://github.com/flowinquiry/spring-testcontainers/actions/workflows/gradle.yml)
4+
[![Maven Central](https://img.shields.io/maven-central/v/io.flowinquiry.testcontainers/spring-testcontainers?label=Maven%20Central)](https://search.maven.org/artifact/io.flowinquiry.testcontainers/spring-testcontainers)
5+
36
Spring-TestContainers is a Java library that makes it easier to write integration tests with Testcontainers, especially when you're using Spring or Spring Boot. It handles the setup and lifecycle of containers for you, so you can focus on testing—not boilerplate.
47

58
We originally built this for FlowInquiry to make our own testing smoother. It worked so well, we decided to share it as a standalone library so other teams can take advantage of it too.
@@ -146,9 +149,9 @@ Add the core library along with the database module(s) you plan to use. Each dat
146149

147150
```kotlin
148151
// Add one or more of the following database modules
149-
testImplementation("io.flowinquiry.testcontainers:postgresql:0.9.1") // PostgreSQL support
150-
testImplementation("io.flowinquiry.testcontainers:mysql:0.9.1") // MySQL support
151-
testImplementation("io.flowinquiry.testcontainers:ollama:0.9.1") // Ollama support
152+
testImplementation("io.flowinquiry.testcontainers:postgresql:<!-- Replace with the latest version -->") // PostgreSQL support
153+
testImplementation("io.flowinquiry.testcontainers:mysql:<!-- Replace with the latest version -->") // MySQL support
154+
testImplementation("io.flowinquiry.testcontainers:ollama:<!-- Replace with the latest version -->") // Ollama support
152155
```
153156

154157
### Maven
@@ -161,7 +164,7 @@ testImplementation("io.flowinquiry.testcontainers:ollama:0.9.1") // Ollama s
161164
<dependency>
162165
<groupId>io.flowinquiry.testcontainers</groupId>
163166
<artifactId>postgresql</artifactId>
164-
<version>0.9.0</version>
167+
<version><!-- Replace with the latest version --></version>
165168
<scope>test</scope>
166169
</dependency>
167170
<dependency>
@@ -170,15 +173,15 @@ testImplementation("io.flowinquiry.testcontainers:ollama:0.9.1") // Ollama s
170173
<dependency>
171174
<groupId>io.flowinquiry.testcontainers</groupId>
172175
<artifactId>mysql</artifactId>
173-
<version>0.9.1</version>
176+
<version><!-- Replace with the latest version --></version>
174177
<scope>test</scope>
175178
</dependency>
176179

177180
<!-- Add this dependency to test Ollama container -->
178181
<dependency>
179182
<groupId>io.flowinquiry.testcontainers</groupId>
180183
<artifactId>ollama</artifactId>
181-
<version>0.9.1</version>
184+
<version><!-- Replace with the latest version --></version>
182185
<scope>test</scope>
183186
</dependency>
184187
```

examples/springboot-ollama/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ dependencies {
2424
implementation(libs.bundles.spring.ai)
2525
testImplementation(platform(libs.junit.bom))
2626
testImplementation(libs.junit.jupiter)
27+
testImplementation(libs.junit.jupiter.params)
2728
testImplementation(libs.junit.platform.launcher)
2829
testImplementation(libs.spring.boot.starter.test)
2930
}

examples/springboot-ollama/src/test/java/io/flowinquiry/testcontainers/examples/ollama/OllamaDemoAppTest.java

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
import io.flowinquiry.testcontainers.ai.OllamaOptions;
1010
import org.junit.jupiter.api.BeforeEach;
1111
import org.junit.jupiter.api.Test;
12+
import org.junit.jupiter.params.ParameterizedTest;
13+
import org.junit.jupiter.params.provider.CsvSource;
1214
import org.slf4j.Logger;
1315
import org.springframework.ai.chat.client.ChatClient;
1416
import org.springframework.beans.factory.annotation.Autowired;
@@ -23,7 +25,7 @@
2325
@EnableOllamaContainer(
2426
dockerImage = "ollama/ollama",
2527
version = "0.9.0",
26-
model = "smollm2:135m",
28+
model = "llama3:latest",
2729
options = @OllamaOptions(temperature = "0.7", topP = "0.5"))
2830
@ActiveProfiles("test")
2931
public class OllamaDemoAppTest {
@@ -53,16 +55,21 @@ public void testHealthEndpoint() {
5355
assertTrue(response.contains("Ollama Chat Controller is up and running"));
5456
}
5557

56-
@Test
57-
public void testChatClient() {
58+
@ParameterizedTest
59+
@CsvSource({
60+
"What is the result of 1+2? Give the value only, 3",
61+
"How many letter 'r' in the word 'Hello'? Give the value only, 0"
62+
})
63+
public void testChatClient(String prompt, String expectedResult) {
5864
log.info("Testing chat client directly");
59-
String prompt = "What is Spring AI?";
6065
log.info("Sending prompt: {}", prompt);
6166

6267
String content = chatClient.prompt().user(prompt).call().content();
6368

6469
log.info("Received response: {}", content);
6570
assertNotNull(content);
6671
assertFalse(content.isEmpty());
72+
assertTrue(
73+
content.contains(expectedResult), "Response should contain '" + expectedResult + "'");
6774
}
6875
}

gradle.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@
22
# https://docs.gradle.org/current/userguide/build_environment.html#sec:gradle_configuration_properties
33

44
org.gradle.configuration-cache=true
5-
version=0.9.1
5+
version=0.9.2
66

gradle/libs.versions.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ spring-ai = "1.0.0"
1818
junit-bom = { group = "org.junit", name = "junit-bom", version.ref = "junit-jupiter" }
1919
junit-jupiter = { group = "org.junit.jupiter", name = "junit-jupiter" }
2020
junit-jupiter-api = { group = "org.junit.jupiter", name = "junit-jupiter-api" }
21+
junit-jupiter-params = { group = "org.junit.jupiter", name = "junit-jupiter-params" }
2122
junit-jupiter-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine" }
2223
junit-platform-launcher = { group = "org.junit.platform", name = "junit-platform-launcher" }
2324
spring-bom = { group = "org.springframework", name = "spring-framework-bom", version.ref = "spring" }

modules/ollama/src/main/java/io/flowinquiry/testcontainers/ai/OllamaContainerProvider.java

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
package io.flowinquiry.testcontainers.ai;
22

33
import static io.flowinquiry.testcontainers.ContainerType.OLLAMA;
4+
import static org.testcontainers.containers.BindMode.READ_WRITE;
45

56
import io.flowinquiry.testcontainers.ContainerType;
7+
import io.flowinquiry.testcontainers.Slf4jOutputConsumer;
68
import io.flowinquiry.testcontainers.SpringAwareContainerProvider;
79
import java.io.IOException;
810
import java.util.Properties;
911
import org.slf4j.Logger;
1012
import org.slf4j.LoggerFactory;
1113
import org.springframework.core.env.ConfigurableEnvironment;
1214
import org.springframework.core.env.PropertiesPropertySource;
15+
import org.testcontainers.containers.Container;
1316
import org.testcontainers.ollama.OllamaContainer;
1417

1518
/**
@@ -46,7 +49,8 @@ public ContainerType getContainerType() {
4649
*/
4750
@Override
4851
protected OllamaContainer createContainer() {
49-
return new OllamaContainer(dockerImage + ":" + version);
52+
return new OllamaContainer(dockerImage + ":" + version)
53+
.withFileSystemBind("/tmp/ollama-cache", "/root/.ollama", READ_WRITE);
5054
}
5155

5256
/**
@@ -60,14 +64,31 @@ protected OllamaContainer createContainer() {
6064
@Override
6165
public void start() {
6266
super.start();
67+
68+
Logger containerLog = LoggerFactory.getLogger(OllamaContainerProvider.class);
69+
container.followOutput(new Slf4jOutputConsumer(containerLog));
70+
6371
try {
6472
log.info("Starting pull model {}", enableContainerAnnotation.model());
65-
container.execInContainer("ollama", "pull", enableContainerAnnotation.model());
73+
pullModelIfMissing(enableContainerAnnotation.model());
6674
} catch (IOException | InterruptedException e) {
6775
throw new RuntimeException(e);
6876
}
6977
}
7078

79+
private void pullModelIfMissing(String modelName) throws IOException, InterruptedException {
80+
Container.ExecResult result = container.execInContainer("ollama", "list");
81+
String output = result.getStdout();
82+
83+
if (!output.contains(modelName)) {
84+
log.info("Model '{}' not found in ollama cache. Pulling...", modelName);
85+
Container.ExecResult pullResult = container.execInContainer("ollama", "pull", modelName);
86+
log.info("Pull complete: {}", pullResult.getStdout());
87+
} else {
88+
log.info("Model '{}' already exists. Skipping pull.", modelName);
89+
}
90+
}
91+
7192
/**
7293
* Applies Ollama-specific configuration to the Spring environment.
7394
*
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package io.flowinquiry.testcontainers;
2+
3+
import org.slf4j.Logger;
4+
import org.slf4j.event.Level;
5+
import org.testcontainers.containers.output.BaseConsumer;
6+
import org.testcontainers.containers.output.OutputFrame;
7+
8+
/**
9+
* An implementation of {@link BaseConsumer} that routes container output to SLF4J logging. This
10+
* consumer allows for different log levels to be used for STDOUT and STDERR streams.
11+
*
12+
* <p>Usage example:
13+
*
14+
* <pre>
15+
* Logger logger = LoggerFactory.getLogger(MyClass.class);
16+
* GenericContainer container = new GenericContainer("some-image")
17+
* .withLogConsumer(new Slf4jOutputConsumer(logger));
18+
* </pre>
19+
*/
20+
public class Slf4jOutputConsumer extends BaseConsumer<Slf4jOutputConsumer> {
21+
22+
/** The SLF4J logger to which container output will be written. */
23+
private final Logger logger;
24+
25+
/** The log level to use for STDOUT output from the container. */
26+
private final Level stdoutLogLevel;
27+
28+
/** The log level to use for STDERR output from the container. */
29+
private final Level stderrLogLevel;
30+
31+
/**
32+
* Creates a new Slf4jOutputConsumer with default log levels. STDOUT messages will be logged at
33+
* DEBUG level, and STDERR messages at ERROR level.
34+
*
35+
* @param logger the SLF4J logger to which container output will be written
36+
*/
37+
public Slf4jOutputConsumer(Logger logger) {
38+
this(logger, Level.DEBUG, Level.ERROR);
39+
}
40+
41+
/**
42+
* Creates a new Slf4jOutputConsumer with custom log levels for STDOUT and STDERR.
43+
*
44+
* @param logger the SLF4J logger to which container output will be written
45+
* @param stdoutLogLevel the log level to use for STDOUT output
46+
* @param stderrLogLevel the log level to use for STDERR output
47+
*/
48+
public Slf4jOutputConsumer(Logger logger, Level stdoutLogLevel, Level stderrLogLevel) {
49+
this.logger = logger;
50+
this.stdoutLogLevel = stdoutLogLevel;
51+
this.stderrLogLevel = stderrLogLevel;
52+
}
53+
54+
/**
55+
* Processes an output frame from a container and logs it using the configured SLF4J logger.
56+
*
57+
* <p>The method:
58+
*
59+
* <ul>
60+
* <li>Skips null or empty frames
61+
* <li>Determines the appropriate log level based on the frame type (STDOUT or STDERR)
62+
* <li>Logs the message with the frame type as a prefix
63+
* </ul>
64+
*
65+
* @param outputFrame the output frame to process
66+
*/
67+
@Override
68+
public void accept(OutputFrame outputFrame) {
69+
if (outputFrame == null || outputFrame.getBytes() == null) return;
70+
71+
String message = outputFrame.getUtf8String().trim();
72+
if (message.isEmpty()) return;
73+
74+
Level levelToUse =
75+
switch (outputFrame.getType()) {
76+
case STDOUT -> stdoutLogLevel;
77+
case STDERR -> stderrLogLevel;
78+
case END -> null;
79+
};
80+
81+
if (levelToUse != null) {
82+
logAtLevel(levelToUse, "[{}] {}", outputFrame.getType(), message);
83+
}
84+
}
85+
86+
/**
87+
* Logs a message at the specified SLF4J level.
88+
*
89+
* @param level the SLF4J level at which to log the message
90+
* @param format the message format string
91+
* @param args the arguments to be formatted into the message string
92+
*/
93+
private void logAtLevel(Level level, String format, Object... args) {
94+
switch (level) {
95+
case TRACE -> logger.trace(format, args);
96+
case DEBUG -> logger.debug(format, args);
97+
case INFO -> logger.info(format, args);
98+
case WARN -> logger.warn(format, args);
99+
case ERROR -> logger.error(format, args);
100+
}
101+
}
102+
}

spring-testcontainers/src/main/java/io/flowinquiry/testcontainers/SpringAwareContainerProvider.java

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ public abstract class SpringAwareContainerProvider<
2525

2626
private static final Logger log = LoggerFactory.getLogger(SpringAwareContainerProvider.class);
2727

28+
private static boolean reuseContainerSupport =
29+
TestcontainersConfiguration.getInstance().environmentSupportsReuse();
30+
2831
/** The version of the container image to use. */
2932
protected String version;
3033

@@ -43,12 +46,17 @@ public final void initContainerInstance(A enableContainerAnnotation) {
4346
enableContainerAnnotation.annotationType().getMethod("dockerImage");
4447
Method versionMethod = enableContainerAnnotation.annotationType().getMethod("version");
4548

46-
log.info("Initializing JDBC container with image {}:{}", dockerImage, version);
49+
log.info("Initializing the container with image {}:{}", dockerImage, version);
4750
this.version = (String) versionMethod.invoke(enableContainerAnnotation);
4851
this.dockerImage = (String) dockerImageMethod.invoke(enableContainerAnnotation);
4952

5053
container = createContainer();
51-
container.withReuse(TestcontainersConfiguration.getInstance().environmentSupportsReuse());
54+
container.withReuse(reuseContainerSupport);
55+
log.info(
56+
"Created the container with image {}:{} with reuse {}",
57+
dockerImage,
58+
version,
59+
reuseContainerSupport);
5260
} catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
5361
throw new IllegalArgumentException(
5462
"Annotation "
@@ -74,7 +82,7 @@ public void start() {
7482
/** Stops the container. This method is called when the Spring context is closed. */
7583
@Override
7684
public void stop() {
77-
if (!TestcontainersConfiguration.getInstance().environmentSupportsReuse()) {
85+
if (!reuseContainerSupport) {
7886
container.stop();
7987
}
8088
}

0 commit comments

Comments
 (0)