测试使用多个调度程序的代码的技术
Techniques for testing code that uses multiple schedulers
当一个 SUT 依赖于多个调度程序时,保持测试代码简洁和集中的最佳方法是什么?也就是说,避免虚假调用以推进多个不同的调度程序。
到目前为止,我的技术一直是定义提供调度程序的应用程序级服务:
public interface ISchedulerService
{
IScheduler DefaultScheduler { get; }
IScheduler SynchronizationContextScheduler { get; }
IScheduler TaskPoolScheduler { get; }
// other schedulers
}
应用程序组件然后注入了一个 ISchedulerService
实例,并且对于任何需要调度程序的反应性管道,它是从服务中获取的。然后测试代码可以使用 TestSchedulerService
:
的实例
public sealed class TestSchedulerService : ISchedulerService
{
private readonly TestScheduler defaultScheduler;
private readonly TestScheduler synchronizationContextScheduler;
private readonly TestScheduler taskPoolScheduler;
// other schedulers
public TestSchedulerService()
{
this.defaultScheduler = new TestScheduler();
this.synchronizationContextScheduler = new TestScheduler();
this.taskPoolScheduler = new TestScheduler();
}
public IScheduler DefaultScheduler
{
get { return this.defaultScheduler; }
}
public IScheduler SynchronizationContextScheduler
{
get { return this.synchronizationContextScheduler; }
}
public IScheduler TaskPoolScheduler
{
get { return this.taskPoolScheduler; }
}
public void Start()
{
foreach (var testScheduler in this.GetTestSchedulers())
{
testScheduler.Start();
}
}
public void AdvanceBy(long time)
{
foreach (var testScheduler in this.GetTestSchedulers())
{
testScheduler.AdvanceBy(time);
}
}
public void AdvanceTo(long time)
{
foreach (var testScheduler in this.GetTestSchedulers())
{
testScheduler.AdvanceTo(time);
}
}
private IEnumerable<TestScheduler> GetTestSchedulers()
{
yield return this.defaultScheduler;
yield return this.synchronizationContextScheduler;
yield return this.taskPoolScheduler;
// other schedulers
}
}
然后测试代码可以这样控制时间:
var scheduler = new TestSchedulerService();
var sut = new SomeClass(scheduler);
scheduler.AdvanceBy(...);
但是,我发现当 SUT 使用多个调度程序时,这可能会导致问题。考虑这个简单的例子:
[Fact]
public void repro()
{
var scheduler1 = new TestScheduler();
var scheduler2 = new TestScheduler();
var pipeline = Observable
.Return("first")
.Concat(
Observable
.Return("second")
.Delay(TimeSpan.FromSeconds(1), scheduler2))
.ObserveOn(scheduler1);
string currentValue = null;
pipeline.Subscribe(x => currentValue = x);
scheduler1.AdvanceBy(TimeSpan.FromMilliseconds(900).Ticks);
scheduler2.AdvanceBy(TimeSpan.FromMilliseconds(900).Ticks);
Assert.Equal("first", currentValue);
scheduler1.AdvanceBy(TimeSpan.FromMilliseconds(100).Ticks);
scheduler2.AdvanceBy(TimeSpan.FromMilliseconds(100).Ticks);
Assert.Equal("second", currentValue);
}
此处 SUT 使用两个调度程序 - 一个控制延迟,另一个控制在哪个线程上观察订阅。该测试实际上失败了,因为调度程序的推进顺序错误。第二次延迟(100 毫秒)有多大并不重要 - scheduler1
在 scheduler2
之前提前的事实意味着订阅代码(由 scheduler1
控制)不会执行。我们需要另一个调用来推进 scheduler1
(或启动它)。
显然在上面的测试代码中,我可以将对 AdvanceBy
的调用交换过来,它就可以工作了。然而,实际上我是通过它注入我的服务并控制时间。它需要为调度程序选择一个特定的顺序,并且没有真正的方法知道 "correct" 顺序是什么——这取决于 SUT。
人们使用什么技术来解决这个问题?我能想到这些:
- 从调度程序服务中删除时间控制方法,而是要求调用者推进特定的调度程序
- 优点:强制测试代码按照他们选择的顺序推进调度程序
- 缺点:膨胀测试代码并模糊意图
- 仅在
TestSchedulerService
中使用单个 TestScheduler
,并在所有调度程序属性中使用 return
- 优点:它解决了这个特定问题
- 缺点:对于那些需要它的测试没有细粒度的控制
- 让
TestSchedulerService
接受一个构造函数参数,告诉它是创建多个 TestScheduler
实例,还是只创建一个。默认只使用一个,因为根据我的经验,需要多个的测试很少见
- 优点:在不放弃对需要它的测试的控制的情况下解决了问题
- 缺点:有点神奇,它使
TestSchedulerService
有点复杂
我倾向于那里的最后一个选项(并且已经删除了代码)。但我想知道是否有 clearer/cleaner 方法来处理这个问题?
正如您所暗示的,这有点 "it depends" 的情况。我觉得你的分析很好。 ISchedulerService
调度程序 DI 的方法很合理,我已经在多个项目中成功使用它。
根据我个人的经验,在一个测试中需要多个不同的 TestScheduler 是极其罕见的 - 我已经编写了超过一千个涉及 Rx 的单元测试并且需要在大约五次左右的情况下执行此操作。
所以我默认使用单个 TestScheduler,并且没有用于管理多个 TestScheduler 场景的特定基础结构。
以它们为特色的测试通常会突出非常具体的边缘情况,并且只是仔细编写和大量评论。
我怀疑没有编写这些测试的用户会喜欢不隐藏在框架后面的操纵调度程序的细节,因为在那些情况下,您需要尽可能清楚地看到正在发生的事情。
仅出于这个原因,我认为我会坚持在需要它们的测试代码中直接操作多个调度程序。
当一个 SUT 依赖于多个调度程序时,保持测试代码简洁和集中的最佳方法是什么?也就是说,避免虚假调用以推进多个不同的调度程序。
到目前为止,我的技术一直是定义提供调度程序的应用程序级服务:
public interface ISchedulerService
{
IScheduler DefaultScheduler { get; }
IScheduler SynchronizationContextScheduler { get; }
IScheduler TaskPoolScheduler { get; }
// other schedulers
}
应用程序组件然后注入了一个 ISchedulerService
实例,并且对于任何需要调度程序的反应性管道,它是从服务中获取的。然后测试代码可以使用 TestSchedulerService
:
public sealed class TestSchedulerService : ISchedulerService
{
private readonly TestScheduler defaultScheduler;
private readonly TestScheduler synchronizationContextScheduler;
private readonly TestScheduler taskPoolScheduler;
// other schedulers
public TestSchedulerService()
{
this.defaultScheduler = new TestScheduler();
this.synchronizationContextScheduler = new TestScheduler();
this.taskPoolScheduler = new TestScheduler();
}
public IScheduler DefaultScheduler
{
get { return this.defaultScheduler; }
}
public IScheduler SynchronizationContextScheduler
{
get { return this.synchronizationContextScheduler; }
}
public IScheduler TaskPoolScheduler
{
get { return this.taskPoolScheduler; }
}
public void Start()
{
foreach (var testScheduler in this.GetTestSchedulers())
{
testScheduler.Start();
}
}
public void AdvanceBy(long time)
{
foreach (var testScheduler in this.GetTestSchedulers())
{
testScheduler.AdvanceBy(time);
}
}
public void AdvanceTo(long time)
{
foreach (var testScheduler in this.GetTestSchedulers())
{
testScheduler.AdvanceTo(time);
}
}
private IEnumerable<TestScheduler> GetTestSchedulers()
{
yield return this.defaultScheduler;
yield return this.synchronizationContextScheduler;
yield return this.taskPoolScheduler;
// other schedulers
}
}
然后测试代码可以这样控制时间:
var scheduler = new TestSchedulerService();
var sut = new SomeClass(scheduler);
scheduler.AdvanceBy(...);
但是,我发现当 SUT 使用多个调度程序时,这可能会导致问题。考虑这个简单的例子:
[Fact]
public void repro()
{
var scheduler1 = new TestScheduler();
var scheduler2 = new TestScheduler();
var pipeline = Observable
.Return("first")
.Concat(
Observable
.Return("second")
.Delay(TimeSpan.FromSeconds(1), scheduler2))
.ObserveOn(scheduler1);
string currentValue = null;
pipeline.Subscribe(x => currentValue = x);
scheduler1.AdvanceBy(TimeSpan.FromMilliseconds(900).Ticks);
scheduler2.AdvanceBy(TimeSpan.FromMilliseconds(900).Ticks);
Assert.Equal("first", currentValue);
scheduler1.AdvanceBy(TimeSpan.FromMilliseconds(100).Ticks);
scheduler2.AdvanceBy(TimeSpan.FromMilliseconds(100).Ticks);
Assert.Equal("second", currentValue);
}
此处 SUT 使用两个调度程序 - 一个控制延迟,另一个控制在哪个线程上观察订阅。该测试实际上失败了,因为调度程序的推进顺序错误。第二次延迟(100 毫秒)有多大并不重要 - scheduler1
在 scheduler2
之前提前的事实意味着订阅代码(由 scheduler1
控制)不会执行。我们需要另一个调用来推进 scheduler1
(或启动它)。
显然在上面的测试代码中,我可以将对 AdvanceBy
的调用交换过来,它就可以工作了。然而,实际上我是通过它注入我的服务并控制时间。它需要为调度程序选择一个特定的顺序,并且没有真正的方法知道 "correct" 顺序是什么——这取决于 SUT。
人们使用什么技术来解决这个问题?我能想到这些:
- 从调度程序服务中删除时间控制方法,而是要求调用者推进特定的调度程序
- 优点:强制测试代码按照他们选择的顺序推进调度程序
- 缺点:膨胀测试代码并模糊意图
- 仅在
TestSchedulerService
中使用单个TestScheduler
,并在所有调度程序属性中使用 return- 优点:它解决了这个特定问题
- 缺点:对于那些需要它的测试没有细粒度的控制
- 让
TestSchedulerService
接受一个构造函数参数,告诉它是创建多个TestScheduler
实例,还是只创建一个。默认只使用一个,因为根据我的经验,需要多个的测试很少见- 优点:在不放弃对需要它的测试的控制的情况下解决了问题
- 缺点:有点神奇,它使
TestSchedulerService
有点复杂
我倾向于那里的最后一个选项(并且已经删除了代码)。但我想知道是否有 clearer/cleaner 方法来处理这个问题?
正如您所暗示的,这有点 "it depends" 的情况。我觉得你的分析很好。 ISchedulerService
调度程序 DI 的方法很合理,我已经在多个项目中成功使用它。
根据我个人的经验,在一个测试中需要多个不同的 TestScheduler 是极其罕见的 - 我已经编写了超过一千个涉及 Rx 的单元测试并且需要在大约五次左右的情况下执行此操作。
所以我默认使用单个 TestScheduler,并且没有用于管理多个 TestScheduler 场景的特定基础结构。
以它们为特色的测试通常会突出非常具体的边缘情况,并且只是仔细编写和大量评论。
我怀疑没有编写这些测试的用户会喜欢不隐藏在框架后面的操纵调度程序的细节,因为在那些情况下,您需要尽可能清楚地看到正在发生的事情。
仅出于这个原因,我认为我会坚持在需要它们的测试代码中直接操作多个调度程序。