使用 C# 进行单元测试,包括 MVC





5.00/5 (3投票s)
讨论在 C# 代码中测试什么以及如何测试,包括一个涵盖 MVC 的部分。
引言
本文是单元测试的入门介绍,将尽可能多地涵盖单元测试的范式以及技术细节。因此,除了涵盖如何进行单元测试,本文还将涵盖测试什么以及为什么测试,更重要的是什么**不**应该进行单元测试以及为什么。在文章的最后,我将讨论一些更高级的与 Web 相关的测试,例如如何测试 MVC 项目,包括依赖会话状态、Cookie、电子邮件等的代码。
我们将进行单元测试的项目基础是一个简单的银行应用程序。
目录
单元测试基础
单元测试是对给定方法中逻辑的测试。当我们为方法编写单元测试时,我们希望确保该方法应该处理的所有可能场景都按照我们期望的方式工作。这些测试随后成为一种“活文档”式的规范,如果方法的运行方式因满足新需求而更改,并且不再满足旧需求,则相关的单元测试应该会失败,从而标记出需要讨论的问题。可能该方法确实需要满足现有条件,因此应该进行重构,使其既能实现新功能又能保留旧功能;也可能是该函数的需求已经改变,以至于失败的单元测试涵盖的场景不再需要,因此该单元测试可以删除。无论新工作后失败的单元测试结果如何,单元测试都在那里标记出**存在**一个需要调查的问题。当然,当所有测试都通过时,我们就会对方法都按照我们期望的方式运行充满信心。当处理由多人开发的大型系统时,这种信心尤为重要,因为您不确定所做的更改是否会破坏其他地方。
我们要测试的方法可能根据不同的参数有多个执行路径,或者有多个我们想要验证的结果。虽然在一个测试中涵盖所有可能的场景很诱人,但通常最好为每个您想要验证的事物编写一个单元测试。例如,在我们的银行系统中,当您在账户之间转账时,不仅相关账户的余额会更新,而且每次账户更新都会创建一个交易记录。我们可以创建一个单一的测试来断言钱已转账并且每个账户都创建了交易记录,但是更好的做法是将其拆分为三个独立的测试:一个测试钱是否已转账,一个测试源账户的交易是否已创建,最后一个测试目标账户的交易是否已创建。虽然这会导致总体测试数量更多,但也会导致更精细的测试,这些测试专注于非常特定的功能片段,这通常是首选的,因为如果测试失败,您就知道具体哪个功能片段失败了。
什么不能进行单元测试
关于单元测试有两个要求,它们很大程度上决定了什么可以和什么不能进行单元测试。
- 单元测试及其运行的代码需要完全**自包含**,并且**不能访问任何外部资源**。
- 单元测试应尽可能**快速**运行。
让我们更详细地探讨这些要求意味着什么。
“自包含”我的意思是代码不能访问任何更高级别的上下文,例如提供 `Request`、`Response`、`Cookies`、`Session` 等的 Web 上下文。这是因为我们的单元测试是在测试运行器(无论是 Microsoft 的测试运行器还是像 NUnit 这样的第三方测试运行器)的上下文中运行的。如果您正在单元测试 MVC 控制器中使用了 `Session` 状态的方法,那段代码只有在由 IIS 执行时才能工作,因为是 IIS 提供了会话。当您将该代码作为单元测试运行时,测试运行器不会给您一个 Web 上下文来运行,因此任何对 `Session` 的引用都将是 `null`。同样,任何对 `Request`、`Response` 等的访问都将失败。
“外部资源”我的意思几乎是任何东西……文件、数据库、网络、SMTP 服务器、Web API、Active Directory,字面上除了基本代码之外的任何东西。大多数开发人员认为单元测试是在本地机器上运行的东西,但如果您为一家拥有自动化交付管道的公司工作,这些单元测试将作为交付过程的一部分由某个服务器运行。因此,您的本地机器可能可以访问某个数据库或 API,但那台服务器可以吗?您如何配置它,使得运行测试的服务器使用合适的测试数据库,而不是在生产数据库上运行测试代码?这是您不能访问外部资源的主要原因,但另一个原因是它与您的单元测试应该快速运行的要求相关联。同样,一些公司会坚持您使用本地化的部署管道,在每次本地部署后运行您的测试,您真的想在每次本地部署时等待您的单元测试访问数据库和发送电子邮件吗?
不依赖外部资源也有助于提高可重复性。假设您的测试需要数据库中存在某些记录才能运行,或者您的测试会创建您需要确保删除的行,对这些东西的管理很快就会失控,但如果您的测试不需要任何数据库状态管理,那么这不是问题,您可以一遍又一遍地重新运行相同的测试。
此时,您可能会觉得单元测试毫无价值,因为您的所有代码都访问外部资源,而这正是单元测试范式发挥作用的地方。关于单元测试最常见的问题之一是“这是我的代码,我如何进行单元测试?”,而答案几乎总是“您不能”。单元测试不是您可以追溯地添加到代码中的东西,如果您想进行单元测试,您必须从一开始就考虑到单元测试来编写代码。本文的其余部分将重点介绍如何实现这一点。
银行系统 - 第一次尝试
我将从一个基本的存储库开始,用于管理银行相关数据库中的表,并编写一个使用该存储库的银行服务。在本文中,当我提到“服务”时,我并非指 Windows 服务,而仅仅是指提供一组相关功能的类。我将首先以许多人在不考虑单元测试时可能采用的方式进行操作。我们的系统非常简单,只有两个表;一个用于保存账户信息,另一个用于保存账户交易历史。
存储库代码
这是一个使用 Entity Framework 与我们的银行数据库中的表进行交互的类。
函数 | 描述 |
GetAccount | 返回表示账户的 `Account` 对象。有两个重载,一个通过 ID 检索账户,一个通过账户号码检索。 |
SetBalance | 更新 `Account` 表,将 `Balance` 字段设置为给定值。 |
AddTransaction | 向 `Transaction` 表添加一行,指示哪个账户被修改,余额修改了多少,以及新余额是多少。 |
using System;
using System.Linq;
using UnitTestArticle.Services.Exceptions;
namespace UnitTestArticle.Repositories
{
public class BankRepositoryBad : IDisposable
{
private BankContext context;
private bool disposed = false;
public BankRepositoryBad(BankContext context)
{
this.context = context;
}
public BankRepositoryBad()
: this (new BankContext())
{
}
public Account GetAccount(string accountNumber)
{
return this.context
.Accounts
.FirstOrDefault(a => a.Account_Number == accountNumber.Trim());
}
public Account GetAccount(int id)
{
return this.context
.Accounts
.FirstOrDefault(a => a.ID == id);
}
public void SetBalance(int id, decimal balance)
{
Account account = GetAccount(id);
if (account == null)
{
throw new AccountNotFoundException(id);
}
account.Balance = balance;
context.SaveChanges();
}
public void AddTransaction(int id, decimal amount, decimal newBalance)
{
context.Transactions.Add(new Transaction
{
Account_ID = id,
Amount = amount,
New_Balance = newBalance,
Transaction_Date = DateTime.Now
});
context.SaveChanges();
}
protected virtual void Dispose(bool disposing)
{
if (!this.disposed)
{
if (disposing)
{
this.context.Dispose();
}
}
this.disposed = true;
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
}
}
服务代码
此类的函数执行与管理银行系统相关的任务,例如在账户之间转账、修改余额等。它包含与这些任务相关的逻辑,并使用银行存储库来更新实际的银行数据库。
函数 | 描述 |
GetAccount | 返回具有所提供帐号的账户的 `Account` 对象。 |
UpdateAccountBalance | 根据给定金额更新 `Account` 的余额。如果金额为正,则增加资金;如果为负,则提取资金。 |
TransferMoney | 在两个账户之间转账,确保资金充足。 |
using System;
using UnitTestArticle.Repositories;
using UnitTestArticle.Services.Exceptions;
namespace UnitTestArticle.Services
{
public class BankServiceBad : IDisposable
{
private BankRepositoryBad repo;
private bool disposed = false;
public BankServiceBad()
{
repo = new BankRepositoryBad();
}
public Account GetAccount(string accountNumber)
{
return repo.GetAccount(accountNumber);
}
public void UpdateAccountBalance(Account account, decimal amount)
{
if (account == null)
{
throw new ArgumentNullException(nameof(account));
}
repo.SetBalance(account.ID, account.Balance += amount);
repo.AddTransaction(account.ID, amount, account.Balance);
if (account.Balance < 0)
{
var reportingService = new ReportingService();
reportingService.AccountIsOverdrawn(account.ID);
}
}
public void TransferMoney(string sourceAccountNumber,
string destinationAccountNumber, decimal transferAmount)
{
if (transferAmount <= 0)
{
throw new InvalidAmountException();
}
Account sourceAccount = repo.GetAccount(sourceAccountNumber);
if (sourceAccount == null)
{
throw new AccountNotFoundException(sourceAccountNumber);
}
Account destinationAccount = repo.GetAccount(destinationAccountNumber);
if (destinationAccount == null)
{
throw new AccountNotFoundException(destinationAccountNumber);
}
if (sourceAccount.Balance < transferAmount)
{
throw new InsufficientFundsException();
}
// remove transferAmount from destination account
repo.SetBalance(sourceAccount.ID, sourceAccount.Balance -= transferAmount);
// record the transaction
repo.AddTransaction(sourceAccount.ID, -transferAmount, sourceAccount.Balance);
// add transferAmount to source account
repo.SetBalance
(destinationAccount.ID, destinationAccount.Balance += transferAmount);
// record the transaction
repo.AddTransaction
(destinationAccount.ID, transferAmount, destinationAccount.Balance);
}
protected virtual void Dispose(bool disposing)
{
if (!this.disposed)
{
if (disposing)
{
this.repo.Dispose();
}
}
this.disposed = true;
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
}
}
调用代码
using (var bankService = new BankServiceBad())
{
string accountNumber = "1111111111";
var account = bankService.GetAccount(accountNumber);
if (account == null)
{
throw new AccountNotFoundException(accountNumber);
}
// Add 100 to account 1111111111
bankService.UpdateAccountBalance(account, 100);
}
上述代码运行后,账户 `1111111111` 的余额将增加 `100`,并且交易表中将有一个条目显示增加了 `100`。
我们这里有两个主要类:`BankRepositoryBad` 和 `BankServiceBad`,其中服务被客户端代码调用,然后服务调用存储库。服务上的一些方法是基本的“传递”方法(如 `GetAccount`),它们只是返回对存储库调用的结果,但是像 `TransferMoney` 这样的方法实际上有一些基本的逻辑,如验证、计算账户余额如何变化、创建交易等,而 `UpdateAccountBalance` 在账户透支时可以选择调用另一个服务。
让我们首先关注单元测试存储库的可能性。这些方法中是否有实际的业务逻辑?实际上没有,它们只是围绕 Entity Framework 调用的包装器;它们中几乎所有的代码都是 Entity Framework 代码,它们只是读取、更新或创建行。我们**能**单元测试这个类吗?不能,因为它依赖于像数据库这样的外部资源。我们**应该**单元测试这个类吗?不,这个类中没有真正的逻辑,代码主要是运行第三方代码(这里是 Entity Framework)。如果我们要单元测试这段代码,我们实际上只是在测试 Microsoft 的代码,而不是我们自己的代码,我们应该假定第三方代码是有效的,我们的单元测试应该只专注于我们自己的代码。
现在我们来看看服务类。我们**应该**单元测试这个类吗?是的,它实现了业务规则,例如验证、转账时会发生什么、修改账户时会发生什么等等。我们**能**单元测试这个类吗?不能,因为它依赖于存储库,而存储库又依赖于数据库,这是一个外部资源。没有办法在不更新某种数据库的情况下单元测试这些方法。单元测试我们的服务类的诀窍是以一种仍然可以测试我们的业务逻辑的方式消除对存储库的依赖,我们通过使用接口来抽象存储库来实现这一点。
银行系统 - 更新
更新后的银行系统将包含一个接口(`IBankRepository`),该接口将由存储库实现。方法和它们的功能将与以前保持不变,唯一的区别是增加了接口。
using System;
namespace UnitTestArticle.Interfaces
{
public interface IBankRepository : IDisposable
{
Account GetAccount(string accountNumber);
Account GetAccount(int id);
void SetBalance(int id, decimal balance);
void AddTransaction(int id, decimal amount, decimal newBalance);
}
}
using System;
using System.Linq;
using UnitTestArticle.Interfaces;
namespace UnitTestArticle.Repositories
{
public class BankRepository : IBankRepository
{
private BankContext context;
private bool disposed = false;
public BankRepository(BankContext context)
{
this.context = context;
}
public BankRepository()
: this (new BankContext())
{
}
public Account GetAccount(string accountNumber)
{
return this.context
.Accounts
.FirstOrDefault(a => a.Account_Number == accountNumber.Trim());
}
public Account GetAccount(int id)
{
return this.context
.Accounts
.FirstOrDefault(a => a.ID == id);
}
public void SetBalance(int id, decimal balance)
{
Account account = GetAccount(id);
if (account == null)
{
throw new ApplicationException("Account not found");
}
account.Balance = balance;
context.SaveChanges();
}
public void AddTransaction(int id, decimal amount, decimal newBalance)
{
context.Transactions.Add(new Transaction
{
Account_ID = id,
Amount = amount,
New_Balance = newBalance,
Transaction_Date = DateTime.Now
});
context.SaveChanges();
}
protected virtual void Dispose(bool disposing)
{
if (!this.disposed)
{
if (disposing)
{
this.context.Dispose();
}
}
this.disposed = true;
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
}
}
接下来,我们将修改 `BankService` 以引用 `IBankRepository` 接口而不是具体的 `BankRepository` 类,我们还将为服务创建一个 `IBankService` 接口。
using System;
namespace UnitTestArticle.Interfaces
{
public interface IBankService : IDisposable
{
Account GetAccount(string accountNumber);
void TransferMoney(string sourceAccountNumber,
string destinationAccountNumber, decimal transferAmount);
void UpdateAccountBalance(Account account, decimal amount);
}
}
using System;
using UnitTestArticle.Interfaces;
using UnitTestArticle.Repositories;
using UnitTestArticle.Services.Exceptions;
namespace UnitTestArticle.Services
{
public class BankService : IBankService, IDisposable
{
private IBankRepository repo;
private bool disposed = false;
public BankService()
: this (new BankRepository())
{
}
public BankService(IBankRepository repo)
{
this.repo = repo;
}
public Account GetAccount(string accountNumber)
{
return repo.GetAccount(accountNumber);
}
public void UpdateAccountBalance(Account account, decimal amount)
{
if (account == null)
{
throw new ArgumentNullException(nameof(account));
}
repo.SetBalance(account.ID, account.Balance += amount);
repo.AddTransaction(account.ID, amount, account.Balance);
if (account.Balance < 0)
{
var reportingService = new ReportingService();
reportingService.AccountIsOverdrawn(account.ID);
}
}
public void TransferMoney(string sourceAccountNumber,
string destinationAccountNumber, decimal transferAmount)
{
if (transferAmount <= 0)
{
throw new InvalidAmountException();
}
Account sourceAccount = repo.GetAccount(sourceAccountNumber);
if (sourceAccount == null)
{
throw new AccountNotFoundException(sourceAccountNumber);
}
Account destinationAccount = repo.GetAccount(destinationAccountNumber);
if (destinationAccount == null)
{
throw new AccountNotFoundException(destinationAccountNumber);
}
if (sourceAccount.Balance < transferAmount)
{
throw new InsufficientFundsException();
}
// remove transferAmount from destination account
repo.SetBalance(sourceAccount.ID, sourceAccount.Balance - transferAmount);
// record the transaction
repo.AddTransaction(sourceAccount.ID, -transferAmount, sourceAccount.Balance);
// add transferAmount to source account
repo.SetBalance
(destinationAccount.ID, destinationAccount.Balance + transferAmount);
// record the transaction
repo.AddTransaction
(destinationAccount.ID, transferAmount, destinationAccount.Balance);
}
protected virtual void Dispose(bool disposing)
{
if (!this.disposed)
{
if (disposing)
{
this.repo.Dispose();
}
}
this.disposed = true;
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
}
}
如果你查看我们方法中的代码,它们都引用了类型为 `IBankRepository` 的“`repo`”,因此我们打破了这些方法中的代码与具体存储库类之间的直接依赖关系。
另请注意 `BankService` 类上的构造函数;一个接受 `IBankRepository` 实现的特定实例,另一个默认构造函数创建一个具体的 `BankRepository` 类的实例。这意味着我们有两种创建类的方式;我们可以在提供我们自己的 `IBankRepository` 实现的同时创建类。
var service = new BankService(somethingThatImplementsIBankRepository);
或者我们可以使用默认的空构造函数创建类
var service = new BankService();
该构造函数在内部创建了一个具体的 `BankService`。
向类提供其所依赖对象的能力称为*控制反转*,因为我们是通过调用代码向类提供其依赖项,而不是传统的服务本身决定其依赖项的方法。当我们进行单元测试时,这一点的重要性将变得显而易见,我们现在就来看看。
单元测试
引言
本文中,我使用的是 Visual Studio 自带的测试运行器,如果您使用 NUnit 等第三方框架,则基本原理大体相同。
相关的测试被分组在一个测试类中,保持一对一的关系,即项目中每个相关类都有一个等效的测试类。如果我有一个 `Maths` 类,那么测试类可能看起来像这样:
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace UnitTestArticle.Tests
{
[TestClass]
public class MathsTests
{
[TestInitialize]
public void Init()
{
// put code in here that you want to have run before each test
}
[TestMethod]
public void AddTest()
{
// Arrange
int number1 = 4;
int number2 = 6;
Maths m = new Maths();
// Act
int result = m.Add(number1, number2);
// Assert
Assert.AreEqual(10, result);
}
[TestMethod]
public void SubtractTest()
{
// Arrange
int number1 = 4;
int number2 = 6;
Maths m = new Maths();
// Act
int result = m.Subtract(number1, number2);
// Assert
Assert.AreEqual(-2, result);
}
}
}
测试类本身用 `[TestClass]` 属性标记,每个测试用 `[TestMethod]` 标记。这些属性告诉测试运行器测试在哪里以及要运行什么作为测试。许多框架还允许您指定在测试之前、测试之后等运行的方法;在上面的示例中,我们的 `Init` 方法被标记为 `[TestInitialize]`,这意味着它将在每个测试方法之前调用。这些方法通常用于设置您要在测试中使用的数据等。
测试方法本身采用“Arrange、Act、Assert”格式。通常,我们首先在“`Arrange`”部分完成所有设置测试数据所需的工作。接下来是“`Act`”部分,我们调用正在测试的方法,最后是“`Assert`”部分,我们验证结果。`Assert` 类提供了一系列方法,让我们测试感兴趣的事物,例如对象是否为 `null`,值是否匹配,引用是否匹配等等。
我们通过选择 Visual Studio 中的 **Test** 菜单,然后选择 **Run** 子菜单,再选择 **All Tests** 来运行测试。或者,我们可以通过“**Test Explorer**”窗口运行测试。
绿色的对勾表示测试已通过,任何失败的测试都将显示红色的叉号。测试资源管理器中也有一个“**Run All**”链接,如果您想调试您的单元测试,可以使用 **Test** 菜单中的 **Debug** 菜单。您还可以右键单击代码编辑器中测试方法的主体,并从上下文菜单中选择运行或调试它。
单元测试银行服务
我们将对银行服务进行单元测试的方式是使用控制反转,向服务提供一个不依赖外部资源的存储库。之所以能够做到这一点,是因为我们的服务依赖于接口而不是具体类,而控制反转允许我们自己提供该具体类,因此我们无需实际数据库即可测试代码逻辑。我将介绍两种实现方式。第一种叫做自搭车(self-shunting),我将介绍它的基本原理,但不会深入探讨,因为有更好的解决方案。自搭车的主要优点是它不依赖任何第三方库,并且为您提供了很好的灵活性。
自搭车
我上面说过,为了对服务代码进行单元测试,我们需要提供一个不依赖外部资源的存储库,而使用自搭车时,该存储库实际上就是测试类本身。
下面,我们有一个也实现了 `IBankRepository` 的测试类。所有方法都在“Self-shunt”区域中,它们使用 `List
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System.Collections.Generic;
using System.Linq;
using UnitTestArticle.Interfaces;
using UnitTestArticle.Services;
using UnitTestArticle.Services.Exceptions;
namespace UnitTestArticle.Tests.Services
{
[TestClass]
public class BankServiceTestsSelfShunt : IBankRepository // our test class
// implements IBankRepository
{
#region Self-shunt
// Our test class is going to implement a mocked version of IBankRepository
// using Lists to hold the data rather than database tables. The code in this
// region is a List based implementation of the IBankRepository methods
private List<Account> Accounts;
private List<Transaction> Transactions;
Account IBankRepository.GetAccount(string accountNumber)
{
return Accounts.FirstOrDefault(a => a.Account_Number == accountNumber);
}
Account IBankRepository.GetAccount(int id)
{
return Accounts.FirstOrDefault(a => a.ID == id);
}
void IBankRepository.SetBalance(int id, decimal balance)
{
Accounts.FirstOrDefault(a => a.ID == id).Balance = balance;
}
void IBankRepository.AddTransaction(int id, decimal amount, decimal newBalance)
{
Transactions.Add(new Transaction
{ Account_ID = id, Amount = amount, New_Balance = newBalance });
}
void IDisposable.Dispose()
{
}
#endregion
private BankService bankService;
[TestInitialize]
public void Init()
{
// This is called at the start of every test
Accounts = new List<Account>();
Transactions = new List<Transaction>();
// Add two test accounts to the Accounts collection
Accounts.Add(new Account { ID = 1, Account_Number = "test1", Balance = 0 });
Accounts.Add(new Account { ID = 2, Account_Number = "test2", Balance = 0 });
bankService = new BankService(this);
}
[TestMethod]
public void GetAccount_WithValidAccount_ReturnsAccount()
{
// Arrange
// Act
Account account = bankService.GetAccount("test1");
// Assert
Assert.IsNotNull(account);
Assert.AreEqual(1, account.ID);
Assert.AreEqual("test1", account.Account_Number);
}
[TestMethod]
public void TransferMoney_WithInsufficientFunds_AccountsUpdated()
{
// Arrange
bankService.GetAccount("test1").Balance = 50;
// Act
bankService.TransferMoney("test1", "test2", 10);
// Assert
Assert.AreEqual(40, ((IBankRepository)this).GetAccount("test1").Balance);
Assert.AreEqual(10, ((IBankRepository)this).GetAccount("test2").Balance);
}
}
}
我们来看看 `TransferMoney_WithSufficientFunds_AccountsUpdated` 测试方法,因为有几点我想展开说明。首先是命名约定。给测试方法命名本身就可以写一篇文章,最适合你的命名约定就是最适合你的,但我使用的约定是:方法名、下划线、我们正在测试的特定情况、下划线,然后是我期望的结果。由于我们可能要针对多种场景测试相同的方法,我们不能仅仅让测试方法名模仿它们正在测试的方法,而且这个约定还将相同方法的测试分组在一起显示在测试运行器中。你如何命名它们取决于你,我不是说你**应该**这样命名,只是说我**就是**这样命名的。
接下来是之前讨论过的“Arrange、Act、Assert”格式。在我们的 arrange 部分,我们将源账户余额设置为 `50`。接下来是“`Act`”部分,我们调用正在测试的 `TransferMoney` 方法,最后是“`Assert`”部分,我们验证结果。
我们的测试确保 `test1` 的余额为 `50`,然后向 `test2` 转账,接着断言 `test1` 的余额为 `40`,`test2` 的余额为 `10`。
让我们更深入地了解一下看似更简单的测试之一:`GetAccount_WithValidAccount_ReturnsAccount`。
[TestMethod]
public void GetAccount_WithValidAccount_ReturnsAccount()
{
// Arrange
// Act
Account account = bankService.GetAccount("test1");
// Assert
Assert.IsNotNull(account);
Assert.AreEqual(1, account.ID);
Assert.AreEqual("test1", account.Account_Number);
}
我们调用 `GetAccount` 来检索 `test1` 的账户,然后测试我们没有得到 `null` 对象,账户 ID 是 1,并且“`test1`”是账户号码。从表面上看,这看起来是一个有效的测试,但是当我们查看我们正在调用的 `BankService` 方法中的代码时:
public Account GetAccount(string accountNumber)
{
return repo.GetAccount(accountNumber);
}
`repo.GetAccount` 返回 `Account` 对象唯一的原因是我的自搭车类中的代码(记住“repo”引用的是测试类本身,所以 `repo.GetAccount` 调用的是测试类上的 `GetAccount` 方法)。所以这个测试只有在我的自搭车代码按照预期工作时才会有效。那么我们真的在测试服务方法吗?还是我们只是在测试自搭车代码?答案是,我们只是在测试自搭车代码,所以这个单元测试实际上没有价值。如果我们想单独测试服务代码,那么我们必须关注服务代码本身在做什么,即调用 repo 上的一个方法。
所以让我们在自搭车代码中添加一些功能来跟踪方法何时被调用。我们将通过一个名为 `GetAccountCalled` 的变量来实现,每次调用 `GetAccount` 时都会递增。
public class BankServiceTestsSelfShunt : IBankRepository // our test class implements
// IBankRepository
{
#region Self-shunt
// Our test class is going to implement a mocked version of IBankRepository
// using Lists to hold the data rather than database tables. The code in this
// region is a List based implementation of the IBankRepository methods
private List<Account> Accounts;
private List<Transaction> Transactions;
// GetAccountCalled will store how many times that method has been called
private int GetAccountCalled;
Account IBankRepository.GetAccount(string accountNumber)
{
// increase the call count for this method
GetAccountCalled++;
return Accounts.FirstOrDefault(a => a.Account_Number == accountNumber);
}
// ... rest of class as before
现在让我们从另一个方向来处理 `GetAccount` 的单元测试。
[TestMethod]
public void GetAccount_CallsRepo()
{
// Arrange
// Act
Account account = bankService.GetAccount("test1");
// Assert
Assert.IsTrue(GetAccountCalled > 0);
}
这可能看起来不那么直观,但它实际上是一个更有效的测试,因为我们唯一想确保 `GetAccount` 所做的就是将帐号传递给存储库以获取结果。我们在这里不测试存储库,所以我们不关心存储库做了什么,我们只关心它是否被调用,所以这才是我们应该测试的唯一事情。这是将单元测试集中在您正在测试的代码应该实际做什么以及您在创建单元测试时必须如何调整思维的另一个例子。
我将在此放弃自搭车方法,我之所以提出它,只是为了向您介绍这个概念,并帮助解决我们在针对模拟代码进行测试时遇到的一些问题。下面,我们将开始使用模拟框架来为我们完成这项工作,这是一种更好的解决方案,也是行业标准。
模拟框架
模拟框架让我们能够创建模拟或仿真我们正在抽象的类的对象,但模拟对象上的方法的行为由我们测试中的代码以编程方式驱动。对于本文,我使用的是 Moq,您可以将其作为 NuGet 包安装。还有其他模拟框架,它们的功能大致相同,只是语法不同,所以请随意使用您熟悉的任何框架。
让我们开始使用 Moq 编写一些测试
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using UnitTestArticle.Interfaces;
using UnitTestArticle.Services;
namespace UnitTestArticle.Tests.Services
{
[TestClass]
public class BankServiceTests
{
private BankService bankService;
private Mock<IBankRepository> repoMock;
private Account test1Account;
private Account test2Account;
[TestInitialize]
public void Init()
{
// This is called at the start of every test
// Create two test accounts
test1Account = new Account { ID = 1, Account_Number = "test1", Balance = 0 };
test2Account = new Account { ID = 2, Account_Number = "test2", Balance = 0 };
// Mock the IBankRepository
repoMock = new Mock<IBankRepository>();
// Ensure GetAccount returns the relevant Account object
repoMock.Setup(m => m.GetAccount("test1")).Returns(test1Account);
repoMock.Setup(m => m.GetAccount("test2")).Returns(test2Account);
bankService = new BankService(repoMock.Object);
}
[TestMethod]
public void TransferMoney_WithSufficientFunds_SourceAccountUpdated()
{
// Arrange
test1Account.Balance = 50;
// Act
bankService.TransferMoney("test1", "test2", 10);
// Assert
repoMock.Verify(m => m.SetBalance(1, 40), Times.Once);
}
}
}
如果你看 `Init` 函数,它创建了两个测试账户对象,然后创建了 `IBankRepository` 的模拟版本。模拟框架的工作方式是,你告诉它当客户端代码以特定参数调用特定函数时会发生什么。在我们的 `Init` 方法中,我们有:
repoMock.Setup(m => m.GetAccount("test1")).Returns(test1Account);
repoMock.Setup(m => m.GetAccount("test2")).Returns(test2Account);
我们正在告诉模拟的 `IBankRepository` 对象,每当使用参数“`test1`”调用 `GetAccount` 时,返回我们的 `test1Account` 对象;对于“`test2`”,返回 `test2Account`。这使我们能够完全抽象出我们的具体存储库类,并以逐个测试的方式指定模拟存储库在被调用时的行为。
`Init` 函数接下来做的是将 `bankService` 变量设置为我们的 `BankService` 类的一个实例,并将模拟的存储库作为参数传入。
bankService = new BankService(repoMock.Object);
由于我们的 `BankService` 使用传入的任何存储库,这使我们能够通过操纵对模拟存储库的调用结果来决定该服务内部代码的行为。因此,如果我们希望银行服务在调用 `GetAccount` 时收到一个余额为 `100` 的账户,我们可以做到。如果我们希望银行服务收到一个余额为 `-100` 的账户,以测试它如何处理透支账户,我们也可以做到,所有这些都无需数据库,纯粹依靠我们模拟框架的力量。
查看 `TransferMoney_WithSufficientFunds_SourceAccountUpdated` 测试方法,我们知道在它运行之前会调用 `Init` 函数,所以 `test1Account` 和 `test2Account` 已经被初始化,并且模拟的 repo 已经被告知在调用 `GetAccount` 时返回适当的 `Account`。这个方法正在测试转账,所以我们把 `test1Account` 的余额设置为 `50`,调用 `TransferMoney` 在 `test1` 和 `test2` 之间转账 `10`,然后我们断言服务告诉 repo 将 `test1` 的 `SetBalance` 设置为 `40`(`40` 是 `50` 的起始余额减去我们转账的 `10`)。
这并不是我们希望 `TransferMoney` 方法所做的全部,我们还希望它更新目标余额并为每次账户更新创建一笔交易。然而,为了保持一组良好的细粒度测试,我们将编写单独的测试来覆盖每个单独的要求。
当我们使用自搭车(self-shunting)的单元测试方法时,我们测试了 `GetAccount` 至少调用了存储库上的等效方法一次,我们现在可以使用 Moq 轻松复制这种功能。
[TestMethod]
public void GetAccount_CallsRepo()
{
// Arrange
// Act
Account account = bankService.GetAccount("test1");
// Assert
repoMock.Verify(m => m.GetAccount("test1"), Times.AtLeastOnce);
}
我们还可以通过测试返回的账户是否与我们告诉模拟仓库返回的账户相同来测试 `GetAccount`。
[TestMethod]
public void GetAccount_WithValidAccount_ReturnsAccount()
{
// Arrange
// Act
Account account = bankService.GetAccount("test1");
// Assert
Assert.IsNotNull(account);
Assert.AreSame(test1Account, account);
}
让我们进一步完善我们的银行服务测试,以涵盖其他 `TransferMoney` 场景,并添加其他方法的测试。请注意,`BankService` 类也使用了 `ReportingService` 类,所以我们对该类也进行了一些修改以进行抽象。
public class BankService : IBankService, IDisposable
{
private IBankRepository repo;
private IReportingService reportingService;
private bool disposed = false;
public BankService()
: this (new BankRepository(), new ReportingService())
{
}
public BankService(IBankRepository repo, IReportingService reportingService)
{
this.repo = repo;
this.reportingService = reportingService;
}
public void UpdateAccountBalance(Account account, decimal amount)
{
if (account == null)
{
throw new ArgumentNullException(nameof(account));
}
repo.SetBalance(account.ID, account.Balance += amount);
repo.AddTransaction(account.ID, amount, account.Balance);
if (account.Balance < 0)
{
reportingService.AccountIsOverdrawn(account.ID);
}
}
// rest of code
银行服务单元测试 - 最终版本
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using UnitTestArticle.Interfaces;
using UnitTestArticle.Services;
using UnitTestArticle.Services.Exceptions;
namespace UnitTestArticle.Tests.Services
{
[TestClass]
public class BankServiceTests
{
private BankService bankService;
private Mock<IBankRepository> repoMock;
private Mock<IReportingService> reportingMock;
private Account test1Account;
private Account test2Account;
[TestInitialize]
public void Init()
{
// This is called at the start of every test
// Create two test accounts
test1Account = new Account { ID = 1, Account_Number = "test1", Balance = 0 };
test2Account = new Account { ID = 2, Account_Number = "test2", Balance = 0 };
// Mock the classes we are abstracting
repoMock = new Mock<IBankRepository>();
reportingMock = new Mock<IReportingService>();
// Ensure GetAccount returns the relevant Account object
repoMock.Setup(m => m.GetAccount("test1")).Returns(test1Account);
repoMock.Setup(m => m.GetAccount("test2")).Returns(test2Account);
bankService = new BankService(repoMock.Object, reportingMock.Object);
}
[TestMethod]
public void GetAccount_WithValidAccount_ReturnsAccount()
{
// Arrange
// Act
Account account = bankService.GetAccount("test1");
// Assert
Assert.IsNotNull(account);
Assert.AreSame(test1Account, account);
}
[TestMethod]
[ExpectedException(typeof(InsufficientFundsException))]
public void TransferMoney_WithInsufficientFunds_RaisesException()
{
// Arrange
test1Account.Balance = 50;
// Act
bankService.TransferMoney("test1", "test2", 100);
// Assert
}
[TestMethod]
public void TransferMoney_WithSufficientFunds_SourceAccountUpdated()
{
// Arrange
test1Account.Balance = 50;
// Act
bankService.TransferMoney("test1", "test2", 10);
// Assert
repoMock.Verify(m => m.SetBalance(1, 40), Times.Once);
}
[TestMethod]
public void TransferMoney_WithSufficientFunds_DestinationAccountUpdated()
{
// Arrange
test1Account.Balance = 50;
// Act
bankService.TransferMoney("test1", "test2", 10);
// Assert
repoMock.Verify(m => m.SetBalance(2, 10), Times.Once);
}
[TestMethod]
public void TransferMoney_WithSufficientFunds_SourceTransactionCreated()
{
// Arrange
test1Account.Balance = 50;
// Act
bankService.TransferMoney("test1", "test2", 10);
// Assert
repoMock.Verify(m => m.AddTransaction(1, -10, It.IsAny<decimal>()), Times.Once);
}
[TestMethod]
public void TransferMoney_WithSufficientFunds_DestinationTransactionCreated()
{
// Arrange
test1Account.Balance = 50;
// Act
bankService.TransferMoney("test1", "test2", 10);
// Assert
repoMock.Verify(m => m.AddTransaction(2, 10, It.IsAny<decimal>()), Times.Once);
}
[TestMethod]
public void UpdateAccountBalance_WithPositiveAmount_IncreasesBalance()
{
// Arrange
test1Account.Balance = 50;
// Act
bankService.UpdateAccountBalance(test1Account, 10);
// Assert
repoMock.Verify(m => m.SetBalance(1, 60), Times.Once);
}
[TestMethod]
public void UpdateAccountBalance_WithNegativeAmount_DecreasesBalance()
{
// Arrange
test1Account.Balance = 50;
// Act
bankService.UpdateAccountBalance(test1Account, -10);
// Assert
repoMock.Verify(m => m.SetBalance(1, 40), Times.Once);
}
[TestMethod]
public void UpdateAccountBalance_WithPositiveBalance_DoesNotReportOverdrawn()
{
// Arrange
test1Account.Balance = 50;
// Act
bankService.UpdateAccountBalance(test1Account, 10);
// Assert
reportingMock.Verify(m => m.AccountIsOverdrawn(1), Times.Never);
}
[TestMethod]
public void UpdateAccountBalance_WithZeroBalance_DoesNotReportOverdrawn()
{
// Arrange
test1Account.Balance = 0;
// Act
bankService.UpdateAccountBalance(test1Account, 0);
// Assert
reportingMock.Verify(m => m.AccountIsOverdrawn(1), Times.Never);
}
[TestMethod]
public void UpdateAccountBalance_WithNegativeBalance_ReportsOverdrawn()
{
// Arrange
test1Account.Balance = 10;
// Act
bankService.UpdateAccountBalance(test1Account, -20);
// Assert
reportingMock.Verify(m => m.AccountIsOverdrawn(1), Times.Once);
}
[TestMethod]
public void UpdateAccountBalance_TransactionRecorded()
{
// Arrange
test1Account.Balance = 50;
// Act
bankService.UpdateAccountBalance(test1Account, 10);
// Assert
repoMock.Verify(m => m.AddTransaction(1, 10, 60), Times.Once);
}
}
}
如果你查看 `TransferMoney_WithInsufficientFunds_RaisesException` 测试,你会发现它做了一些有趣的事情。它期望在 Act 阶段抛出异常,我们测试它的方式是向方法添加 `[ExpectedException]` 属性。如果抛出了该异常,则测试被认为是成功的;如果未抛出,则测试被认为是失败的。
另一个有趣的地方是 `It.IsAny` 符号。
repoMock.Verify(m => m.AddTransaction(2, 10, It.IsAny<decimal>()), Times.Once);</decimal>
这意味着该参数的值无关紧要,它只需要是正确的类型,但可以有任何值。当我们不关心参数具体是什么,它与我们正在测试的内容无关时,我们使用此符号。如果您关心具体值,则可以提供它们(就像我们对 `2` 和 `10` 所做的那样)。我们也可以在 `Setup` 命令中使用这种技术,因此如果您不关心参数的确切值,我们可以使用 `IsAny`。
下面的代码只有在 `GetAccount` 以“`test1`”作为参数被调用时,才会返回 `test1Account`。
repoMock.Setup(m => m.GetAccount("test1")).Returns(test1Account);
然而,下面的代码将返回新的 `Account`,无论向 `GetAccount` 提供了什么参数。
repoMock.Setup(m => m.GetAccount(It.IsAny<string>())).Returns(new Account
{ ID = 123, Balance = 1000, Account_Number = "testAccount" });
测试私有方法
我们的代码没有使用任何 `private` 方法,但是如果您有一个想要测试的 `private` 方法,那么您不能直接测试它,因为您不能直接调用它。相反,您需要创建测试来调用 `public` 父方法,以使 `private` 方法的所有功能也得到测试。
单元测试 MVC 控制器
MVC 非常适合单元测试,因为控制器和模型是基本的 .NET 对象,可以在没有任何 Web 上下文的情况下独立实例化和调用,并且控制器操作的返回类型也是基本的 .NET 类,这使我们能够检查响应,而这正是我们单元测试所需的。让我们编写一个基本表单,允许我们在两个账户之间转账。
模型
using System.ComponentModel.DataAnnotations;
namespace UnitTestArticle.Models
{
public class TransferMoneyModel
{
[Display(Name ="Source Account Number")]
[Required]
[MinLength(10), MaxLength(10)]
public string SourceAccountNumber { get; set; }
[Display(Name = "Destination Account Number")]
[Required]
[MinLength(10), MaxLength(10)]
public string DestinationAccountNumber { get; set; }
[Display(Name = "Amount")]
[Required]
[Range(0.01,1000000)]
public decimal Amount { get; set; }
}
}
视图
@model TransferMoneyModel
@{
ViewBag.Title = "Transfer Money";
}
<h2>Transfer Money</h2>
@Html.ValidationSummary()
@using (Html.BeginForm())
{
<div class="form-group">
@Html.LabelFor(m => m.SourceAccountNumber)
@Html.TextBoxFor(m => m.SourceAccountNumber, new { @class = "form-control" })
</div>
<div class="form-group">
@Html.LabelFor(m => m.DestinationAccountNumber)
@Html.TextBoxFor(m => m.DestinationAccountNumber, new { @class = "form-control" })
</div>
<div class="form-group">
@Html.LabelFor(m => m.Amount)
@Html.TextBoxFor(m => m.Amount, new { @class = "form-control", type="number" })
</div>
<button type="submit" class="btn btn-primary">Transfer</button>
}
控制器 (Controller)
using System.Web.Mvc;
using UnitTestArticle.Interfaces;
using UnitTestArticle.Models;
using UnitTestArticle.Services;
using UnitTestArticle.Services.Exceptions;
namespace UnitTestArticle.Controllers
{
public class AccountController : Controller
{
private IBankService bankService;
public AccountController()
: this (new BankService())
{
}
public AccountController(IBankService bankService)
{
this.bankService = bankService;
}
public ActionResult Index()
{
return View();
}
[HttpGet]
public ActionResult TransferMoney()
{
return View();
}
[HttpPost]
public ActionResult TransferMoney(TransferMoneyModel model)
{
if (model.SourceAccountNumber == model.DestinationAccountNumber)
{
ModelState.AddModelError("SameAccount",
"The source and destination accounts must be different");
}
if (!ModelState.IsValid)
{
return View(model);
}
try
{
bankService.TransferMoney(model.SourceAccountNumber,
model.DestinationAccountNumber, model.Amount);
}
catch (InsufficientFundsException)
{
ModelState.AddModelError("InsufficientFunds",
"There were insufficient funds to complete the transfer.");
}
catch (AccountNotFoundException ex)
{
ModelState.AddModelError("AccountNotFound",
$"There was a problem finding account {ex.AccountNumber}.");
}
catch
{
ModelState.AddModelError("TransferFailed",
"There was a problem with the transfer, please contact your bank.");
}
if (!ModelState.IsValid)
{
return View(model);
}
return RedirectToAction("Index");
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
bankService.Dispose();
}
base.Dispose(disposing);
}
}
}
当您创建一个带有单元测试的新项目时,测试项目中会自动创建一个“Controllers”文件夹,因此在该文件夹中创建一个名为 `AccountControllerTests` 的新类。在下面的类中,我们有一个测试方法,用于测试 `TransferMoney` 操作的“正常路径”。在单元测试中,正常路径是指一切按预期工作、所有输入都有效且没有出现任何问题的场景。在进行单元测试时,测试尽可能多的场景很重要,其中一些路径将测试失败条件,但也有一些是正常路径。
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using System.Web.Mvc;
using UnitTestArticle.Controllers;
using UnitTestArticle.Interfaces;
using UnitTestArticle.Models;
using UnitTestArticle.Services.Exceptions;
namespace UnitTestArticle.Tests.Controllers
{
[TestClass]
public class AccountControllerTests
{
private Mock<IBankService> bankServiceMock;
[TestInitialize]
public void Init()
{
bankServiceMock = new Mock<IBankService>();
}
[TestMethod]
public void TransferMoney_HappyPath_TransfersMoney()
{
// Arrange
var model = new TransferMoneyModel
{
SourceAccountNumber = "test1",
DestinationAccountNumber = "test2",
Amount = 123
};
var controller = new AccountController(bankServiceMock.Object);
// Act
RedirectToRouteResult result =
controller.TransferMoney(model) as RedirectToRouteResult;
// Assert
Assert.IsNotNull(result);
bankServiceMock.Verify(m => m.TransferMoney("test1", "test2", 123), Times.Once);
Assert.AreSame("Index", result.RouteValues["action"]);
}
}
}
查看上面 `TransferMoney_HappyPath_TransfersMoney` 测试方法,我们使用数据设置模型,创建 `AccountController` 的实例,然后调用该控制器上的 `TransferMoney`。我们将纯 .NET 对象传递给控制器,这是 MVC 比 WebForms 更容易进行单元测试的方式之一,因为 WebForms 中的输入要么通过 `Request` 对象读取,要么通过服务器端控件读取,这两种方式都不容易抽象。当 `TransferMoney` 正常工作时,它会重定向到 `Index` 操作,因此我们断言响应是一个 `RedirectToRouteResult` 对象(我们通过使用“`as`”运算符进行转换来实现,如果对象无法转换,则返回 `null`),我们检查我们的银行服务是否调用了 `TransferMoney` 方法,最后检查我们正在重定向到的操作是否名为 `Index`。严格来说,我们可以将其拆分为两个测试,一个确保银行服务已被调用,另一个确保结果是重定向到 Index,但为了简洁,我将所有正常路径测试放在一个测试函数中。这更容易,因为我们的 `TransferMoney` 方法只有一个正常路径,但情况并非总是如此。
让我们用更多测试来充实我们的测试类。
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using System.Web.Mvc;
using UnitTestArticle.Controllers;
using UnitTestArticle.Interfaces;
using UnitTestArticle.Models;
using UnitTestArticle.Services.Exceptions;
namespace UnitTestArticle.Tests.Controllers
{
[TestClass]
public class AccountControllerTests
{
private Mock<IBankService> bankServiceMock;
[TestInitialize]
public void Init()
{
bankServiceMock = new Mock<IBankService>();
}
[TestMethod]
public void TransferMoney_HappyPath_TransfersMoney()
{
// Arrange
var model = new TransferMoneyModel
{
SourceAccountNumber = "test1",
DestinationAccountNumber = "test2",
Amount = 123
};
var controller = new AccountController(bankServiceMock.Object);
// Act
RedirectToRouteResult result =
controller.TransferMoney(model) as RedirectToRouteResult;
// Assert
Assert.IsNotNull(result);
bankServiceMock.Verify(m => m.TransferMoney("test1", "test2", 123), Times.Once);
Assert.AreSame("Index", result.RouteValues["action"]);
}
[TestMethod]
public void TransferMoney_WithSameAccount_HasInvalidModelState()
{
// Arrange
var model = new TransferMoneyModel
{
SourceAccountNumber = "test1",
DestinationAccountNumber = "test1",
Amount = 123
};
var controller = new AccountController(bankServiceMock.Object);
// Act
ViewResult result = controller.TransferMoney(model) as ViewResult;
// Assert
Assert.IsNotNull(result);
Assert.IsTrue(controller.ModelState.Count > 0);
Assert.IsTrue(controller.ModelState.ContainsKey("SameAccount"));
}
[TestMethod]
public void TransferMoney_WithInsufficientFunds_HasInvalidModelState()
{
// Arrange
var model = new TransferMoneyModel
{
SourceAccountNumber = "test1",
DestinationAccountNumber = "test2",
Amount = 123
};
bankServiceMock.Setup(m => m.TransferMoney
(model.SourceAccountNumber, model.DestinationAccountNumber, model.Amount))
.Throws(new InsufficientFundsException());
var controller = new AccountController(bankServiceMock.Object);
// Act
ViewResult result = controller.TransferMoney(model) as ViewResult;
// Assert
Assert.IsNotNull(result);
Assert.IsTrue(controller.ModelState.Count > 0);
Assert.IsTrue(controller.ModelState.ContainsKey("InsufficientFunds"));
}
[TestMethod]
public void TransferMoney_WithInvalidAccount_HasInvalidModelState()
{
// Arrange
var model = new TransferMoneyModel
{
SourceAccountNumber = "test1",
DestinationAccountNumber = "test2",
Amount = 123
};
bankServiceMock.Setup(m => m.TransferMoney
(model.SourceAccountNumber, model.DestinationAccountNumber, model.Amount))
.Throws(new AccountNotFoundException("test1"));
var controller = new AccountController(bankServiceMock.Object);
// Act
ViewResult result = controller.TransferMoney(model) as ViewResult;
// Assert
Assert.IsNotNull(result);
Assert.IsTrue(controller.ModelState.Count > 0);
Assert.IsTrue(controller.ModelState.ContainsKey("AccountNotFound"));
}
}
}
对于各种失败条件,我们配置模拟的银行服务抛出相关的异常,以确保控制器正确处理它们。例如,在 `TransferMoney_WithInsufficientFunds_HasInvalidModelState` 测试中,我们这样设置模拟的银行服务:
bankServiceMock.Setup(m => m.TransferMoney(model.SourceAccountNumber,
model.DestinationAccountNumber, model.Amount))
.Throws(new InsufficientFundsException());
控制器中任何试图使用银行服务在两个账户之间转移该金额的代码,都将使银行服务抛出 `InsufficientFundsException` 异常。
我们的银行服务会抛出异常来指示无效状态,但如果您模拟的方法返回“`false`”来指示失败而不是抛出异常,那么您只需配置您的模拟对象返回 `false` 即可。
单元测试模型验证
我传递给控制器的 `TransferMoneyModel` 带有 MVC 验证属性,用于强制执行强制字段、范围、字符串长度等条件,但我没有编写任何测试来涵盖这些。虽然我们必须记住,我们的测试应该只涵盖我们自己的逻辑,而不是第三方代码,并且测试模型验证实际上只是测试 Microsoft 的代码,但另一方面,我们可能希望确保人们没有将验证属性更改为与我们预期不符,因此您可能也希望为模型验证添加测试。
使用 Web 上下文对象进行单元测试
我们上面测试的 `TransferMoney` 操作不依赖任何网络上下文,因此它不访问 `Request` 对象、`Session` 对象、Cookie 或其他此类东西。如果您的代码确实依赖这些东西,我们处理它的方式与处理 Entity Framework 完全相同,我们只需将代码抽象到我们可以模拟的接口后面。让我们看一个更新后的操作,我们在 `Session` 中存储源账户,以防我们以后想将其作为默认选择。除了使用 `Session`,我们还会向账户持有人发送一封确认邮件。由于电子邮件依赖于 SMTP 服务器和网络等外部资源,这也是我们需要抽象出来的内容。
我们将不直接与 `Session` 或 SMTP 交互,而是构建一个 `Session` 管理器类来处理所有与 `Session` 的交互,同样,我们将有一个电子邮件服务也做同样的事情。这些新类也将实现它们自己的接口。
会话管理器接口
我们的 `Session` 管理器非常简单,有一个存储值的方法和一个检索值的方法。为了增加一些价值,我们使用泛型来使 `Session` 存储强类型化。
namespace UnitTestArticle.Interfaces
{
public interface ISessionManager
{
void Store<T>(string key, T value);
T Get<T>(string key);
}
}
会话管理器
using System.Web;
using UnitTestArticle.Interfaces;
namespace UnitTestArticle.Services
{
public class SessionManager : ISessionManager
{
public T Get<T>(string key)
{
return (T)HttpContext.Current.Session[key];
}
public void Store<T>(string key, T value)
{
HttpContext.Current.Session[key] = value;
}
}
}
邮件服务接口
namespace UnitTestArticle.Interfaces
{
public interface IEmailService
{
void SendTransferEmailConfirmation(string sourceAccountNumber,
string destinationAccountNumber, decimal transferAmount);
}
}
电子邮件服务
我没有费心去充实这个,但它会获取相关的电子邮件地址,然后通过 SMTP 构造并发送电子邮件。
using UnitTestArticle.Interfaces;
namespace UnitTestArticle.Services
{
public class EmailService : IEmailService
{
public void SendTransferEmailConfirmation
(string sourceAccountNumber, string destinationAccountNumber, decimal transferAmount)
{
// code to send email confirmation here
}
}
}
账户控制器
我们需要修改控制器以允许传入我们的两个新服务,并且我还修改了 `TransferMoney` 操作以在 `Session` 中存储源帐号并发送电子邮件。更新后的控制器如下:
public class AccountController : Controller
{
private IBankService bankService;
private ISessionManager sessionManager;
private IEmailService emailService;
public AccountController()
: this (new BankService(), new SessionManager(), new EmailService())
{
}
public AccountController(IBankService bankService,
ISessionManager sessionManager, IEmailService emailService)
{
this.bankService = bankService;
this.sessionManager = sessionManager;
this.emailService = emailService;
}
[HttpPost]
public ActionResult TransferMoney(TransferMoneyModel model)
{
if (model.SourceAccountNumber == model.DestinationAccountNumber)
{
ModelState.AddModelError("SameAccount",
"The source and destination accounts must be different");
}
if (!ModelState.IsValid)
{
return View(model);
}
try
{
bankService.TransferMoney(model.SourceAccountNumber,
model.DestinationAccountNumber, model.Amount);
}
catch (InsufficientFundsException)
{
ModelState.AddModelError("InsufficientFunds",
"There were insufficient funds to complete the transfer.");
}
catch (AccountNotFoundException ex)
{
ModelState.AddModelError("AccountNotFound",
$"There was a problem finding account {ex.AccountNumber}.");
}
catch
{
ModelState.AddModelError("TransferFailed",
"There was a problem with the transfer, please contact your bank.");
}
if (!ModelState.IsValid)
{
return View(model);
}
this.sessionManager.Store("SourceAccountNumber", model.SourceAccountNumber);
this.emailService.SendTransferEmailConfirmation
(model.SourceAccountNumber, model.DestinationAccountNumber, model.Amount);
return RedirectToAction("Index");
}
单元测试
我修改了 `TransferMoney` 的正常路径测试,以验证会话管理器和电子邮件服务已被调用。同样,我们实际上也可以将它们作为单独的测试,但为了简洁,我将它们都放在一个测试中。
[TestClass]
public class AccountControllerTests
{
private Mock<IBankService> bankServiceMock;
private Mock<ISessionManager> sessionManagerMock;
private Mock<IEmailService> emailService;
[TestInitialize]
public void Init()
{
bankServiceMock = new Mock<IBankService>();
sessionManagerMock = new Mock<ISessionManager>();
emailService = new Mock<IEmailService>();
}
[TestMethod]
public void TransferMoney_HappyPath_TransfersMoney()
{
// Arrange
var model = new TransferMoneyModel
{
SourceAccountNumber = "test1",
DestinationAccountNumber = "test2",
Amount = 123
};
var controller = new AccountController
(bankServiceMock.Object, sessionManagerMock.Object, emailService.Object);
// Act
RedirectToRouteResult result = controller.TransferMoney(model) as RedirectToRouteResult;
// Assert
Assert.IsNotNull(result);
// assert TransferMoney was called on the bank service
bankServiceMock.Verify(m => m.TransferMoney("test1", "test2", 123), Times.Once);
// assert the source account number was stored in the session
sessionManagerMock.Verify(m => m.Store("SourceAccountNumber", "test1"));
// assert the confirmation email was sent
emailService.Verify(m => m.SendTransferEmailConfirmation("test1", "test2", 123),
Times.Once);
// assert the result is a redirect to Index
Assert.AreSame("Index", result.RouteValues["action"]);
}
依赖注入和 IoC
当我创建具有依赖项的控制器和其他类时,我使用一种模式,即我有一个接受这些依赖项的构造函数,以及一个指定使用哪个具体类的默认无参数构造函数。
public BankService()
: this (new BankRepository(), new ReportingService())
{
}
public BankService(IBankRepository repo, IReportingService reportingService)
{
this.repo = repo;
this.reportingService = reportingService;
}
带参数的构造函数使用了控制反转,这是一种技术,通过调用代码向类提供其依赖类。这使得站点在正常操作下使用正确版本的类,并在进行单元测试时使用模拟版本的类。
在 MVC 项目中通常使用“依赖注入”(DI)框架,这是一个允许您注册哪个具体类用作给定接口实现的框架,MVC 内置支持使用 DI 创建控制器。当 DI 在项目中被使用时,如果您的所有接口都映射到 DI 框架中的具体类,则可以删除默认的无参数构造函数,因为当 MVC 创建控制器时,它会查看构造函数参数中的每个接口,并让 DI 为该参数提供适当的具体类。这个过程是递归的,因此如果其中一个类(例如 `BankService`)也有带参数的构造函数(`IBankRepository` 和 `IReportingService`),DI 也会用于将这些参数解析为具体类。但是,当 DI 未在 MVC 框架中注册时,它会使用默认的无参数构造函数创建控制器,这就是我们使用上述模式的原因。
代码覆盖率
代码覆盖率是一个指标,可以让你知道你的代码有多少百分比被你的测试覆盖,有时它还可以突出你没有考虑到的场景。Visual Studio 的一些高级版本内置了代码覆盖率工具,也有很多第三方代码覆盖率工具,但大多数都需要购买,不过也有一些开源解决方案可用。
历史
- 2020年9月20日:首次发布