n4nAI

Unit testing LLM API integrations in C# with Moq

Learn how to unit test LLM API integrations in C# using Moq to isolate HTTP calls, mock responses, and verify behavior without hitting live endpoints.

n4n Team2 min read538 words

Audio narration

Coming soon — every post will get a voice note here.

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.

Tagscsharpmoqtestingllm-integration

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All c# / .net llm api integration posts →