Multiple Interfaces

February 20, 2026 · View on GitHub

Home > Guides > Multiple Interfaces

Multiple Interfaces

KnockOff handles multiple interfaces through two mechanisms: interface inheritance (a single stub covers the full hierarchy with unified interceptors) and inline stubs (multiple [KnockOff<T>] attributes on one test class for unrelated interfaces).


The Comparison

The scenario

A service that implements both a repository and a unit of work:

public interface IRepository
{
    User? GetUser(int id);
    void Save(User user);
}

public interface IUnitOfWork
{
    void Commit();
    void Rollback();
}

And an interface hierarchy:

public interface IReadableStore
{
    string? GetById(int id);
    int Count { get; }
}

public interface IStore : IReadableStore
{
    void Save(int id, string value);
    void Delete(int id);
}

KnockOff

Interface inheritance — a single stub covers the full hierarchy. All interceptors are unified:

var stub = new Stubs.IStore();

// All members from IStore AND IReadableStore — same flat API
stub.GetById.Call((id) => $"item-{id}");  // from IReadableStore
stub.Count.Get(42);                           // from IReadableStore
stub.Save.Call((int id, string value) => { });           // from IStore
stub.Delete.Call((id) => { });                // from IStore

IStore store = stub;
Assert.Equal("item-1", store.GetById(1));
Assert.Equal(42, store.Count);

Source delegation works per interface level — pass a partial implementation and only matching members get delegated:

var stub = new Stubs.IStore();
var readOnlySource = new ReadOnlyStore(
    new Dictionary<int, string> { [1] = "value-1" });

// Only delegates GetById and Count — Save and Delete remain unconfigured
stub.Source(readOnlySource);

Unrelated interfaces — use inline stubs:

var repo = new Stubs.IRepository();
var uow = new Stubs.IUnitOfWork();

repo.GetUser.Call((id) => new User { Id = id });
uow.Commit.Call(() => { }).Verifiable();

// Pass both to the system under test
var service = new MyService(repo, uow);
service.UpdateUser(1);

uow.Verify();

Moq

Moq uses mock.As<T>() to add interfaces to a single mock:

var mock = new Mock<IRepository>();

// Add second interface
var uow = mock.As<IUnitOfWork>();

// Setup on primary interface
mock.Setup(x => x.GetUser(It.IsAny<int>()))
    .Returns<int>(id => new User { Id = id });

// Setup on secondary interface — different variable
uow.Setup(x => x.Commit());

// Cast to get the secondary interface
var service = new MyService(mock.Object, (IUnitOfWork)mock.Object);

// Verify on secondary — need the As<T> reference
uow.Verify(x => x.Commit(), Times.Once());

Pain points:

  • .As<T>() returns a separate Mock<T> reference — setup and verify are split across variables
  • With 3+ interfaces, you juggle multiple references: mock, asRepo, asUow, asLogger...
  • Easy to forget which variable corresponds to which interface
  • Must cast mock.Object to access secondary interfaces

NSubstitute

NSubstitute supports multiple interfaces via Substitute.For<T1, T2>():

var sub = Substitute.For<IRepository, IUnitOfWork>();

// Primary interface — works directly
sub.GetUser(Arg.Any<int>()).Returns(x => new User { Id = x.Arg<int>() });

// Secondary interface — must cast
((IUnitOfWork)sub).Commit();

// Verify on secondary — must cast
((IUnitOfWork)sub).Received().Commit();

Pain points:

  • Must cast to access non-primary interfaces: ((IUnitOfWork)sub).Commit()
  • Casts are noisy with 3+ interfaces
  • Substitute.For<T1, T2, T3>() supports up to 3 type parameters; beyond that requires the params overload

Summary

CapabilityKnockOffMoqNSubstitute
Interface hierarchyUnified flat API: stub.MemberSame as single interfaceSame as single interface
Unrelated interfacesSeparate inline stubs or standalone stubs.As<T>() — split setup/verifySubstitute.For<T1, T2>() — cast for secondary
Source delegation per levelstub.Source(partialImpl) — per interfaceNot availableNot available
3+ interfacesOne [KnockOff<T>] per interfaceMultiple .As<T>() variablesCast for each non-primary interface
Verify across interfacesEach stub has its own Verify()Verify on each .As<T>() refCast + .Received() per interface