异步初始化及其单元测试

Asynchronous initialization and its unit testing

背景 我需要一些 class 来执行后台初始化,这应该从构造函数开始。目前我正在使用由构造函数启动的 Task,然后所有操作,取决于该初始化等待 Task 完成。

请看下面的简化示例:

interface IEntry {}

interface IRepository
{
    IQueryable<IEntry> Query { get; }
    void Add(IEntry entry);
}

class PrefetchedRepository : IRepository
{
    private readonly Task _prefetchingTask;
    private readonly ICollection<IEntry> _entries = new List<IEntry>();
    private readonly IRepository _underlyingRepository;

    public PrefetchedRepository(IRepository underlyingRepository)
    {
        _underlyingRepository = underlyingRepository;
        // Background initialization starts here
        _prefetchingTask = Task.Factory.StartNew(Prefetch);
    }

    public IQueryable<IEntry> Query
    { 
        get
        {
            EnsurePrefetchCompleted();
            return _entries.AsQueryable();
        }
    }

    public void Add(IEntry entry)
    {
        EnsurePrefetchCompleted();      
        _entries.Add(entry);
        _underlyingRepository.Add(entry);
    }

    private void EnsurePrefetchCompleted()
    {
        _prefetchingTask.Wait();
    }

    private void Prefetch()
    {
        foreach (var entry in _underlyingRepository.Query)
        {
            _entries.Add(entry);
        }
    }
}

这行得通。当我想在单元测试中测试初始化​​时,问题就开始了。我正在创建实例并提供底层存储库的模拟。我想确保所有条目都按预期从模拟中获取。

[TestFixture]
public class PrefetchingRepositoryTests
{
    [Test]
    public void WhenInitialized_PrefetchingIsDone()
    {
        // Arrange
        var underlyingRepositoryMock = A.Fake<IRepository>();

        // Act
        var target = new PrefetchedRepository(_underlyingRepository);

        // Assert
        underlyingRepositoryMock.CallsTo(r => r.Query).MustHaveHappened(Repeated.Exactly(1));   
    }
}   

正如你所想象的,大多数时候都失败了,因为实际上初始化并没有在断言点开始。

问题

问题1 - 初始化:有没有更优雅的异步初始化方法而不是在构造函数中启动任务并等待它依赖操作?

问题 2 - 测试: 我想到了 2 种可能的方法来解决测试和测试者之间的竞争:

  1. 使用事件句柄进行测试:

    [Test]
    public void WhenInitialized_PrefetchingIsDone()
    {
        // Arrange ...
        var invokedEvent = new ManualResetEvent(false);
        underlyingRepositoryMock.CallsTo(r => r.Query).Invokes(_ => invokedEvent.Set());
    
        // Act ...
    
        // Assert
        Assert.True(invokedEvent.WaitOne(1000));
    }
    
  2. 公开 EnsurePrefetchCompleted 方法作为内部方法并在单元测试中使用它(假设使用 [assembly: InternalsVisibleTo("...")]

这两种解决方案的问题是,在故障持续时间很长的情况下(实际上在第二种情况下 - 它受到测试超时的限制)。

有没有更简单的方法来进行这种测试?

将预取逻辑提取到一个单独的预取器中 class 并在测试时使用无需使用单独线程即可执行获取的模拟预取器。

这将允许您对 PrefetchedRepository 进行白盒测试,我看到您正在尝试使用它 underlyingRepositoryMock.CallsTo(r => r.Query).MustHaveHappened(Repeated.Exactly(1));(我永远不会做白盒测试,但我就是这样。)

完成白盒测试后,您就可以对 PrefetchedRepository 进行黑盒测试,而无需担心它的内部工作方式。 (它是否调用其他对象来完成它的工作,调用它们的次数等)因此,您的测试代码将不需要猜测可以检查查询是否已被调用的时间点,因为它会根本不关心查询是否被调用。本质上,您的测试代码将针对 interface IRepository 进行测试,而不是针对 class PrefetchedRepository.

不要公开处于无效状态的实例。客户端代码在调用 PrefetchedRepository 中的任何成员时可能经常注意到延迟,只是因为底层存储库很慢。您试图通过隐藏客户端代码甚至不知道的 EnsurePrefetchCompleted 中所有糟糕的等待逻辑来隐藏这些细节,从而变得聪明。但这可能会让客户感到惊讶,为什么即使这样也需要很多时间??

更好的方法是在 API 的 public 表面公开 Task 并让客户端代码 await 它在对存储库实例执行任何操作之前.

像这样:

class PrefetchedRepository : IRepository
{
    private readonly Task _prefetchingTask;
    private readonly ICollection<IEntry> _entries = new List<IEntry>();
    private readonly IRepository _underlyingRepository;

    public PrefetchedRepository(IRepository underlyingRepository)
    {
        _underlyingRepository = underlyingRepository;
        // Background initialization starts here
        _prefetchingTask = Task.Factory.StartNew(Prefetch);
    }

    public Task Initialization
    { 
        get
        {
            return _prefetchingTask;
        }
    }
    ...
}

那你可以

var repo = new PrefetchedRepository(someOtherSlowRepo);
await repo.Initialization;
//Then do whatever with the repo.

当然要删除那个 EnsurePrefetchCompleted 方法和对它的所有调用。

但我知道这会引入所谓的气味 Temporal Coupling

更好的设计是引入一个工厂来为你做这件事。

public class PrefetchedRepositoryFactory
{
    public Task<IRepository> CreateAsync()
    {
        //someOtherSlowRepo can be a parameter or instance field of this class
        var repo = new PrefetchedRepository(someOtherSlowRepo);
        await repo.Initialization;
        return repo;
    }
}

那么你可以简单地做

var repo = await prefetchedRepositoryFactory.CreateAsync();
//Do whatever with repo.

如果您这样做,则无需特别注意测试,因为您手头始终拥有完整构建的存储库。

你可以在测试方法里面等待;大多数主要的单元测试框架都支持 async Task 返回方法。