Testing LLM applications requires a different approach than traditional software. You need to balance:
Speed - Tests should run fast
Cost - Avoid expensive API calls in CI/CD
Determinism - LLM responses vary, so test behaviors not exact outputs
Coverage - Test error paths, timeouts, and edge cases
Unit Testing with a Scripted Client
For pure logic tests, give your code a scripted client instead of a real one. llm4s-testkit provides
ScriptedLLMClient: it answers from a script you write, records every request your code sent, and never touches the
network. Add it as a test dependency:
importorg.llm4s.error.NetworkErrorimportorg.llm4s.llmconnect.LLMClientimportorg.llm4s.llmconnect.model._importorg.llm4s.testkit.{Reply,ScriptedLLMClient}importorg.llm4s.types.Resultimportorg.scalatest.flatspec.AnyFlatSpecimportorg.scalatest.matchers.should.MatchersclassWeatherAgentSpecextendsAnyFlatSpecwithMatchers{// Example code under test: it takes any LLMClientclassWeatherAgent(client:LLMClient){defrun(query:String):Result[Completion]=client.complete(Conversation(Seq(UserMessage(query))))}"WeatherAgent"should"return the model's answer"in{valclient=ScriptedLLMClient.always(Reply.text("The weather in London is 15°C and cloudy.").withUsage(TokenUsage(promptTokens=10,completionTokens=15,totalTokens=25)))valresult=newWeatherAgent(client).run("What's the weather in London?")result.map(_.content)shouldBeRight("The weather in London is 15°C and cloudy.")client.callCountshouldBe1client.lastCall.flatMap(_.lastUserText)shouldBeSome("What's the weather in London?")}itshould"pass a provider error on"in{valclient=ScriptedLLMClient.always(Reply.failure(NetworkError("Connection timeout",None,"https://api.example.com")))newWeatherAgent(client).run("What's the weather?")match{caseLeft(_:NetworkError)=>succeedcaseother=>fail(s"Expected NetworkError but got: $other")}}}
streamComplete replays the same reply as chunks (the text in pieces, then any tool calls, then a chunk with the
finish reason), so code that streams is tested with the same script.
A reply chosen by the request
ScriptedLLMClient.respondingTo picks the reply from the conversation, so the answer does not depend on how many calls
came before. A request no rule matches fails with an UnscriptedCallError that shows the last message, instead of
hanging or throwing:
1
2
3
4
5
6
7
8
9
10
11
12
13
importorg.llm4s.llmconnect.model._importorg.llm4s.testkit.{Reply,ScriptedLLMClient}deflastUser(conversation:Conversation):String=conversation.messages.collect{caseu:UserMessage=>u.content}.lastOption.getOrElse("")valclient=ScriptedLLMClient.respondingTo{caseciflastUser(c)=="What is 2+2?"=>Reply.text("2+2 equals 4")caseciflastUser(c)=="What is the capital of France?"=>Reply.text("The capital of France is Paris")}valanswer=client.complete(Conversation(Seq(UserMessage("What is 2+2?"))))// Right, "2+2 equals 4"valunexpected=client.complete(Conversation(Seq(UserMessage("Something else"))))// Left(UnscriptedCallError)
Integration Testing with Ollama
For integration tests, use Ollama to avoid API costs:
Setup
1
2
3
4
5
6
7
8
# Install Ollama
curl -fsSL https://ollama.com/install.sh | sh
# Pull a small, fast model
ollama pull llama3.2
# Start server
ollama serve
Test Configuration
Put a named provider section in your test resources and select it as the default, with
llm4s-ollama as a test dependency. See Named provider sections.
1
2
3
4
5
6
7
8
9
10
11
12
# src/test/resources/application.confllm4s{providers{provider="ollama-test"ollama-test{provider="ollama"model="llama3.2"baseUrl="http://localhost:11434"# optional: this is the default}}}
importorg.llm4s.config.Llm4sConfigimportorg.llm4s.llmconnect.LLMConnectimportorg.llm4s.llmconnect.model._importorg.llm4s.model.ModelRegistryServiceimportorg.scalatest.flatspec.AnyFlatSpecimportorg.scalatest.matchers.should.MatchersclassLLMIntegrationSpecextendsAnyFlatSpecwithMatchers{// Only run if Ollama is availabledefollamaAvailable:Boolean={try{valurl=newjava.net.URL("http://localhost:11434")valconnection=url.openConnection()connection.setConnectTimeout(1000)connection.connect()true}catch{case_:Exception=>false}}"LLMClient"should"complete basic requests"in{assume(ollamaAvailable,"Ollama server not available")valresult=for{config<-Llm4sConfig.defaultProvider()registry<-Llm4sConfig.modelRegistryService()givenModelRegistryService=registryclient<-LLMConnect.getClient(config)response<-client.complete(Conversation(Seq(UserMessage("Say 'hello' and nothing else"))))}yieldresponseresultmatch{caseRight(response)=>response.content.toLowerCaseshouldinclude("hello")caseLeft(error)=>fail(s"Request failed: $error")}}itshould"handle streaming responses"in{assume(ollamaAvailable,"Ollama server not available")varchunks=List.empty[StreamedChunk]valresult=for{config<-Llm4sConfig.defaultProvider()registry<-Llm4sConfig.modelRegistryService()givenModelRegistryService=registryclient<-LLMConnect.getClient(config)completion<-client.streamComplete(Conversation(Seq(UserMessage("Count: 1, 2, 3"))),onChunk=chunk=>chunks=chunks:+chunk)}yieldcompletionresultmatch{caseRight(completion)=>chunksshouldnotbeemptychunks.last.finishReasonshouldbe(Some("stop"))caseLeft(error)=>fail(s"Streaming failed: $error")}}}
Testing Error Handling
Always test error paths. Reply.failure makes the client return the real LLMError types, so your code sees exactly
what a provider would give it:
importorg.llm4s.agent.Agentimportorg.llm4s.agent.graph.GraphErrorimportorg.llm4s.error.{AuthenticationError,NetworkError,RateLimitError}importorg.llm4s.testkit.{Reply,ScriptedLLMClient}importorg.scalatest.flatspec.AnyFlatSpecimportorg.scalatest.matchers.should.MatchersclassErrorHandlingSpecextendsAnyFlatSpecwithMatchers{privatedefrunWith(reply:Reply)=Agent.builder("test-agent",ScriptedLLMClient.always(reply)).build().flatMap(_.run("test query"))"Agent"should"handle rate limiting"in{// A provider error ends the run as GraphError.NodeFailed, carrying the client's error as its causerunWith(Reply.failure(RateLimitError("Rate limit exceeded")))match{caseLeft(GraphError.NodeFailed(_,_,_:RateLimitError))=>succeedcaseother=>fail(s"Expected RateLimitError but got: $other")}}itshould"handle authentication errors"in{runWith(Reply.failure(AuthenticationError("openai","Invalid API key","401")))match{caseLeft(GraphError.NodeFailed(_,_,_:AuthenticationError))=>succeedcaseother=>fail(s"Expected AuthenticationError but got: $other")}}itshould"handle network timeouts"in{// No need to sleep: the error a timed-out call returns is what the code under test seesrunWith(Reply.failure(NetworkError("Request timeout",None,"https://api.example.com")))match{caseLeft(GraphError.NodeFailed(_,_,_:NetworkError))=>succeedcaseother=>fail(s"Expected NetworkError but got: $other")}}}
Testing Tool Calling
Script the model’s tool call and then its answer with ScriptedLLMClient.sequence: the first call gets the first
reply, the second call the second. The agent runs your real tool in between, and the recorded calls show what it sent
back to the model:
importorg.llm4s.agent.Agentimportorg.llm4s.llmconnect.model._importorg.llm4s.testkit.{Reply,ScriptedLLMClient}importorg.llm4s.toolapi.{Schema,ToolBuilder,ToolRegistry}importorg.scalatest.flatspec.AnyFlatSpecimportorg.scalatest.matchers.should.MatchersclassToolCallingSpecextendsAnyFlatSpecwithMatchers{"Agent"should"invoke the weather tool with the model's arguments"in{varcapturedCity:Option[String]=None// A tool that records the arguments it was called withvalweatherTool=ToolBuilder[Map[String, Any], String]("get_weather","Get weather for a city",Schema.`object`[Map[String, Any]]("Weather query parameters").withProperty(Schema.property("city",Schema.string("City name")))).withHandler{extractor=>extractor.getString("city").map{city=>capturedCity=Some(city)s"Weather in $city: 20°C"}}.buildSafe()valclient=ScriptedLLMClient.sequence(Reply.toolCall("get_weather","""{"city": "London"}"""),Reply.text("It is 20°C in London."))valoutcome=for{tool<-weatherToolagent<-Agent.builder("test-agent",client).withTools(newToolRegistry(Seq(tool))).build()_<-agent.run("What's the weather in London?")}yield()outcome.isRightshouldBetruecapturedCityshouldBeSome("London")client.callCountshouldBe2// the second request carries the tool's result back to the modelclient.calls(1).messages.collect{caset:ToolMessage=>t.content}shouldBeSeq("Weather in London: 20°C")}}
Testing RAG Applications
Test document retrieval and answer generation separately:
importorg.scalatest.flatspec.AnyFlatSpecimportorg.scalatest.matchers.should.MatchersclassRAGSpecextendsAnyFlatSpecwithMatchers{"VectorStore"should"retrieve relevant documents"in{valdocuments=List("Scala is a functional programming language","Python is a dynamically typed language","Java runs on the JVM")// Note: This is conceptual pseudocode showing testing patterns. `InMemoryVectorStore`, `embedder` and// `RAGPipeline` below are illustrative stand-ins, not llm4s classes with these signatures. The llm4s-rag// module has real stores (SQLiteVectorStore, PgVectorStore, QdrantVectorStore).valvectorStore=newInMemoryVectorStore()// Pseudocode - use your vector storedocuments.foreach(doc=>vectorStore.add(doc,embedder.embed(doc)))// embedder is conceptualvalresults=vectorStore.search("functional programming",topK=1)results.headshouldinclude("Scala")}"RAG pipeline"should"include context in LLM prompt"in{// a scripted client from llm4s-testkit: it records the prompt the pipeline sentvalclient=ScriptedLLMClient.always(Reply.text("Based on the context, Scala is functional."))valrag=newRAGPipeline(client,vectorStore,embedder)rag.query("What is Scala?")client.lastCall.flatMap(_.lastUserText).getshouldinclude("context")client.lastCall.flatMap(_.lastUserText).getshouldinclude("functional")}}
# .github/workflows/test.ymlname:Teston:[push,pull_request]jobs:test:runs-on:ubuntu-lateststeps:-uses:actions/checkout@v3-name:Setup Scalauses:olafurpg/setup-scala@v14with:java-version:21-name:Install Ollamarun:|curl -fsSL https://ollama.com/install.sh | shollama serve &sleep 5ollama pull llama3.2-name:Run unit tests (fast)run:sbt "testOnly *UnitSpec"-name:Run integration tests (with Ollama)# The provider comes from src/test/resources/application.conf (the ollama-test section# above); no environment variable selects it.run:sbt "testOnly *IntegrationSpec"# Skip expensive tests in CI-name:Run full test suiterun:sbt testif:github.event_name == 'push' && github.ref == 'refs/heads/main'
Test Categorization
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Tag tests by speed/costimportorg.scalatest.Tagimportorg.scalatest.flatspec.AnyFlatSpecobjectUnitTestextendsTag("UnitTest")objectIntegrationTestextendsTag("IntegrationTest")objectExpensiveTestextendsTag("ExpensiveTest")classFastSpecextendsAnyFlatSpec{"Fast unit test"should"run in CI"taggedAsUnitTestin{// Mock-based test}}classSlowSpecextendsAnyFlatSpec{"Expensive test"should"run manually"taggedAsExpensiveTestin{// Uses real OpenAI API}}
Run specific test categories:
1
2
3
4
5
# Fast tests only
sbt "testOnly * -- -n UnitTest"# Everything except expensive tests
sbt "testOnly * -- -l ExpensiveTest"
Best Practices
✅ Mock by default: Use mock clients for unit tests
✅ Ollama for integration: Free and fast enough for CI
✅ Test behaviors, not outputs: LLM responses vary, so test that tools are called, documents are retrieved, etc.
✅ Use deterministic models when possible: Set temperature=0 for more predictable outputs
✅ Separate concerns: Test tool logic independently from LLM integration
✅ Tag expensive tests: Don’t run them in every CI build
✅ Use smaller models in CI: llama3.2 is fast and free via Ollama