在这篇文章中,您将看到一个使用构建器模式来帮助对具有多个依赖项的服务进行单元测试的示例
您是否曾经需要对具有六个(或更多)依赖项的服务或外观进行单元测试?我会说——依赖注入很棒,但是测试具有太多依赖项的服务肯定会导致程序集的痛苦。让我给你举个例子。
public class UserFacade
{
private readonly IApiService _apiService;
private readonly IAuthService _authService;
private readonly IDatabaseService _databaseService;
private readonly IEmailService _emailService;
private readonly IFileService _fileService;
private readonly ISessionService _sessionService;
public UserFacade(
IApiService apiService,
IAuthService authService,
IDatabaseService databaseService,
IEmailService emailService,
IFileService fileService,
ISessionService sessionService
)
{
_apiService = apiService;
_authService = authService;
_databaseService = databaseService;
_emailService = emailService;
_fileService = fileService;
_sessionService = sessionService;
}
public void SignUp(string email, string password)
{
var authUser = _authService.CreateUser(email, password);
if (authUser == null)
throw new Exception("Unable to create auth user");
_apiService.CreateUserInApi(authUser);
var welcomeEmailContents = _fileService.ReadFile("template.html");
_emailService.SendEmail(email, "Welcome!", welcomeEmailContents);
}
public string SignIn(string email, string password)
{
var authUser = _authService.GetUser(email, password);
if (authUser == null)
throw new UnauthorizedAccessException("User does not exist.");
_databaseService.RecordLogin(authUser);
return _sessionService.CreateSession(authUser);
}
}
在这里,我们有一个相当简单的外观,允许用户注册或登录。它有六个依赖项,虽然不是微不足道的,但与您可能在野外看到的相比,这个数字并不大。现在,我们将如何模拟这些依赖项以便为SignUp和SignIn方法编写单元测试?
在线框级别,这可能看起来像这样:
public class UserFacadeTests
{
private UserFacade _sut;
[SetUp]
public void Setup()
{
// Create mocks
var apiServiceMock = new Mock();
var authServiceMock = new Mock();
var databaseServiceMock = new Mock();
var emailServiceMock = new Mock();
var fileServiceMock = new Mock();
var sessionServiceMock = new Mock();
// Setup service under test
_sut = new UserFacade(
apiServiceMock.Object,
authServiceMock.Object,
databaseServiceMock.Object,
emailServiceMock.Object,
fileServiceMock.Object,
sessionServiceMock.Object
);
}
[Test]
public void Test1()
{
Assert.Pass();
}
[Test]
public void Test2()
{
Assert.Pass();
}
}
如果这对您来说完全陌生,让我们快速回顾一些基础知识。
什么是模拟?
模拟是接口或抽象类的动态实现,您可以将它们传递到您的服务中进行测试。在这个例子中,我使用了一个名为Moq的模拟库。虽然此处未显示,但Moq允许您为接口上的属性和方法定义回调和返回值,这有助于验证被测试服务的行为。
这是什么业务?
sut仅表示被测服务(或系统)。由于您的测试类应该只测试一项服务,因此常见的约定是命名该服务sut(或_sut)以将其标识为正在测试中。
所以有什么问题?
首先,您可能有多个用于显着大小的门面的测试类——可能每个方法或不同行为有一个测试类。这意味着如果构造函数签名发生变化,您将有几个地方需要更新。这很烦人。
但其次,请注意并非所有依赖项都用于每个操作。该SignUp方法调用_authService.CreateUser,_apiService.CreateUserInApi,_fileService.ReadFile和_emailService.SendEmail。该SignIn方法,在另一方面,调用_authService.GetUser,_databaseService.RecordLogin和_sessionService.CreateSession。这意味着如果我的测试类只测试这两种方法之一,我实际上不需要提供所有服务依赖项。不过,进一步想象一下,如果我们有两个测试用例——一个用于当_authService.GetUser返回值,另一个用于返回值null(模拟未找到用户)。为了支持这一点,我们需要为每个测试用例创建一个全新的_sut实例,这意味着每个测试都有大量的样板代码。
这是我的意思的快速说明。
[Test]
public void Test_SignIn_UserFound()
{
_authServiceMock.Setup(m => m.GetUser(It.IsAny(), It.IsAny()))
.Returns(new User()
{
// ...
});
var sut = new UserFacade(_apiServiceMock.Object,
_authServiceMock.Object, _databaseServiceMock.Object,
_emailServiceMock.Object, _fileServiceMock.Object,
_sessionServiceMock.Object);
var sessionId = sut.SignIn("john.doe@gmail.com", "password");
Assert.IsNotNull(sessionId);
}
[Test]
public void Test_SignIn_UserNotFound()
{
_authServiceMock.Setup(m => m.GetUser(It.IsAny(), It.IsAny()))
.Returns(null as User);
var sut = new UserFacade(_apiServiceMock.Object,
_authServiceMock.Object, _databaseServiceMock.Object,
_emailServiceMock.Object, _fileServiceMock.Object,
_sessionServiceMock.Object);
var sessionId = sut.SignIn("john.doe@gmail.com", "password");
Assert.IsNull(sessionId);
}
请记住,这是一个人为的示例,仅包含一个模拟服务。在实际测试中,您可能需要模拟多个服务,不同的测试用例可能具有不同的行为。简而言之,测试很快就会变得非常丑陋。
好吧,爱因斯坦,你有什么建议?
首先,让我们添加一种通用的方式来动态存储依赖项。
public class DependencyManager
{
private readonly Dictionary _dependencies =
new Dictionary();
public void AddDependency(T dependency)
where T : class
{
_dependencies[typeof(T)] = dependency;
}
public T GetDependency()
where T : class
{
if (_dependencies.ContainsKey(typeof(T)))
{
return _dependencies[typeof(T)] as T;
}
return null;
}
}
我通常有一个Shared测试项目,它在一个规模很大的解决方案中的所有其他测试项目之间共享,我会在那里放置这样的东西。思路是,利用这个,我们可以在我们的测试Setup阶段注册一些常见的依赖,然后在测试用例中注册一些测试用例特定的依赖。可以多次注册相同的类型而不会出现问题,并且检索尚未设置的类型的依赖项将返回null而不是产生错误。这对于测试不需要特定依赖项的代码路径很有用,实际上可以是null。
接下来,让我们添加一种方法来逐步构建被测服务的实例(在本例中为UserFacade)。
public class UserFacadeBuilder
{
private readonly DependencyManager _dependencyManager = new DependencyManager();
public UserFacadeBuilder AddDependency(T value)
where T : class
{
_dependencyManager.AddDependency(value);
return this;
}
public UserFacade Build()
{
return new UserFacade(
_dependencyManager.GetDependency(),
_dependencyManager.GetDependency(),
_dependencyManager.GetDependency(),
_dependencyManager.GetDependency(),
_dependencyManager.GetDependency(),
_dependencyManager.GetDependency()
);
}
}
该组件有两个目的。首先,它将被测服务的更新封装在一个位置,这样如果构造函数签名发生变化,我们只需要在一个地方进行更改。其次,它提供了一个流畅的API来注册被测服务的依赖关系。让我们看看我们的测试类现在的样子。
public class UserFacadeTests
{
private UserFacadeBuilder _sutBuilder;
[SetUp]
public void Setup()
{
// Create common mocks
var apiServiceMock = new Mock();
var databaseServiceMock = new Mock();
var emailServiceMock = new Mock();
var fileServiceMock = new Mock();
var sessionServiceMock = new Mock();
_sutBuilder = new UserFacadeBuilder()
.AddDependency(apiServiceMock.Object)
.AddDependency(databaseServiceMock.Object)
.AddDependency(emailServiceMock.Object)
.AddDependency(fileServiceMock.Object)
.AddDependency(sessionServiceMock.Object);
}
[Test]
public void Test_SignIn_UserFound()
{
var authServiceMock = new Mock();
authServiceMock.Setup(m => m.GetUser(It.IsAny(), It.IsAny()))
.Returns(new User()
{
// ...
});
var sut = _sutBuilder
.AddDependency(authServiceMock.Object)
.Build();
var sessionId = sut.SignIn("john.doe@gmail.com", "password");
Assert.IsNotNull(sessionId);
}
[Test]
public void Test_SignIn_UserNotFound()
{
var authServiceMock = new Mock();
authServiceMock.Setup(m => m.GetUser(It.IsAny(), It.IsAny()))
.Returns(null as User);
var sut = _sutBuilder
.AddDependency(authServiceMock.Object)
.Build();
var sessionId = sut.SignIn("john.doe@gmail.com", "password");
Assert.IsNull(sessionId);
}
}
现在,每个测试用例只注册一个特定于测试的自定义模拟,而不是每次都必须注册所有模拟。我们还可以省略测试所关注的代码路径不需要的依赖项。例如,由于我们只测试SignIn方法,IEmailService,IApiService和IFileService可以安全地从建设者省略。
我发现这种模式非常有助于对实际大小的服务进行测试,并具有可管理的实际依赖项数量。
https://www.codeproject.com/Articles/5309312/Using-the-Builder-Pattern-to-Help-Your-Unit-Tests