单元测试是否已使用 Moq 调用 System.Threading.Timer 构造函数 (TimerCallback) 中使用的方法

Unit testing whether or not method used in System.Threading.Timer constructor (TimerCallback) has been called using Moq

提问人:Christopher Pagan 提问时间:10/18/2023 最后编辑:Mark SeemannChristopher Pagan 更新时间:10/18/2023 访问量:43

问:

我希望确保将回调传递到签名中带有 TimerCallback 的 System.Threading.Timer:

public Timer (System.Threading.TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period);

我有一个持有计时器的类,需要一种方法来确保回调 () 具有测试覆盖率。另外,我不会等待 15 分钟看看会发生什么。MethodCMethodC

using System.Threading;
public class MyClass
{
    private Timer? myTimer;

    public MyClass() { }

    public void MethodA()
    {
        MethodB();
    }

    private void MethodB()
    {
        myTimer = new Timer(
            MethodC, // TimerCallback
            new Object(),
            TimeSpan.FromMinutes(15),
            TimeSpan.FromMinutes(15));
        // do some things
    }

    private void MethodC(object? state)
    {
        // do something
    }
}

根据我的搜索,答案似乎是将计时器移动到一个接口,然后模拟接口。我正在使用最小起订量。 但是,我仍然缺少对实现目标的一些理解。我不知道如何测试回调是否在 的构造函数中给出。MethodCmyTimer

我最终想检查是否通过最小起订量和其他行为达到代码。Verify

我尝试将计时器代码移动到接口:

    interface ITimer
    {
        void SetTimer(TimerCallback callback, object state, TimeSpan dueTime, TimeSpan period);
        void Dispose();
    }

    class MyTimer : ITimer, IDisposable
    {
        private Timer timer;
        private TimerCallback callback;

        public MyTimer(TimerCallback callback, Object state, TimeSpan dueTime, TimeSpan period)
        {
            this.callback = callback;
            this.SetTimer(this.callback, state, dueTime, period);
        }

        public void SetTimer(TimerCallback callback, object state, TimeSpan dueTime, TimeSpan period)
        {
            this.timer = new Timer(callback, state, dueTime, period);
        }

        public void Dispose()
        {
            timer.Dispose();
        }
    }


.......



using System.Threading;
public class MyClass : IDisposable {


  private ITimer myTimer;
  
  public MyClass(){}

  public void MethodA(){
    MethodB();
  }

  private void MethodB(){
    this.myTimer = new MyTimer(this.MethodC, // TimerCallback
                             new Object(), 
                             TimeSpan.FromMinutes(15), 
                             TimeSpan.FromMinutes(15))
    // do some things
  }

  private void MethodC(){
    // do something
  }
  
}

计时器回调通过构造函数提供给对象。没有方法可以调用,因此最小起订量在这里似乎不起作用。调用一定时间后,回调只是在另一个线程上调用,也是私有的。TimerSetup(Expression<Action<T>> expression)MethodC

我试图让我的类在另一种方法中创建,但是我想测试,而不是。MyTimerTimerMethodCSetTimer

该设计似乎错综复杂且无法测试。但我对最小起订量也不是很熟悉。有没有关于如何改进这一点的建议,以便我可以测试?MethodC

C# 计时器 回调 最小起订量

评论


答:

0赞 Steve Py 10/18/2023 #1

如果要测试 MethodC 的功能,一种选择是将 MethodC 标记为 MethodC,而不是使内部结构对单元测试项目可见。使用 .Net Core,这相当简单,尤其是在遵循一致的约定(例如使用 ProjectName.UnitTests.csproj 对 ProjectName.csproj 项目进行单元测试)时。添加到 ProjectName.csproj 的以下组将允许单元测试和集成测试项目查看内部结构:internalprivate

如果您有一些逻辑可以从非公开且不方便公开访问的测试中受益,这可能很有帮助。需要注意的是,这是特定于单元测试的代码,应尽可能避免使用。但是,我认为这更像是一个指导方针,而不是一个规则。如果与替代重构以尝试去除所有气味的成本相比,收益大于气味,那么它可能是值得的。我通常将合法的内部结构限制在我想要测试的程序集上。将方法保留在我们不希望访问的位置的选项,因为您可以使用内部测试适配器:privateinternal

private MethodC(/* parameters */) 
{
    // ...
}

internal MethodC_TestAdapter(/* parameters */)
{
#if TEST
     MethodC( /* parameters */ )
#else
     throw new InvalidOperation($"{nameof(MethodC_TestAdapter)} should never be called in non-testing code.");
#end if
}

其中 TEST 是一个预处理器,只有在本地运行和自动构建的单元测试配置时才应定义。这样一来,如果某个人后来在他们的智能感知中找到“MethodC_TestAdapter”并调用它,那么他们应该会受到一个带有解释的异常的欢迎。

也就是说,编写高度可测试的代码的部分挑战是在如何构建代码时遵循良好的编码原则。理想情况下,如果 MethodC 实际上是一个处理程序,则希望将 MethodB 和 MethodC 之间的状态解耦。 如果 MethodC 更改了此类中的公共状态,则会出现一些问题,因为这是一个杂耍情况,即在任何给定时间该状态可能是什么,以及方法 C 所做的更新与可能执行的其他方法之间的竞争。例如,设置标志、递增或更改值等。如果 MethodC 可以被隔离为无状态或只管理它自己的状态,那么 MethodC 可以移出到它自己的类中,在那里可以构造一个实例或注入到 MethodB 所在的类中。MethodB 可以建立链接到依赖项实例 MethodC 的计时器。通过这种方式,可以测试其服务上的 MethodC 的行为,而无需担心访问修饰符。面向对象语言处理状态,但目标是尽可能地将其划分。

0赞 Mark Seemann 10/18/2023 #2

编写不容易测试的代码是微不足道的。OP就是这样一个例子。您应该期望必须以某种方式修改受测系统 (SUT) 才能使其可测试。有时,这通常会导致更模块化的设计,因此这不一定是一件坏事。

立即浮现在脑海中的最简单的事情是首先定义一个接口:

public interface ITimer
{
    void Start(
        TimerCallback callback,
        object? state,
        TimeSpan dueTime,
        TimeSpan period);
}

根据依赖关系反转原则,不必定义比 SUT 需要的更多成员和更多细节,在这种情况下,看起来像构造函数的方法就足够了。

然后修改 SUT 以使用构造函数注入:

public class MyClass
{
    private readonly ITimer timer;

    public MyClass(ITimer timer)
    {
        this.timer = timer;
    }

    public void MethodA()
    {
        MethodB();
    }

    private void MethodB()
    {
        timer.Start(
            MethodC, // TimerCallback
            new Object(),
            TimeSpan.FromMinutes(15),
            TimeSpan.FromMinutes(15));
        // do some things
    }

    private void MethodC(object? state)
    {
        // do something
    }
}

现在可以验证该方法是否被调用:Start

[Fact]
public void MethodAInvokesMethodC()
{
    var mockTimer = new Mock<ITimer>();
    var sut = new MyClass(mockTimer.Object);

    sut.MethodA();
    
    mockTimer.Verify(t => t.Start(
        It.IsAny<TimerCallback>(),
        It.IsAny<object>(),
        TimeSpan.FromMinutes(15),
        TimeSpan.FromMinutes(15)));
}

如果还想验证哪个方法被指定为回调,可以考虑将 Verify 调用更改为:

mockTimer.Verify(t => t.Start(
    It.Is<TimerCallback>(cb => cb.Method.Name == "MethodC"),
    It.IsAny<object>(),
    TimeSpan.FromMinutes(15),
    TimeSpan.FromMinutes(15)));

但是,这会使测试更加脆弱,因为如果更改 的名称,则此测试将失败。相反,请考虑使:MethodCMethodCpublic

public void MethodC(object? state)
{
    // do something
}

然后,您可以像这样编写断言:

mockTimer.Verify(t => t.Start(
    sut.MethodC,
    It.IsAny<object>(),
    TimeSpan.FromMinutes(15),
    TimeSpan.FromMinutes(15)));

现在,这也意味着您可以针对它编写测试,因此制作它并不一定是一件坏事。MethodCpublicpublic

对于生产用途,您可以使用实际类实现接口:Timer

public sealed class TimerTimer : ITimer
{
    private Timer? timer;

    public void Start(
        TimerCallback callback,
        object? state,
        TimeSpan dueTime,
        TimeSpan period)
    {
        timer = new Timer(callback, state, dueTime, period);
    }
}