单元测试 - 抽象创建简单值
讨论了三种在不影响类可测试性的情况下创建简单 .NET 类型的方法
最初发布于此处。
引言
我最近的职业编码工作涉及编写大量的单元测试。我所在的团队正试图实施一种测试驱动开发(TDD)方法,而我之前在没有任何自动化测试的开发团队(我知道……太惊人了!!!)工作过,这给我带来了一些有趣的挑战。
当然,我并没有遇到什么新问题。许多开发者都遇到过我遇到的相同问题。我非常感谢那些将他们解决这些问题的方法发布到互联网上的人,这样在我绝望的时候,我就可以找到解决代码困境的方法。我怀着同样的精神提供这篇文章。
定义问题
我发现,在构建类时,我经常需要实现一些非常基本的系统逻辑,这些逻辑通常基于核心 .NET 库的static
方法或属性。最常见的例子可能是为即将保存的记录获取当前日期时间。如果您对“Hello World!
”教程的完成时间之外的 5 分钟有所了解,那么您很可能知道使用 DateTime.Now
可以极其简单地实现这一点。以下面的例子为例
public class DocumentService : IDocumentService
{
private IRepository _repository;
public DocumentService(IRepository repository)
{
_repository = repository;
}
public void CreateNewDocument(string documentName)
{
var document = new Document
{
Name = documentName,
Created = DateTime.Now
};
_repository.Save(document);
}
}
如果您只想让应用程序运行起来(或者如果您在我之前的雇主那里工作),那么这就可以了。此服务类中的 CreateNewDocument
方法只做两件事
- 创建一个带有名称和当前时间戳的文档
- 保存文档
但是,如果您希望为 DocumentService
类添加单元测试,那么您将很快意识到,现在存在对 .NET DateTime
类的“硬依赖”,您无法摆脱。您如何测试 Document.Created
是否已正确设置?
在这里,我将分享一些选项。每个选项都解决了测试 DocumentService
类的问题,但各有优缺点。在我看来,每个选项都比它前面的选项有所改进。
选项 1:将值作为方法参数提供
一种避免这种依赖的方法是将当前时间戳作为方法的参数传递——请参阅下面的示例
public class DocumentService : IDocumentService
{
private IRepository _repository;
public DocumentService(IRepository repository)
{
_repository = repository;
}
public void CreateNewDocument(string documentName, DateTime currentTimestamp)
{
var document = new Document
{
Name = documentName,
Created = currentTimestamp
};
_repository.Save(document);
}
}
虽然这个解决方案可以让 DocumentService
类变得可测试,但它并没有真正解决问题。解决问题的责任已经委托给了其他人/事。
任何调用 CreateNewDocument
方法的类现在都必须实现一种方法来生成一个 DateTime
对象来表示当前时间戳并将其传递过去。最有可能的是,这将在调用堆栈的更高处导致 DateTime.Now
的另一个不可测试的使用。
这并不理想,因为现在调用类不仅要完成更多的工作,而且还必须知道 DocumentService
打算如何使用日期时间值,以便提供正确的值。不应该需要了解 DocumentService
的内部工作原理。
选项 2:注入一个服务来提供值
解决此问题的一种常见模式是创建一个包装器来封装无法测试的功能,以便在创建单元测试时替换它。
public interface IDateTimeProvider
{
DateTime Now { get; }
}
public class DateTimeProvider : IDateTimeProvider
{
public DateTime Now => DateTime.Now;
}
这通常被称为“外观模式”,其中操作的底层复杂性隐藏在一个简单的接口后面——即使这里没有多少实际的“复杂性”。
现在可以通过构造函数将 DateTimeProvider
注入到文档服务中。
public class DocumentService : IDocumentService
{
private IRepository _repository;
private IDateTimeProvider _dateTimeProvider;
public DocumentService(IRepository repository, IDateTimeProvider dateTimeProvider)
{
_repository = repository;
_dateTimeProvider = dateTimeProvider;
}
public void CreateNewDocument(string documentName)
{
var document = new Document
{
Name = documentName,
Created = _dateTimeProvider.Now
};
_repository.Save(document);
}
}
在编写单元测试时,您可以使用您选择的模拟框架提供 IDateTimeProvider
的假实现,该实现为 _dateTimeProvider.Now
提供一个 static
值。
这是一种非常实用的模式,适用于许多情况,特别是当提供程序的实际需求变得更复杂时。但是,这种方法存在一些明显的缺点。
首先,这里的实际需求非常简单,因此为提供单个日期时间对象创建一个额外的类可能会被认为是过度。特别是如果您考虑到如果使用 IOC 容器,您还需要配置依赖项解析,并在测试中实例化模拟对象。也许创建一个提供程序对象比它带来的麻烦还要大。
其次,正如方法注入所指出的,可以合理地认为选择和应用时间戳的责任应由 DocumentService
自己承担。此服务类的任何使用者都需要提供一个合适的 IDateTimeProvider
实例,这似乎会破坏责任流程。
选项 3:使用可注入的函数属性
我没有找到关于此模式的任何其他引用,因此我选择将其命名为“可注入函数属性”——如果有人知道更好/更合适的名称,请告诉我。
public class DocumentService : IDocumentService
{
private IRepository _repository;
internal Func<DateTime> GetCurrentTimestamp { get; set; } = () => DateTime.Now;
public DocumentService(IRepository repository)
{
_repository = repository;
}
public void CreateNewDocument(string documentName)
{
var document = new Document
{
Name = documentName,
Created = GetCurrentTimestamp()
};
_repository.Save(document);
}
}
此解决方案与方法注入和提供程序注入相比具有多个优势
- 默认行为包含在需要它的类中。
- 不需要额外的类。
- 不需要额外的 IOC 配置。
- 模拟行为要简单得多(请参阅下面的代码示例)。
GetCurrentTimestamp
属性仅存在于类上,因此接口的消费者不必访问该属性。GetCurrentTimestamp
属性是内部的,这可以防止当前程序集之外的类更新它。
以下是文档服务类的一个示例单元测试
[TestClass]
public class DocumentServiceTests
{
[TestMethod]
public void CreateNewDocument_SavesDocument()
{
var testTimestamp = new DateTime(2018, 1, 1);
Document savedDocument;
var mockRepository = new Mock();
var documentService = new DocumentService(mockRepository.Object);
mockRepository
.Setup(x => x.Save(It.IsAny()))
.Callback(x => savedDocument = x);
documentService.GetCurrentTimestamp = () => testTimestamp;
var documentName = "TEST DOCUMENT";
documentService.CreateNewDocument(documentName);
Assert.AreEqual(documentName, savedDocument.Name);
Assert.AreEqual(testTimestamp, savedDocument.Created);
}
}
我在这个模式的示例中使用了 DateTime.Now
,因为这是我使用它的最常见原因,但我已经将其用于其他几个方面。例如,通过 Guid.NewGuid()
获取新的 GUID,或者获取新的 TextWriter
实现。
public class WriterService : IWriterService
{
internal Func<TestWriter> GetTextWriter { get; set; } = () => new StringWriter();
public WriterService()
{
}
public void DoSomethingWithADocument(Document document)
{
using (var textWriter = GetTextWriter())
{
WriteDocumentToString(textWriter, document);
}
}
}
通过添加此 GetTextWriter
属性来注入一个函数,我能够在单元测试中提供一个文本编写器的模拟实现,这使我能够验证它是否被正确调用以及是否被正确处理(这是它自己的一个挑战,我将留待以后讨论)。
此模式有两个显著的缺点。
首先,在编写测试时,没有任何提示来提供新函数,因此很容易忘记并让测试使用 DateTime.Now
默认值。
其次,您团队中的其他开发人员可能不熟悉此模式。当您试图将其隐藏在代码审查中时,或者当其他人来处理同一个类时,很可能会受到质疑。您可以随时在代码中添加一个注释,链接到这篇文章,并给我带来更多流量(尽管作为一般规则,我认为代码中的注释是不好的)。
结束语
我希望您发现此模式很有用。对我来说,这是在不将责任泄露给类外部的情况下处理这些简单问题的最简单、最干净的方法。如果您有更好的方法,请告诉我。我很高兴学习新的做事方式。