C# 通过伪造调度对 System.Reactive 可观察对象进行单元测试

假设有一个依赖时间的可观察对象,你需要编写不依赖时间的单元测试。依赖时间的可观察对象会使用超时、窗口(或缓冲)以及节流(或采样),现在需要对可观察对象进行单元测试,但单元测试的用时不要过长。

在单元测试中加入延迟自然是可行的,但是,这样做会造成两个问题:首先,单元测试耗时长;其次,所有单元测试同时运行会导致竞争条件,使计时变得不可预测。

System.Reactive 库的开发人员在设计之初就考虑了测试。其实,System.Reactive 库自身已经过了大量的单元测试。为了能充分地进行单元测试,System.Reactive 引入了调度器这个概念,每个处理时间的 System.Reactive 运算符都是通过这个抽象的调度器实现的。

为了能够测试可观察对象,需要让调用函数指定调度器。比如,可以使用 7.5 节中的 MyTimeoutClass 并添加调度器:

public interface IHttpService
    {
        IObservable<string> GetString(string url);
    }
    
    public class MyTimeoutClass
    {
        private readonly IHttpService _httpService;
    
        public MyTimeoutClass(IHttpService httpService)
        {
            _httpService = httpService;
        }
    
        public IObservable<string> GetStringWithTimeout(string url,
            IScheduler scheduler = null)
        {
            return _httpService.GetString(url)
                .Timeout(TimeSpan.FromSeconds(1), scheduler ?? Scheduler.Default);
        }
    }

接下来,修改 HTTP 服务存根,这样它能理解调度,然后引入可变延迟:

private class SuccessHttpServiceStub : IHttpService
    {
        public IScheduler Scheduler { get; set; }
        public TimeSpan Delay { get; set; }
    
        public IObservable<string> GetString(string url)
        {
            return Observable.Return("stub")
                .Delay(Delay, Scheduler);
        }
    }

现在使用 System.Reactive 库中的 TestScheduler 类型,从而有效地掌控(虚拟)时间。

TestScheduler 存在于 System.Reactive 中的一个独立的 NuGet 包里,需要安装 Microsoft.Reactive.Testing NuGet 包才能使用。

凭借 TestScheduler 可以完全掌控时间,而且通常只要设置好参数,然后调用 TestScheduler.Start 就足够了。它会虚拟地推进时间,直到完成所有操作。下面是一个简单的成功单元测试用例:

[TestMethod]
    public void MyTimeoutClass_SuccessfulGetShortDelay_ReturnsResult()
    {
        var scheduler = new TestScheduler();
        var stub = new SuccessHttpServiceStub
        {
            Scheduler = scheduler,
            Delay = TimeSpan.FromSeconds(0.5),
        };
        var my = new MyTimeoutClass(stub);
        string result = null;
    
        my.GetStringWithTimeout("http://exampleurl", scheduler)
            .Subscribe(r => { result = r; });
    
        scheduler.Start();
    
        Assert.AreEqual("stub", result);
    }

代码模拟了半秒的网络延迟。注意,该单元测试耗时不到半秒,这一点很重要。在我的计算机上,它耗时 70 毫秒。半秒的延迟只存在于虚拟时间中。另一个重要的差别是,该单元测试并非异步,由于使用了 TestScheduler,因此所有的测试可以立即完成。

既然到处都用了测试调度器,那么测试超时情况就简单了:

[TestMethod]
    public void MyTimeoutClass_SuccessfulGetLongDelay_ThrowsTimeoutException()
    {
        var scheduler = new TestScheduler();
        var stub = new SuccessHttpServiceStub
        {
            Scheduler = scheduler,
            Delay = TimeSpan.FromSeconds(1.5),
        };
        var my = new MyTimeoutClass(stub);
        Exception result = null;
    
        my.GetStringWithTimeout("http://exampleurl", scheduler)
            .Subscribe(_ => Assert.Fail("Received value"), ex => { result = ex; });
    
        scheduler.Start();
    
        Assert.IsInstanceOfType(result, typeof(TimeoutException));
    }

上面的单元测试耗时不到 1.5 秒,它通过虚拟时间立即执行了。

本节只略微探讨了 System.Reactive 调度器和虚拟时间。在编写 System.Reactive 代码时,推荐进行单元测试。当代码变得越来越复杂时,就可以放心交由 Microsoft.Reactive.Testing 来处理。

TestScheduler 还有 AdvanceTo 方法和 AdvanceBy 方法,可以在虚拟时间中渐进地前行。在某些情况下能够使用这些方法,但应当尽力让单元测试只专注于测试一个东西。如果需要测试超时,可以写一个简单的单元测试,来局部推进 TestScheduler,同时确保超时不至于过早发生。然后,推进 TestScheduler,直到超过超时值,并确保发生了超时。但是,我个人更倾向于尽可能分开进行单元测试。比如,通过一个单元测试确保超时未过早发生,然后通过另一个单元测试确保超时在之后确实发生了。

 (完)

相关阅读:


C# 对 async 方法进行单元测试

C# 对预期失败的 async 方法进行单元测试

C# 对 async void 方法进行单元测试

C# 对数据流网格进行单元测试

C# 对 System.Reactive 可观察对象进行单元测试

C# 通过伪造调度对 System.Reactive 可观察对象进行单元测试

评论

此博客中的热门博文

in 参数(C# 7.2)

C# ref 局部变量和 ref return

类 ref 结构体(C# 7.2)