Multi-module test support framework for Embabel Agent applications providing integration testing, mock AI services, and test configuration utilities
Step-by-step guide for testing custom options converters that preserve core LLM values.
An OptionsConverter transforms framework-agnostic LlmOptions into provider-specific ChatOptions (e.g., OpenAI, Anthropic). Testing ensures your converter preserves critical values like temperature, topP, and frequencyPenalty.
Critical LLM parameters must be preserved during conversion:
| Parameter | Purpose | Must Preserve |
|---|---|---|
| temperature | Controls randomness | ✓ Yes |
| topP | Nucleus sampling | ✓ Yes |
| frequencyPenalty | Token repetition penalty | ✓ Yes |
| maxTokens | Output limit | Recommended |
Extend the base test class for automatic validation.
import com.embabel.agent.test.models.OptionsConverterTestSupport
import org.junit.jupiter.api.Test
class MyOptionsConverterTest : OptionsConverterTestSupport<MyCustomOptions>(
MyOptionsConverter()
) {
// Inherits 'should preserve core values' test
// Automatically tests temperature, topP, frequencyPenalty
@Test
fun `should handle custom parameters`() {
// Add your custom parameter tests
}
}Use the standalone function for more flexible testing.
import com.embabel.agent.test.models.checkOptionsConverterPreservesCoreValues
import org.junit.jupiter.api.Test
class MyOptionsConverterTest {
@Test
fun `should preserve core values`() {
val converter = MyOptionsConverter()
// This throws AssertionError if preservation fails
checkOptionsConverterPreservesCoreValues(converter)
}
}import com.embabel.agent.test.models.OptionsConverterTestSupport
import com.embabel.common.ai.model.OptionsConverter
import org.springframework.ai.openai.OpenAiChatOptions
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
class OpenAiOptionsConverterTest : OptionsConverterTestSupport<OpenAiChatOptions>(
OpenAiOptionsConverter()
) {
// Core preservation test is inherited
@Test
fun `should convert max tokens`() {
val llmOptions = LlmOptions(
maxTokens = 1000
)
val result = optionsConverter.convert(llmOptions)
assertEquals(1000, result.maxTokens)
}
@Test
fun `should convert all parameters`() {
val llmOptions = LlmOptions(
temperature = 0.7,
topP = 0.9,
frequencyPenalty = 0.5,
maxTokens = 2000
)
val result = optionsConverter.convert(llmOptions)
assertEquals(0.7, result.temperature)
assertEquals(0.9, result.topP)
assertEquals(0.5, result.frequencyPenalty)
assertEquals(2000, result.maxTokens)
}
}import com.embabel.agent.test.models.checkOptionsConverterPreservesCoreValues
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
class AnthropicOptionsConverterTest {
private val converter = AnthropicOptionsConverter()
@Test
fun `should preserve core values`() {
checkOptionsConverterPreservesCoreValues(converter)
}
@Test
fun `should handle Anthropic-specific parameters`() {
val llmOptions = LlmOptions(
temperature = 0.5,
maxTokens = 1500
)
val result = converter.convert(llmOptions)
assertEquals(0.5, result.temperature)
assertEquals(1500, result.maxTokensToSample)
}
}import com.embabel.agent.test.models.OptionsConverterTestSupport;
import static com.embabel.agent.test.models.OptionsConverterTestUtilsKt.checkOptionsConverterPreservesCoreValues;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
public class OpenAiOptionsConverterTest {
private final OpenAiOptionsConverter converter = new OpenAiOptionsConverter();
@Test
public void shouldPreserveCoreValues() {
checkOptionsConverterPreservesCoreValues(converter);
}
@Test
public void shouldConvertAllParameters() {
LlmOptions llmOptions = new LlmOptions();
llmOptions.setTemperature(0.7);
llmOptions.setTopP(0.9);
llmOptions.setFrequencyPenalty(0.5);
llmOptions.setMaxTokens(2000);
OpenAiChatOptions result = converter.convert(llmOptions);
assertEquals(0.7, result.getTemperature(), 0.001);
assertEquals(0.9, result.getTopP(), 0.001);
assertEquals(0.5, result.getFrequencyPenalty(), 0.001);
assertEquals(2000, result.getMaxTokens());
}
}Test parameters unique to your provider:
@Test
fun `should handle OpenAI presence penalty`() {
val llmOptions = LlmOptions(
temperature = 0.7
)
// Add custom parameter
llmOptions.setCustomParameter("presencePenalty", 0.6)
val result = optionsConverter.convert(llmOptions)
assertEquals(0.6, result.presencePenalty)
}Test that defaults are applied correctly:
@Test
fun `should apply default values`() {
val llmOptions = LlmOptions() // Empty options
val result = optionsConverter.convert(llmOptions)
// Verify defaults
assertEquals(0.7, result.temperature) // Default temperature
assertNull(result.maxTokens) // No default for tokens
}Test behavior with null values:
@Test
fun `should handle null values`() {
val llmOptions = LlmOptions(
temperature = null,
topP = 0.9,
frequencyPenalty = null
)
val result = optionsConverter.convert(llmOptions)
assertNull(result.temperature)
assertEquals(0.9, result.topP)
assertNull(result.frequencyPenalty)
}Test that values are within valid ranges:
@Test
fun `should enforce valid temperature range`() {
// Test minimum
var llmOptions = LlmOptions(temperature = 0.0)
var result = optionsConverter.convert(llmOptions)
assertEquals(0.0, result.temperature)
// Test maximum
llmOptions = LlmOptions(temperature = 2.0)
result = optionsConverter.convert(llmOptions)
assertEquals(2.0, result.temperature)
}Test boundary conditions:
@Test
fun `should handle edge cases`() {
// Very small temperature
var options = LlmOptions(temperature = 0.0001)
var result = optionsConverter.convert(options)
assertEquals(0.0001, result.temperature, 0.00001)
// Very large max tokens
options = LlmOptions(maxTokens = 100000)
result = optionsConverter.convert(options)
assertEquals(100000, result.maxTokens)
}Test that conversion doesn't modify original:
@Test
fun `should not modify original options`() {
val original = LlmOptions(
temperature = 0.7,
topP = 0.9
)
val converted = optionsConverter.convert(original)
// Original unchanged
assertEquals(0.7, original.temperature)
assertEquals(0.9, original.topP)
// Converted has same values
assertEquals(0.7, converted.temperature)
assertEquals(0.9, converted.topP)
}Test converting same options multiple times:
@Test
fun `should be idempotent`() {
val llmOptions = LlmOptions(temperature = 0.7)
val result1 = optionsConverter.convert(llmOptions)
val result2 = optionsConverter.convert(llmOptions)
assertEquals(result1.temperature, result2.temperature)
assertEquals(result1.topP, result2.topP)
}class OpenAiOptionsConverterTest : OptionsConverterTestSupport<OpenAiChatOptions>(
OpenAiOptionsConverter()
) {
@Test
fun `should handle OpenAI specific options`() {
val options = LlmOptions(
temperature = 0.8,
maxTokens = 1000
)
val result = optionsConverter.convert(options)
assertEquals(0.8, result.temperature)
assertEquals(1000, result.maxTokens)
assertNotNull(result.model)
}
}class AnthropicOptionsConverterTest : OptionsConverterTestSupport<AnthropicChatOptions>(
AnthropicOptionsConverter()
) {
@Test
fun `should handle Anthropic specific options`() {
val options = LlmOptions(
temperature = 0.6,
maxTokens = 2000
)
val result = optionsConverter.convert(options)
assertEquals(0.6, result.temperature)
assertEquals(2000, result.maxTokensToSample)
}
}class CustomProviderConverterTest : OptionsConverterTestSupport<CustomChatOptions>(
CustomProviderConverter()
) {
@Test
fun `should handle custom provider options`() {
val options = LlmOptions(
temperature = 0.5,
customParam = "value"
)
val result = optionsConverter.convert(options)
assertEquals(0.5, result.temperature)
assertEquals("value", result.customParameter)
}
}When using OptionsConverterTestSupport or checkOptionsConverterPreservesCoreValues, these values are automatically tested:
// Automatically tests:
val options = LlmOptions(temperature = 0.7)
val result = converter.convert(options)
assertEquals(0.7, result.temperature)// Automatically tests:
val options = LlmOptions(topP = 0.9)
val result = converter.convert(options)
assertEquals(0.9, result.topP)// Automatically tests:
val options = LlmOptions(frequencyPenalty = 0.5)
val result = converter.convert(options)
assertEquals(0.5, result.frequencyPenalty)Problem: Core preservation test fails.
Solution: Check your converter preserves the values:
override fun convert(options: LlmOptions): MyCustomOptions {
return MyCustomOptions.builder()
.withTemperature(options.temperature) // Must preserve
.withTopP(options.topP) // Must preserve
.withFrequencyPenalty(options.frequencyPenalty) // Must preserve
.build()
}Problem: Type parameter doesn't match.
Solution: Ensure type parameter matches converter output:
// Correct: matches converter output type
class MyTest : OptionsConverterTestSupport<OpenAiChatOptions>(
MyConverter() // Produces OpenAiChatOptions
)
// Wrong: type mismatch
class MyTest : OptionsConverterTestSupport<AnthropicChatOptions>(
MyConverter() // Produces OpenAiChatOptions - WRONG!
)Problem: Floating point comparison fails.
Solution: Use delta for comparison:
assertEquals(0.7, result.temperature, 0.001) // Allow small differenceAlways Test Core Values: Use the provided utilities for temperature, topP, frequencyPenalty
Test Provider-Specific: Add tests for your provider's unique parameters
Test Edge Cases: Include boundary values and null handling
Test Defaults: Verify default values are applied correctly
Keep Tests Simple: One assertion per test when possible
Use Descriptive Names: Test names should describe what's being verified
// Good names
@Test
fun `should preserve temperature when converting to OpenAI options`() { }
@Test
fun `should apply default max tokens of 1000`() { }
@Test
fun `should handle null frequency penalty`() { }