🎭 Imposter - Fast, Strongly-Typed Mocking for .NET

When you’re writing tests in .NET today, you’re probably reaching for a mocking library like Moq, NSubstitute, or FakeItEasy.

They’re all solid tools—but they also come with a familiar set of trade-offs:

  • Reflection and dynamic proxies add overhead and obscure what actually runs.
  • You can write setups that happily compile but only fail at runtime.
  • Debugging a broken mock often means diving into generated IL and stack traces that don’t look anything like your code.

Imposter takes a different path.

It leans on C# source generators to produce strongly-typed imposters at build time: no runtime proxy generation, no opaque dynamic behavior.

If it builds, it runs.


❓ Why Another Mocking Library?

Let’s start with the “why”. Most existing mocking frameworks are built on a similar foundation:

  • They inspect your types at runtime using reflection.
  • They create dynamic proxies (or emit IL) to intercept calls.
  • They rely heavily on runtime configuration of behaviors.

This works-but it also means:

  • Runtime-only errors: you can write a mock configuration that compiles fine but crashes as soon as that test runs.
  • Opaque generated code: it’s hard to see what the mock actually emits; the behavior lives in runtime-generated IL rather than readable C#.
  • Hidden costs: proxies and reflection add overhead you don’t always see until your test suite grows.

The best part is that Imposter is still relatively fresh: the design space isn’t “frozen”, and I’d genuinely love to hear about pain points you’ve hit with other mocking libraries (or just ideas for improvements) that Imposter could address. If something sharp comes to mind, open an issue on GitHub

Moq Example: Compiles Fine, Fails at Runtime

Here’s a classic minimal example in Moq that compiles, but fails when executed:

public interface ICalculator
{
    int Add(int x, int y);
}

[Fact]
public void Moq_InvalidCallbackSignature()
{
    var mock = new Mock<ICalculator>();
    // throws 'invalid callback. Setup on method with parameters (int, int) cannot invoke callback with parameters (int)'
    mock.Setup(m => m.Add(1, 2))
        .Callback((int a) =>
        {

        });
}

The compiler is happy. The problem only shows up when Moq tries to register the callback, it throws an exception.

With Imposter, the same kind of mistake simply doesn’t compile, because the API is strongly-typed per member.


💡 The Core Idea: Generated Imposters

First, add the package to your test project:

<ItemGroup>
  <PackageReference Include="Imposter" Version="*" PrivateAssets="all" />
</ItemGroup>

Imposter revolves around a single attribute:

using Imposter.Abstractions;

[assembly: GenerateImposter(typeof(IMyService))]

public interface IMyService
{
    int Increment(int value);
}

Note: For simplicity we put the GenerateImposter in the same project where IMyService is defined. That is not a requirement—GenerateImposterAttribute can (and often should) be declared in the test project while IMyService lives in a referenced project. See Sample.Tests

When you build the project, a C# source generator kicks in and produces a concrete imposter type, which you can inspect. The following is generated.

  • IMyServiceImposter – the generated imposter class with all of it’s helper classes in it to enable impersonation.
  • IMyService.Imposter() – a helper factory method for C# 14 and above.

In tests, you:

  1. Create the imposter.
  2. Configure behavior using a fluent API.
  3. Get an Instance() that you pass into your system under test.
var imposter = IMyService.Imposter();        // or new IMyServiceImposter();
imposter.Increment(Arg<int>.Any()).Returns(3);

var service = imposter.Instance();
service.Increment(1).ShouldBe(3);

There’s no dynamic proxy here—just generated C# code that your compiler sees and type-checks like everything else.

For more details about argument matching check the docs


Modes: Implicit vs Explicit

When you construct an imposter, you choose the mode:

var implicitImposter = new IMyServiceImposter(ImposterMode.Implicit);
var explicitImposter = new IMyServiceImposter(ImposterMode.Explicit);
  • Implicit – missing setups return default(T) / do nothing.
  • Explicit – missing setups throw MissingImposterException.

For unit tests, Explicit is usually the better default—it catches “forgotten” setups early.

Sequencing with Then()

You can model evolving behavior over time with Then():

imposter.GetNumberAsync()
    .ReturnsAsync(1)
    .Then().ReturnsAsync(2)
    .Then().ReturnsAsync(3);

await service.GetNumberAsync(); // 1
await service.GetNumberAsync(); // 2
await service.GetNumberAsync(); // 3
await service.GetNumberAsync(); // 3 (last value repeats)

Any combination of Returns, ReturnsAsync, and Throws can be chained this way. Once the sequence is exhausted, the last outcome repeats.

Callbacks

Callbacks let you observe calls and apply side effects:

imposter.Increment(Arg<int>.Any())
    .Returns(v => v + 1)
    .Callback(v => Logger.Log($"Increment called with {v}"));

All of this is strongly-typed, checked at compile time.


🧪 Verifying Calls with Count

Verification is built into the same fluent API via Called(Count …):

service.Increment(1);
service.Increment(2);
service.Increment(2);

imposter.Increment(Arg<int>.Any()).Called(Count.AtLeast(3));
imposter.Increment(2).Called(Count.Exactly(2));
imposter.Increment(1).Called(Count.Once());
imposter.Increment(999).Called(Count.Never());

If verification fails, Imposter throws a VerificationFailedException with clear expected vs actual counts, plus a textual list of performed invocations.

see the Verification section in the official docs for more details about verification.


🏛️ Classes and Base Implementations

Imposter doesn’t just work with interfaces—it can also impersonate non-sealed classes with virtual/abstract members.

That opens up a powerful option: forwarding to the base implementation.

[assembly: GenerateImposter(typeof(MyService))]

public class MyService
{
    public virtual int Add(int a, int b) => a + b;
}

In your tests:

var imposter = new MyServiceImposter();
var service = imposter.Instance();

// Forward matching calls to the real implementation
imposter.Add(Arg<int>.Any(), Arg<int>.Any()).UseBaseImplementation();

service.Add(2, 5); // 7 (calls MyService.Add)

You can also mix base behavior with custom returns using Then():

imposter.Add(Arg<int>.Any(), Arg<int>.Any())
    .UseBaseImplementation()
    .Then().Returns(100);

service.Add(1, 1); // 2 (base)
service.Add(1, 1); // 100

This is great when classes encapsulate invariants, caching, or validation logic you don’t want to reimplement in tests.


🧬 Generics and Open Generics

Generic support in Imposter is first-class:

  • Generic methods get independent setups per closed type argument combination.
  • Generic interfaces/classes get separate imposters for each closed type.
  • Open generic targets (like IAsyncObservable<>) can be registered once and used across many concrete types.

Example with an open generic interface:

using Imposter.Abstractions;

[assembly: GenerateImposter(typeof(IAsyncObservable<>))]

public interface IAsyncObservable<T>
{
    void OnNext(T item);
}

Each closed type gets its own imposter and call history:

var stringImposter = IAsyncObservable<string>.Imposter();
var intImposter = IAsyncObservable<int>.Imposter();

var stringStream = stringImposter.Instance();
var intStream = intImposter.Instance();

stringStream.OnNext("payload");
stringStream.OnNext("another");
intStream.OnNext(42);

stringImposter.OnNext(Arg<string>.Any()).Called(Count.Exactly(2));
intImposter.OnNext(Arg<int>.Any()).Called(Count.Once());

No cross-talk between the different type arguments—each closed generic combination is isolated.


⚙️ Performance and Benchmarks

Because imposters are generated at compile time and don’t rely on runtime proxy generation, Imposter can be both fast and memory-efficient.

The project ships with a BenchmarkDotNet suite that compares Imposter with popular mocking libraries (Moq, NSubstitute) on a simple method-impersonation scenario:

public interface ICalculator
{
    int Square(int input);
}

For 1, 10, 100, and 1000 setups + calls, the mean times look like this (from the README):

  • 1 iteration: Moq ≈ 69,346 ns, NSubstitute ≈ 1,976 ns, Imposter ≈ 194 ns
  • 10 iterations: Moq ≈ 686,283 ns, NSubstitute ≈ 11,202 ns, Imposter ≈ 1,897 ns
  • 100 iterations: Moq ≈ 6,804,897 ns, NSubstitute ≈ 335,391 ns, Imposter ≈ 34,012 ns
  • 1000 iterations: Moq ≈ 99,710,929 ns, NSubstitute ≈ 26,986,939 ns, Imposter ≈ 2,452,971 ns

And in terms of allocated memory:

  • 1 iteration: Moq ≈ 13.05 KB, NSubstitute ≈ 7.84 KB, Imposter ≈ 2.4 KB
  • 10 iterations: Moq ≈ 115.73 KB, NSubstitute ≈ 29.29 KB, Imposter ≈ 22.37 KB
  • 100 iterations: Moq ≈ 1416.91 KB, NSubstitute ≈ 247.26 KB, Imposter ≈ 222.05 KB
  • 1000 iterations: Moq ≈ 42,275.19 KB, NSubstitute ≈ 2,420.82 KB, Imposter ≈ 2,218.93 KB

Numbers will vary across machines and frameworks, but the pattern is consistent:

  • Predictable, low allocation behavior.
  • Competitive or better throughput as the number of setups/invocations grows.

If you care about large test suites or tight CI budgets, these characteristics make source-generated imposters a strong fit.


✅ Limitations

A few important constraints and recommendations:

  • C# 9.0 or later only
  • Only non-sealed classes can be impersonated; only virtual/abstract members participate on class imposters.

🔎 Properties, Indexers, and Events

This post focused on methods, but Imposter’s model extends naturally to other members:

🧭 Where to Go Next

Checkout Docs and Github