Most LLM integrations in .NET fail in production because the code that calls the model is coupled to HttpClient and impossible to test. This guide shows how to apply c# moq llm api testing patterns to isolate your business logic from the network and assert on prompt construction, retries, and error handling.
Step 1: Define a narrow interface over the LLM call
Mocking HttpClient directly is brittle. You end up asserting on HttpMessageHandler protected methods, which breaks on every SDK bump. Wrap the completion call in a small interface that expresses your domain needs, not the wire format.
public record CompletionRequest(
string Model,
string Prompt,
float Temperature = 0.2f,
int MaxTokens = 256);
public record CompletionResult(string Text, int PromptTokens, int CompletionTokens);
public interface ILlmClient
{
Task<CompletionResult> CompleteAsync(CompletionRequest request, CancellationToken ct = default);
}
Record types are immutable and cheap to construct in tests. Keep the interface synchronous in shape but async in implementation. That gives you a clean seam for Moq without fighting Task continuation details.
Step 2: Implement the real client against an OpenAI-compatible endpoint
The concrete client translates your request to the provider’s JSON contract. Below is a minimal version targeting any OpenAI-compatible /v1/chat/completions surface.
public class OpenAiCompatibleClient : ILlmClient
{
private readonly HttpClient _http;
private readonly string _apiKey;
public OpenAiCompatibleClient(HttpClient http, string apiKey)
{
_http = http;
_apiKey = apiKey;
}
public async Task<CompletionResult> CompleteAsync(CompletionRequest request, CancellationToken ct = default)
{
var payload = new
{
model = request.Model,
messages = new[] { new { role = "user", content = request.Prompt } },
temperature = request.Temperature,
max_tokens = request.MaxTokens
};
using var req = new HttpRequestMessage(HttpMethod.Post, "/v1/chat/completions");
req.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _apiKey);
req.Content = JsonContent.Create(payload);
using var res = await _http.SendAsync(req, ct);
res.EnsureSuccessStatusCode();
var json = await res.Content.ReadFromJsonAsync<JsonElement>(ct);
var text = json.GetProperty("choices")[0].GetProperty("message").GetProperty("content").GetString()!;
var usage = json.GetProperty("usage");
return new CompletionResult(
text,
usage.GetProperty("prompt_tokens").GetInt32(),
usage.GetProperty("completion_tokens").GetInt32());
}
}
If you point this at a gateway such as n4n.ai, the same code works unchanged: one OpenAI-compatible endpoint fronts 240+ models and handles provider fallback, so your ILlmClient stays identical. The unit tests below never care which backend you use.
Step 3: Write unit tests with Moq and xUnit
Install the packages:
dotnet add package Moq
dotnet add package xunit
dotnet add package Microsoft.NET.Test.Sdk
dotnet add package xunit.runner.visualstudio
Suppose a service classifies support tickets using the LLM:
public class TicketClassifier
{
private readonly ILlmClient _llm;
public TicketClassifier(ILlmClient llm) => _llm = llm;
public async Task<string> ClassifyAsync(string ticketBody)
{
var res = await _llm.CompleteAsync(new CompletionRequest(
Model: "gpt-4o-mini",
Prompt: $"Classify this ticket: {ticketBody}",
Temperature: 0));
return res.Text.Trim();
}
}
A basic c# moq llm api testing setup mocks ILlmClient and asserts the service returns the mocked text:
using Moq;
using Xunit;
public class TicketClassifierTests
{
[Fact]
public async Task ClassifyAsync_ReturnsModelOutput()
{
var mock = new Mock<ILlmClient>();
mock.Setup(x => x.CompleteAsync(It.IsAny<CompletionRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new CompletionResult("billing", 10, 2));
var classifier = new TicketClassifier(mock.Object);
var result = await classifier.ClassifyAsync("I was charged twice");
Assert.Equal("billing", result);
}
}
Run dotnet test and the test passes in milliseconds with zero network traffic.
Step 4: Assert on request shape and error handling
Moq’s Callback captures the argument so you can verify the prompt and model. This catches regressions where a refactor silently drops context or changes the model without review.
[Fact]
public async Task ClassifyAsync_SendsCorrectModelAndPrompt()
{
CompletionRequest? captured = null;
var mock = new Mock<ILlmClient>();
mock.Setup(x => x.CompleteAsync(It.IsAny<CompletionRequest>(), It.IsAny<CancellationToken>()))
.Callback<CompletionRequest, CancellationToken>((r, _) => captured = r)
.ReturnsAsync(new CompletionResult("tech", 5, 1));
var classifier = new TicketClassifier(mock.Object);
await classifier.ClassifyAsync("App crashes on launch");
Assert.NotNull(captured);
Assert.Equal("gpt-4o-mini", captured!.Model);
Assert.Contains("App crashes on launch", captured.Prompt);
Assert.Equal(0f, captured.Temperature);
mock.Verify(x => x.CompleteAsync(It.IsAny<CompletionRequest>(), It.IsAny<CancellationToken>()), Times.Once);
}
Now test failure paths. Real LLM endpoints throttle and drop connections. Your service should surface or log these without crashing the request pipeline.
[Fact]
public async Task ClassifyAsync_PropagatesException()
{
var mock = new Mock<ILlmClient>();
mock.Setup(x => x.CompleteAsync(It.IsAny<CompletionRequest>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new HttpRequestException("503"));
var classifier = new TicketClassifier(mock.Object);
await Assert.ThrowsAsync<HttpRequestException>(
() => classifier.ClassifyAsync("anything"));
}
If you add retry logic, mock a sequence: fail once, succeed on second call.
mock.SetupSequence(x => x.CompleteAsync(It.IsAny<CompletionRequest>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new TimeoutException())
.ReturnsAsync(new CompletionResult("ok", 1, 1));
That sequence test proves your retry policy actually re-invokes the client. Without c# moq llm api testing, you’d only discover the bug in production logs.
Step 5: Run the suite and verify success
Execute from the repo root:
dotnet test --logger "console;verbosity=normal"
A green run means:
- The classifier returns mocked labels without any network call.
- The exact model string and prompt template are enforced.
- Exception paths behave as designed.
To confirm isolation, disconnect from the network and re-run. If tests still pass, you’ve removed the HttpClient dependency correctly. For confidence against the real contract, keep a separate integration project that constructs OpenAiCompatibleClient with a real HttpClient and an environment-supplied key. Run those tests on CI nightly, not on every commit. The unit suite with Moq is your fast feedback loop; the integration suite catches serialization drift.
Step 6: Extend to model routing and token budgeting
Real services often pick a model based on input length or cost. Mock the client to assert routing logic:
[Fact]
public async Task RouteShortPromptToMini_ModelSelected()
{
CompletionRequest? captured = null;
var mock = new Mock<ILlmClient>();
mock.Setup(x => x.CompleteAsync(It.IsAny<CompletionRequest>(), It.IsAny<CancellationToken>()))
.Callback<CompletionRequest, CancellationToken>((r, _) => captured = r)
.ReturnsAsync(new CompletionResult("ok", 1, 1));
var router = new SmartRouter(mock.Object);
await router.RespondAsync("Hi");
Assert.Equal("gpt-4o-mini", captured!.Model);
}
You can also verify token budgeting by asserting MaxTokens on the captured request. Moq makes it trivial to simulate a provider returning near-limit usage and confirm your code downgrades the next call.
These patterns form the backbone of c# moq llm api testing for any .NET service that talks to a model. You ship logic that is verified, not hoped for.