# How to Unit Test IChatClient in .NET Without Calling a Real Model

The first AI endpoint you ship is exciting. The first AI endpoint you try to **unit test IChatClient** against is where the excitement stops. Your test suite suddenly needs an API key, costs money per run, takes four seconds instead of forty milliseconds, and fails randomly because the model phrased its answer differently this time. In production I've watched a team quietly mark their AI tests `[Skip]` within two sprints of shipping, which is the worst possible outcome: the least deterministic code in the system ends up the least tested.

The fix is not clever mocking. It is recognising that `IChatClient` is an ordinary .NET interface and that almost everything worth testing around an AI endpoint is deterministic code that happens to sit next to a model. This walkthrough builds a small fake chat client, uses it to assert the things that actually break, and draws a clear line between what belongs in a unit test and what needs evaluation instead. The complete test project - fake, streaming support, recording helpers and all - is on [Patreon](https://www.patreon.com/CodingDroplets) if you would rather read working code than assemble it from snippets.

Knowing where that line sits is the part most teams get wrong, because "test the AI" is not one problem. [Chapter 17 of AI-Powered .NET APIs](https://aiapis.codingdroplets.com/) separates the two explicitly - what you can unit test, such as prompts building correctly and tools validating their input, versus what needs `Microsoft.Extensions.AI.Evaluation` wired into xUnit - against one real support API you can run.

[![AI-Powered .NET APIs](https://newsletter.codingdroplets.com/images/ai-api-course-banner-1.jpg align="center")](https://aiapis.codingdroplets.com/)

Everything here targets .NET 10 with `Microsoft.Extensions.AI` 10.x.

## The Business Problem

Take a realistic feature: a support API that classifies an incoming ticket into a category and a priority, then routes it. The service builds a system prompt, appends the ticket text, calls the model, parses the result, and hands off to a queue.

Four things in that flow can break, and only one of them involves the model:

1.  The system prompt is built wrong - a placeholder never substituted, the wrong tenant's policy text injected.
    
2.  The conversation history is trimmed incorrectly and drops the system message.
    
3.  The model's response is unparseable, or parses into a category the routing table does not know.
    
4.  The model classifies correctly but stupidly.
    

Items one to three are plain deterministic bugs. They deserve fast unit tests. Item four is a quality question, and no unit test will ever answer it. Conflating the two is why AI test suites rot.

## Why Not Just Mock IChatClient With Moq?

You can, and for a single call it works. The problem shows up the moment streaming enters the picture.

`IChatClient` has two methods that matter. `GetResponseAsync` returns a `Task<ChatResponse>`, which mocking frameworks handle fine. `GetStreamingResponseAsync` returns an `IAsyncEnumerable<ChatResponseUpdate>`, and setting that up in Moq or NSubstitute means hand-building an async sequence in every test that touches streaming. Worse, the relationship between a `ChatResponse` and the `ChatResponseUpdate` chunks that would have produced it is something you now have to keep consistent by hand, in every test.

In production I've found a small hand-written fake beats a mocking framework here for three reasons: it keeps the two methods consistent with each other automatically, it can record what the application actually sent, and it reads as a test fixture rather than as six lines of setup ceremony. The mocking framework is still the right tool for `IOrderRepository`. It is the wrong tool for a streaming AI client.

## Building the Fake Chat Client

The interface surface you need to satisfy is small: two methods, a `GetService` hook, and `IDisposable`. Here is the core of a scripted fake that returns canned replies in order and records every message list it was given:

```csharp
public sealed class FakeChatClient(params string[] replies) : IChatClient
{
    private int _index;
    public List<IList<ChatMessage>> Received { get; } = [];

    public Task<ChatResponse> GetResponseAsync(
        IEnumerable<ChatMessage> messages,
        ChatOptions? options = null,
        CancellationToken cancellationToken = default)
    {
        Received.Add([.. messages]);
        var text = replies[Math.Min(_index++, replies.Length - 1)];
        return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, text)));
    }

    public object? GetService(Type serviceType, object? serviceKey = null) => null;
    public void Dispose() { }
}
```

That is C# 12 primary constructor and collection expression syntax, so .NET 8 or later. The streaming method is the other half - it replays the same canned reply as a sequence of `ChatResponseUpdate` chunks, which is what keeps the streaming and non-streaming paths honest with each other.

The important design decision is `Received`. A fake that only returns values lets you test the parsing half of your service. A fake that also **records the request** lets you test the prompt-building half, which is where the subtle bugs live.

## Testing the Half That Actually Breaks

With the fake in place, the tests that matter are ordinary xUnit tests with no network, no key and no flakiness.

**Assert the prompt was built correctly.** This is the highest-value AI test you can write and almost nobody writes it:

```csharp
[Fact]
public async Task Classifier_Sends_Tenant_Policy_In_System_Message()
{
    var chat = new FakeChatClient("""{"category":"billing","priority":"high"}""");
    var sut = new TicketClassifier(chat);

    await sut.ClassifyAsync("I was charged twice", tenantId: "acme");

    var system = chat.Received[0].First(m => m.Role == ChatRole.System);
    Assert.Contains("acme refund policy", system.Text);
}
```

**Assert the parsing is defensive.** Feed the fake a reply the model will eventually produce and your parser will not expect - a fenced code block, a leading apology, a category that is not in your enum - and assert your service degrades correctly rather than throwing an unhandled exception into the request pipeline. This is a genuinely common production failure and it is trivially testable. We cover the failure shapes in detail in our post on [invalid JSON from structured outputs](https://codingdroplets.com/structured-output-invalid-json-dotnet-fixes).

**Assert history trimming preserves the system message.** Point the fake at a service holding twenty turns of conversation, run the trim, and assert the system message survived and the oldest user turn did not.

**Assert cancellation propagates.** Pass an already-cancelled token and assert the endpoint returns rather than hanging. Streaming endpoints get this wrong constantly, as covered in [streaming LLM responses with IChatClient](https://codingdroplets.com/stream-llm-responses-aspnet-core-ichatclient).

None of these tests care what the model says. All of them catch bugs that reach production.

## Wiring the Fake Into an Integration Test

For endpoint-level tests, register the fake in `WebApplicationFactory` so the real provider is never constructed:

```csharp
builder.ConfigureTestServices(services =>
{
    services.RemoveAll<IChatClient>();
    services.AddSingleton<IChatClient>(new FakeChatClient("routed:billing"));
});
```

`RemoveAll<T>()` comes from `Microsoft.Extensions.DependencyInjection.Extensions` and matters more than it looks - without it you end up with two registrations and the last one wins by accident rather than by intent.

This gives you a full request-through-response test of the AI endpoint: routing, model binding, validation, auth, serialisation and error handling all exercised, with the model replaced by a constant. That is the correct scope for an integration test of an AI feature.

## What You Should Not Try to Unit Test

Be honest about the boundary. A unit test asserts a deterministic property of your code. These are not that:

*   **Answer quality.** Whether the classification is correct across a realistic ticket distribution is an evaluation problem, measured over a dataset with relevance and groundedness scores, not asserted in a `[Fact]`.
    
*   **Prompt effectiveness.** Whether your system prompt reduces hallucination is measured, not asserted.
    
*   **Provider behaviour.** Whether Azure OpenAI returns the same shape as Ollama for a given request is a contract concern. One narrow, explicitly-tagged test hitting the real provider on a schedule covers it. It does not belong in the suite that runs on every commit.
    

Trying to assert quality in a unit test produces exactly the outcome I described at the top: a test that fails for reasons nobody can act on, then gets skipped, then gets deleted. Put quality measurement in an evaluation suite where a score can trend over time. Our guide on [evaluating LLM output in .NET](https://codingdroplets.com/evaluate-llm-output-dotnet) covers that side.

## Trade-Offs Worth Naming

A hand-written fake is code you now own. When `Microsoft.Extensions.AI` adds a member to `IChatClient`, your fake stops compiling. That is a real maintenance cost, and it is small - the interface is deliberately narrow, and a compile error is a much better failure mode than a silently wrong mock.

The bigger trade-off is false confidence. A green suite built entirely on canned replies proves your plumbing is correct and proves nothing about whether the feature works. That is fine as long as everyone knows it. It stops being fine the moment someone reads "AI tests passing" as "the AI is good."

The middle ground I've shipped and would recommend: fakes for everything on the commit path, one thin real-provider smoke test on the nightly build, and an evaluation suite that runs on prompt changes.

## What to Do Next

Start with the recording assertion. Pick your most complex prompt-building code path, drop a fake behind it, and assert the exact system message that gets sent. In my experience that single test finds a bug the first time it runs on a codebase that has never had one, usually a placeholder that was never substituted or a policy block appended to the wrong role.

From there, add the parse-failure tests, then the trimming tests, then wire the fake into `WebApplicationFactory` for the endpoint-level pass.

## Frequently Asked Questions

### How Do I Unit Test IChatClient Without an API Key?

Register a hand-written fake that implements `IChatClient` and return canned `ChatResponse` values. Because your services depend on the interface rather than a concrete provider client, no key, network call or running Ollama instance is needed. Swap the registration in `ConfigureTestServices` for endpoint-level tests.

### Should I Use Moq or NSubstitute to Mock IChatClient?

For a single non-streaming call, either works. For anything touching `GetStreamingResponseAsync`, a small fake is easier, because you would otherwise hand-build an `IAsyncEnumerable<ChatResponseUpdate>` in every test and keep it consistent with the non-streaming path yourself. The fake also records requests, which mocking frameworks make awkward to assert against.

### Can I Test Structured Outputs and Tool Calling the Same Way?

Yes. For structured outputs, have the fake return the JSON the model would produce and assert your deserialisation and validation handle both the good and the malformed shapes. For tool calling, the higher-value tests are on the tool implementations themselves - argument validation, authorisation, idempotency - since those are ordinary methods the model merely invokes.

### What Is the Difference Between Testing and Evaluating an AI Endpoint?

A test asserts a deterministic property of your code and fails a build when it breaks. An evaluation measures output quality across a dataset and produces a score you track over time. Prompt construction, parsing and error handling are tests. Relevance, groundedness and refusal behaviour are evaluations. Keep them in separate suites with separate cadences.

### Does DelegatingChatClient Help With Testing?

It does, for a different job. `DelegatingChatClient` in `Microsoft.Extensions.AI` is a base class for wrapping another client, which makes it a clean way to build a recording or replay wrapper around a real client - useful for capturing production-shaped responses once and replaying them in tests. For a scripted fake with no inner client, implementing `IChatClient` directly is simpler.

### Where Is the Official IChatClient Reference?

The [IChatClient interface documentation on Microsoft Learn](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.ai.ichatclient) lists the exact member signatures, including the extension methods for structured output, and is the authority on what your fake needs to satisfy.

* * *

## About the Author

I'm Celin Daniel, Co-founder of [Coding Droplets](https://codingdroplets.com/). I've been building .NET and ASP.NET Core systems in production for 13+ years - APIs, distributed backends, enterprise platforms. Everything I write here comes from real shipping experience: patterns that held up, trade-offs that bit us, and lessons learned the hard way.

*   GitHub: [codingdroplets](http://github.com/codingdroplets/)
    
*   YouTube: [Coding Droplets](https://www.youtube.com/@CodingDroplets)
    
*   Website: [codingdroplets.com](https://codingdroplets.com/)
