无接口无虚方法的类如何注入伪造实现?以DiscordSocketClient测试为例
解决方案
方案1:自定义接口+适配包装类(最推荐,长期可维护)
这是.NET生态下处理不可Mock第三方依赖的标准最佳实践,无额外依赖,符合依赖倒置原则,改造成本极低。
实现步骤
- 定义包含所需方法的抽象接口
public interface IDiscordClient { Task LoginAsync(TokenType tokenType, string token, bool validateToken = true); Task StartAsync(); Task LogoutAsync(); Task StopAsync(); }
- 编写适配类继承原生
DiscordSocketClient并实现接口,直接透传原生方法即可
public class DiscordSocketClientAdapter : DiscordSocketClient, IDiscordClient { // 透传原生类的所有构造函数即可,根据你实际使用的构造函数调整 public DiscordSocketClientAdapter() : base() {} public DiscordSocketClientAdapter(DiscordSocketConfig config) : base(config) {} }
- 修改消费类的依赖为抽象接口,不需要改动原有业务逻辑
public sealed class ConsumingClass { private readonly IDiscordClient _discordClient; public ConsumingClass(IDiscordClient discordClient) { _discordClient = discordClient; } public override async Task Initialize(CancellationToken cancellationToken) { await _discordClient.LoginAsync(TokenType.Bot, "my-token"); await _discordClient.StartAsync(); } public override async Task TearDown(CancellationToken cancellationToken) { await _discordClient.LogoutAsync(); await _discordClient.StopAsync(); } }
- 调整DI容器注册逻辑,将适配类注册为单例
// 替换原来的DiscordSocketClient注册即可 services.AddSingleton<IDiscordClient, DiscordSocketClientAdapter>(sp => { // 按你原有逻辑构造DiscordSocketClient实例即可 var config = new DiscordSocketConfig { /* 原有配置 */ }; return new DiscordSocketClientAdapter(config); });
测试用例编写
直接Mock抽象接口即可实现你需要的调用校验逻辑
public class ConsumingClassTests { [Fact] public async Task Initialize_ShouldInvokeLoginAndStartOnce() { // Arrange var mockDiscordClient = new Mock<IDiscordClient>(); var consumingClass = new ConsumingClass(mockDiscordClient.Object); // Act await consumingClass.Initialize(CancellationToken.None); // Assert mockDiscordClient.Verify(x => x.LoginAsync(TokenType.Bot, "my-token", true), Times.Once); mockDiscordClient.Verify(x => x.StartAsync(), Times.Once); } [Fact] public async Task TearDown_ShouldInvokeLogoutAndStopOnce() { // Arrange var mockDiscordClient = new Mock<IDiscordClient>(); var consumingClass = new ConsumingClass(mockDiscordClient.Object); // Act await consumingClass.TearDown(CancellationToken.None); // Assert mockDiscordClient.Verify(x => x.LogoutAsync(), Times.Once); mockDiscordClient.Verify(x => x.StopAsync(), Times.Once); } }
方案2:免改业务代码的隔离框架(仅适合临时场景)
如果你完全不想改动现有业务代码和DI配置,可以使用支持拦截非虚方法的测试隔离框架,比如Pose、JustMock、TypeMock等,这类框架可以在运行时替换原生方法的实现,不需要定义抽象层。
以Pose为例的测试实现如下:
[Fact] public async Task Initialize_ShouldCallLoginAndStartOnce() { int loginCallCount = 0; int startCallCount = 0; // 替换原生LoginAsync实现 var loginShim = Shim.Replace(() => Is.A<DiscordSocketClient>().LoginAsync(Is.A<TokenType>(), Is.A<string>(), Is.A<bool>())) .With((DiscordSocketClient _, TokenType type, string token, bool validate) => { loginCallCount++; return Task.CompletedTask; }); // 替换原生StartAsync实现 var startShim = Shim.Replace(() => Is.A<DiscordSocketClient>().StartAsync()) .With((DiscordSocketClient _) => { startCallCount++; return Task.CompletedTask; }); // 执行测试逻辑 var client = new DiscordSocketClient(); var consumingClass = new ConsumingClass(client); await PoseContext.Isolate(async () => { await consumingClass.Initialize(CancellationToken.None); }, loginShim, startShim); // 校验调用次数 Assert.Equal(1, loginCallCount); Assert.Equal(1, startCallCount); }
该方案缺点明显:存在额外第三方依赖,部分框架为商业收费,.NET版本或第三方包更新后容易出现兼容性问题,长期维护成本远高于方案1。
选择建议
优先选择方案1,整体改造成本极低,且后续如果需要升级Discord.Net版本、替换SDK实现都不需要修改上层业务代码,可扩展性极强。
内容的提问来源于stack exchange,提问作者Question3r
相关产品推荐
相关产品推荐

