TramAI

CI Integration

TramAI tests should never call real AI providers in CI. This guide covers how to run your TramAI application tests efficiently in CI environments.


What This Covers

  • Cached provider responses for deterministic test execution
  • Deterministic replay with mock providers
  • Avoiding API calls in CI
  • Gradle build patterns for TramAI testing
  • Parallel test execution considerations

The Problem

Real AI provider calls in CI cause:

  • Cost: every CI run incurs API charges
  • Flakiness: network issues, rate limits, and model latency cause non-deterministic failures
  • Speed: provider calls add seconds to minutes per test
  • Rate limits: concurrent CI jobs can trigger 429 responses

The solution: never call real providers in CI unless you are running explicit provider integration tests.


Minimum Setup: Mock Provider in Tests

TramAI ships MockAiProvider and SimulatedFailureProvider in the tramai-testing module.

Dependency

testImplementation("dev.tramai:tramai-testing:0.3.1")

Basic Test Pattern

@Test
fun `analyze returns structured result`() = runTest {
    val provider = MockAiProvider {
        onMethod("analyze") respondWith """{"status":"ok"}"""
    }

    val tramai = Tramai {
        provider(provider, default = true)
        model("gpt-4o", "mock")
    }

    val service = tramai.create<Analyzer>()
    val result = runBlocking { service.analyze("invoice-1") }

    assertEquals(Status("ok"), result)
}

Gradle Build Patterns

Separate Source Sets for Integration Tests

Separate provider-dependent tests from fast unit tests:

// build.gradle.kts
sourceSets {
    create("integrationTest") {
        compileClasspath += sourceSets.main.get().output
        runtimeClasspath += sourceSets.main.get().output
    }
}

val integrationTest by tasks.registering(Test::class) {
    testClassesDirs = sourceSets["integrationTest"].output.classesDirs
    classpath = sourceSets["integrationTest"].runtimeClasspath
    // Only run integration tests on demand
}

Skip Real Provider Calls by Default

Use a Gradle property to gate provider-dependent tests:

tasks.named<Test>("test") {
    // Fast unit tests only — no real providers
    systemProperty("tramai.test.skip-real-providers", "true")
}

tasks.register<Test>("providerTest") {
    // Explicit task for provider integration tests
    systemProperty("tramai.test.use-real-providers", 
        project.findProperty("useRealProviders")?.toString() ?: "false")
}

Deterministic Replay

Queuing Multiple Responses

Simulate retry scenarios in CI:

val provider = MockAiProvider {
    onMethod("analyze") respondWith "not json"    // first attempt fails
    onMethod("analyze") respondWith """{"status":"ok"}"""  // retry succeeds
}

Simulating Provider Failures

Use SimulatedFailureProvider to test resilience:

val provider = SimulatedFailureProvider {
    onMethod("analyze").retryableFailure("rate limited", statusCode = 429)
    onMethod("analyze") respondWith """{"status":"ok"}"""
}

Recording and Validating Behavior

val observer = RecordingOperationObserver()

TramaiAssertions.assertThat(provider, observer)
    .whenCalled("analyze")
    .wasCalledTimes(1)
    .andRetried(0)
    .andParsedSuccessfully()
    .emittedProvider("mock")

Caching Provider Responses

For acceptance tests that need realistic responses, cache provider output:

Simple JSON Fixture Pattern

class CachedProvider : ModelProvider {
    private val cache = loadFixture("provider-responses.json")

    override suspend fun complete(request: ModelRequest): ModelResponse {
        val key = cacheKey(request)
        return cache[key] ?: error("No cached response for $key")
    }

    override fun providerId(): String = "cached"
}

Store fixtures in src/test/resources/fixtures/:

{
  "analyze:InvoiceAnalyzer": {
    "content": "{\"status\":\"ok\"}",
    "modelUsed": "gpt-4o",
    "inputTokens": 150,
    "outputTokens": 25
  }
}

GitHub Actions Example

name: CI

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'

      - name: Cache Gradle
        uses: actions/cache@v4
        with:
          path: ~/.gradle/caches
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}

      - name: Run fast tests (no real providers)
        run: ./gradlew test

      - name: Run provider integration tests (on demand)
        if: github.event_name == 'schedule'
        run: ./gradlew providerTest -DuseRealProviders=true
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}

Parallel Test Execution

Mock providers are stateless — TramAI tests can run in parallel safely.

// build.gradle.kts
tasks.withType<Test> {
    maxParallelForks = Runtime.getRuntime().availableProcessors()
    // Each test creates its own MockAiProvider — no shared state
}

What to Test in CI

Always test:

  • prompt and argument wiring through service methods
  • structured return behavior
  • retry behavior for malformed output
  • provider routing decisions
  • business logic wrapped around TramAI service calls

Test on demand (not every push):

  • real provider integration (scheduled or manual)
  • end-to-end workflows against a test provider

What Not to Test in CI

Do not test in every CI run:

  • provider HTTP specifics in application tests
  • internal prompt word choices
  • Jackson serialization (tested by TramAI module tests)
  • model intelligence or quality

Limitations

  • MockAiProvider simulates provider responses — it does not validate that the prompt is correct
  • recorded fixtures can become stale if your prompt or data structures change
  • cached responses do not catch provider API contract changes

Next Steps