C--和--NET-Core-测试驱动开发-全-
C# 和 .NET Core 测试驱动开发(全)
原文:
zh.annas-archive.org/md5/32CD200F397A73ED943D220E0FB2E744
译者:飞龙
前言
您如何验证您的跨平台.NET Core 应用程序在部署到任何地方时都能正常工作?随着业务、团队和技术环境的发展,您的代码能够随之发展吗?通过遵循测试驱动开发的原则,您可以简化代码库,使查找和修复错误变得微不足道,并确保您的代码能够按照您的想法运行。
本书指导开发人员通过建立专业的测试驱动开发流程来创建健壮、可投入生产的 C# 7 和.NET Core 应用程序。为此,您将首先学习 TDD 生命周期的各个阶段、一些最佳实践和一些反模式。
在第一章介绍了 TDD 的基础知识后,您将立即开始创建一个示例 ASP.NET Core MVC 应用程序。您将学习如何使用 SOLID 原则编写可测试的代码,并设置依赖注入。
接下来,您将学习如何使用 xUnit.net 测试框架创建单元测试,以及如何使用其属性和断言。一旦掌握了基础知识,您将学习如何创建数据驱动的单元测试以及如何在代码中模拟依赖关系。
在本书的最后,您将通过使用 GitHub、TeamCity、VSTS 和 Cake 来创建一个健康的持续集成流程。最后,您将修改持续集成构建,以测试、版本化和打包一个示例应用程序。
本书适合对象
本书适用于希望通过实施测试驱动开发原则构建质量、灵活、易于维护和高效企业应用程序的.NET 开发人员。
本书涵盖内容
第一章,“探索测试驱动开发”,向您介绍了如何通过学习和遵循测试驱动开发的成熟原则来改善编码习惯和代码。
第二章,“使用.NET Core 入门”,向您介绍了.NET Core 和 C# 7 的超酷新跨平台功能。我们将通过实际操作来学习,在 Ubuntu Linux 上使用测试驱动开发原则创建一个 ASP.NET MVC 应用程序。
第三章,“编写可测试的代码”,演示了为了获得测试驱动开发周期的好处,您必须编写可测试的代码。在本章中,我们将讨论创建可测试代码的 SOLID 原则,并学习如何为依赖注入设置我们的.NET Core 应用程序。
第四章,“.NET Core 单元测试”,介绍了.NET Core 和 C#可用的单元测试框架。我们将使用 xUnit 框架创建一个共享的测试上下文,包括设置和清除代码。您还将了解如何创建基本的单元测试,并使用 xUnit 断言来证明单元测试的结果。
第五章,“数据驱动的单元测试”,介绍了允许您通过一系列数据输入来测试代码的概念,可以是内联的,也可以来自数据源。在本章中,我们将创建 xUnit 中的数据驱动单元测试或理论。
第六章,“模拟依赖关系”,解释了模拟对象是模仿真实对象行为的模拟对象。在本章中,您将学习如何使用 Moq 框架,使用 Moq 创建的模拟对象来隔离您正在测试的类与其依赖关系。
第七章,持续集成和项目托管,侧重于测试驱动开发周期的目标,即快速提供有关代码质量的反馈。持续集成流程将这种反馈周期延伸到发现代码集成问题。在本章中,您将开始创建一个持续集成流程,该流程可以为开发团队提供有关代码质量和集成问题的快速反馈。
第八章,创建持续集成构建流程,解释了一个出色的持续集成流程将许多不同的步骤整合成一个易于重复的流程。在本章中,您将配置 TeamCity 和 VSTS 使用跨平台构建自动化系统 Cake 来清理、构建、恢复软件包依赖关系并测试您的解决方案。
第九章,测试和打包应用程序,教您修改 Cake 构建脚本以运行 xUnit 测试套件。您将通过为.NET Core 支持的各种平台版本化和打包应用程序来完成该过程。
为了充分利用本书
假定您具有 C#编程和 Microsoft Visual Studio 的工作知识。
下载示例代码文件
您可以从www.packtpub.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,可以访问www.packtpub.com/support并注册,以便直接通过电子邮件接收文件。
您可以按照以下步骤下载代码文件:
-
登录或注册www.packtpub.com。
-
选择“支持”选项卡。
-
单击“代码下载和勘误”。
-
在搜索框中输入书名,然后按照屏幕上的说明操作。
文件下载后,请确保使用最新版本的解压缩或提取文件夹:
-
Windows 的 WinRAR/7-Zip
-
Mac 的 Zipeg/iZip/UnRarX
-
Linux 的 7-Zip/PeaZip
该书的代码包也托管在 GitHub 上,网址是github.com/PacktPublishing/CSharp-and-.NET-Core-Test-Driven-Development
。如果代码有更新,将在现有的 GitHub 存储库上进行更新。
我们还有其他代码包,来自我们丰富的图书和视频目录,可在github.com/PacktPublishing/
上找到。快去看看吧!
下载彩色图片
我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图片。您可以在这里下载:www.packtpub.com/sites/default/files/downloads/CSharpanddotNETTestDrivenDevelopment_ColorImages.pdf
。
使用的约定
本书中使用了许多文本约定。
CodeInText
:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。这是一个例子:“为了使测试通过,您必须迭代实现生产代码。当实现以下IsServerOnline
方法时,预计Test_IsServerOnline_ShouldReturnTrue
测试方法将通过。”
代码块设置如下:
[Fact]
public void Test_IsServerOnline_ShouldReturnTrue()
{
bool isOnline=IsServerOnline();
Assert.True(isOnline);
}
任何命令行输入或输出都是按照以下格式编写的:
sudo apt-get update
sudo apt-get install dotnet-sdk-2.0.0
粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这种方式出现在文本中。这是一个例子:“Visual Studio Code 将尝试下载 Linux 平台所需的依赖项,Linux 的 Omnisharp 和.NET Core 调试器。”
警告或重要说明看起来像这样。
技巧和窍门看起来像这样。
第一章:探索测试驱动开发
为了打造健壮、可维护和可扩展的软件应用程序,软件开发团队和利益相关者必须在软件开发过程的不同阶段早期做出一些重要决策。这些决策者必须在整个软件开发过程中采用软件行业经过测试和证明的最佳实践和标准。
当开发人员使用开发方法、编码风格和实践来构建代码库时,这些方法会自动使源代码变得僵化且难以维护,软件项目的质量会迅速下降。本章指出了导致编写糟糕代码的习惯和实践,因此应该避免。解释了应该学习的编程习惯、开发风格和方法,以便编写清洁和可维护的代码。
在本章中,我们将涵盖以下主题:
-
维护代码的困难
-
糟糕的代码是如何变成糟糕的
-
我们可以做些什么来防止糟糕的代码
-
测试驱动开发的原则
-
测试驱动开发周期
维护代码的困难
有两种类型的代码——好的代码和糟糕的代码。这两种类型的代码在编译时语法可能是正确的,运行代码可以得到预期的结果。然而,由于编写方式的原因,糟糕的代码在扩展或甚至对代码进行小改动时会导致严重问题。
当程序员使用不专业的方法和风格编写代码时,通常会导致糟糕的代码。此外,使用难以阅读的编码风格或格式,以及未能正确有效地测试代码都是糟糕代码的先兆。当程序员为了满足即将到来的截止日期和项目里程碑而牺牲专业精神时,代码可能会写得很糟糕。
我曾遇到一些软件项目,它们迅速成为被遗弃的遗留软件项目,因为不断出现的生产错误和无法轻松地满足用户的变更请求。这是因为这些软件应用程序在投入生产时积累了严重的技术债务,这是由于软件开发人员编写了糟糕的代码,导致了糟糕的设计和开发决策,并使用了已知会导致未来维护问题的编程风格。
源代码元素——方法、类、注释和其他工件——应该易于阅读、理解、调试、重构和扩展,如果需要由原始开发人员以外的其他开发人员进行;否则,糟糕的代码已经被编写。
当你在扩展或添加新功能时,你会知道你的代码有问题,因为你会破坏现有的工作功能。当代码部分无法解码或对其进行任何更改会使系统停止时,也会发生这种情况。糟糕的代码通常是因为不遵守面向对象和“不要重复自己”(DRY)原则或错误使用这些原则。
DRY 是编程中的一个重要原则,旨在将系统分解为小组件。这些组件可以轻松管理、维护和重复使用,以避免编写重复的代码并使代码的不同部分执行相同的功能。
糟糕的代码是如何出现的?
糟糕的代码不仅仅出现在代码库中;程序员写了糟糕的代码。大多数情况下,糟糕的代码可能是由于以下任何原因之一而写成的:
-
开发人员在编写代码时使用错误的方法经常被归因于组件之间的紧密耦合
-
错误的程序设计
-
程序元素和对象的糟糕命名约定
-
编写不可读的代码以及没有适当的测试用例的代码库,因此在需要维护代码库时会导致困难
紧密耦合
大多数传统软件应用程序都被认为是紧密耦合的,灵活性和模块化性很少或根本没有。紧密耦合的软件组件会导致刚性的代码库,难以修改、扩展和维护。随着大多数软件应用程序随着时间的推移而发展,当应用程序的组件紧密耦合时,会产生大量的维护问题。这是由于需求变化、用户业务流程和操作的变化所导致的。
第三方库和框架可以减少开发时间,并允许开发人员集中精力实施用户的业务逻辑和需求,而无需浪费宝贵的生产时间通过实现常见或乏味的任务来重新发明轮子。然而,有时开发人员会将应用程序与第三方库和框架紧密耦合,从而创建维护瓶颈,需要大力修复当需要替换引用的库或框架时。
以下代码片段显示了与第三方smpp
库紧密耦合的示例:
public void SendSMS()
{
SmppManager smppManager= new SmppManager();
smppManager.SendMessage("0802312345","Hello", "John");
}
public class SmppManager
{
private string sourceAddress;
private SmppClient smppClient;
public SmppManager()
{
smppClient = new SmppClient();
smppClient.Start();
}
public void SendMessage(string recipient, string message, string senderName)
{
// send message using referenced library
}
}
代码异味
代码异味是由Kent Beck首次使用的一个术语,它指出了源代码中的更深层次的问题。代码库中的代码异味可能来自于源代码中的复制、使用不一致或模糊的命名约定和编码风格、创建具有长参数列表的方法以及具有庞大方法和类,即知道并做太多事情,从而违反了单一责任原则。列表还在继续。
在源代码中常见的代码异味是当开发人员创建两个或更多执行相同操作的方法,几乎没有变化或在应该在单个点中实现的程序细节或事实在多个方法或类中复制,导致代码库难以维护。
以下两个 ASP.NET MVC 动作方法有代码行,创建了一个强类型的字符串年份和月份列表。这些代码行本来可以被重构为第三个方法,并被这两个方法调用,但却在这两个方法中被复制:
[HttpGet]
public ActionResult GetAllTransactions()
{
List<string> years = new List<string>();
for (int i = DateTime.Now.Year; i >= 2015; i--)
years.Add(i.ToString());
List<string> months = new List<string>();
for (int j = 1; j <= 12; j++)
months.Add(j.ToString());
ViewBag.Transactions= GetTransactions(years,months);
return View();
}
[HttpGet]
public ActionResult SearchTransactions()
{
List<string> years = new List<string>();
for (int i = DateTime.Now.Year; i >= 2015; i--)
years.Add(i.ToString());
List<string> months = new List<string>();
for (int j = 1; j <= 12; j++)
months.Add(j.ToString());
ViewBag.Years = years;
ViewBag.Months = months;
return View();
}
另一个常见的代码异味出现在开发人员创建具有长参数列表的方法时,就像以下方法中所示:
public void ProcessTransaction(string username, string password, float transactionAmount, string transactionType, DateTime time, bool canProcess, bool retryOnfailure)
{
//Do something
}
坏或破损的设计
在实施应用程序时,经常会出现结构或设计和模式导致糟糕的代码,尤其是在错误使用面向对象编程原则或设计模式时。一个常见的反模式是意大利面条式编码。这在对面向对象理解不深的开发人员中很常见,这涉及创建具有不清晰结构、几乎没有可重用性以及对象和组件之间没有关系的代码库。这导致应用程序难以维护和扩展。
在经验不足的开发人员中有一种常见的做法,即在解决应用程序复杂性时不必要或不适当地使用设计模式。当错误使用设计模式时,会给代码库带来糟糕的结构和设计。使用设计模式应该简化复杂性,并为软件问题创建可读和可维护的解决方案。当某个模式导致可读性问题并明显增加了程序的复杂性时,值得重新考虑是否使用该模式,因为该模式被误用了。
例如,单例模式用于创建对资源的单个实例。单例类的设计应该有一个私有构造函数,没有参数,一个静态变量引用资源的单个实例,以及一个管理的公共手段来引用静态变量。单例模式可以简化对单一共享资源的访问,但如果没有考虑线程安全性,也可能会导致很多问题。两个或更多线程可以同时访问if (smtpGateway==null)
这一行,如果这行被评估为true
,就会创建资源的多个实例,就像下面代码中所示的实现一样:
public class SMTPGateway
{
private static SMTPGateway smtpGateway=null;
private SMTPGateway()
{
}
public static SMTPGateway SMTPGatewayObject
{
get
{
if (smtpGateway==null)
{
smtpGateway = new SMTPGateway();
}
return smtpGateway;
}
}
}
命名程序元素
有意义和描述性的元素命名可以极大地提高源代码的可读性。它可以让程序的逻辑流程更容易理解。令人惊讶的是,软件开发人员仍然会给程序元素起太短或者不够描述性的名字,比如给变量起一个字母的名字,或者使用缩写来命名变量。
对元素使用通用或模糊的名称会导致歧义。例如,将一个方法命名为Extract()
或Calculate()
,乍一看会导致主观解释。对变量使用模糊的名称也是如此。例如:
int x2;
string xxya;
虽然程序元素的命名本身就是一门艺术,但是名称应该被选择来定义目的,并简要描述元素,并确保所选名称符合所使用的编程语言的标准和规则。
有关可接受的命名准则和约定的更多信息,请访问:docs.microsoft.com/en-us/dotnet/standard/design-guidelines/naming-guidelines
。
源代码的可读性
一个良好的代码库可以通过一个新团队成员或者甚至是程序员在离开几年后能够轻松理解来轻松区分出一个糟糕的代码库。由于时间紧迫和截止日期临近,软件开发团队往往会妥协和牺牲专业精神来满足截止日期,不遵循推荐的最佳实践和标准。这经常导致他们产生不可读的代码。
以下代码片段将执行其预期的功能,尽管其中包含使用糟糕的命名约定编写的元素,这影响了代码的可读性:
public void updatetableloginentries()
{
com.Connection = conn;
SqlParameter par1 = new SqlParameter();
par1.ParameterName = "@username";
par1.Value = main.username;
com.Parameters.Add(par1);
SqlParameter par2 = new SqlParameter();
par2.ParameterName = "@date";
par2.Value = main.date;
com.Parameters.Add(par2);
SqlParameter par3 = new SqlParameter();
par3.ParameterName = "@logintime";
par3.Value = main.logintime;
com.Parameters.Add(par3);
SqlParameter par4 = new SqlParameter();
par4.ParameterName = "@logouttime";
par4.Value = DateTime.Now.ToShortTimeString(); ;
com.Parameters.Add(par4);
com.CommandType = CommandType.Text;
com.CommandText = "update loginentries set logouttime=@logouttime where username=@username and date=@date and logintime=@logintime";
openconn();
com.ExecuteNonQuery();
closeconn();
}
糟糕的源代码文档
当使用编程语言的编码风格和约定编写代码时,可以很容易地理解代码,同时避免之前讨论过的糟糕的代码陷阱。然而,源代码文档非常有价值,在软件项目中的重要性不可低估。对类和方法进行简要而有意义的文档编写可以让开发人员快速了解它们的内部结构和操作。
当没有适当的文档时,理解复杂或写得不好的类会变成一场噩梦。当原始编写代码的程序员不再提供澄清时,宝贵的生产时间可能会因为试图理解类或方法的实现而丢失。
未经测试的代码
尽管已经有很多文章和讨论在各种开发者会议上启动了不同类型的测试——测试驱动开发、行为驱动开发和验收测试驱动开发,但令人担忧的是,仍然有开发人员不断开发和发布未经彻底测试或根本没有经过测试的软件应用程序。
发布未经充分测试的应用程序可能会产生灾难性后果和维护问题。值得注意的是美国国家航空航天局于1998 年 12 月 11 日发射的火星气候轨道飞行器在接近火星时失败,原因是由于转换错误导致的软件错误,其中轨道飞行器的程序代码在计算时使用的是磅而不是牛顿。对负责计算度量标准的特定模块进行简单的单元测试可能会检测到错误并可能防止失败。
此外,根据 2016 年测试优先方法的现状报告,由名为QASymphony的测试服务公司对来自 15 个不同国家的 200 多家软件组织的测试优先方法的采用进行了调查,结果显示近一半的受访者在他们开发的应用程序中没有实施测试优先方法。
我们可以做些什么来防止糟糕的代码
编写干净的代码需要有意识地保持专业精神,并在软件开发过程的各个阶段遵循最佳行业标准。从软件项目开发的一开始就应该避免糟糕的代码,因为通过糟糕的代码积累的坏账可能会减慢软件项目的完成速度,并在软件部署到生产环境后造成未来问题。
要避免糟糕的代码,你必须懒惰,因为一般说来懒惰的程序员是最好的和最聪明的程序员,因为他们讨厌重复的任务,比如不得不回去修复本可以避免的问题。尽量使用避免编写糟糕代码的编程风格和方法,以避免不得不重写代码以修复可避免的问题、错误或支付技术债务。
松散耦合
松散耦合是紧密耦合的直接相反。这是一种良好的面向对象编程实践,通过允许组件几乎不知道其他组件的内部工作和实现来实现关注点的分离。通信是通过接口进行的。这种方法允许轻松替换组件,而不需要对整个代码库进行太多更改。在紧耦合部分的示例代码可以重构以实现松散耦合:
//The dependency injection would be done using Ninject
public ISmppManager smppManager { get; private set; }
public void SendSMS()
{
smppManager.SendMessage("0802312345","Hello", "John");
}
public class SmppManager
{
private string sourceAddress;
private SmppClient smppClient;
public SmppManager()
{
smppClient = new SmppClient();
smppClient.Start();
}
public void SendMessage(string recipient, string message, string senderName)
{
// send message using referenced library
}
}
public interface ISmppManager
{
void SendMessage(string recipient, string message, string senderName);
}
声音架构和设计
通过使用良好的开发架构和设计策略可以避免糟糕的代码。这将确保开发团队和组织具有高级架构、策略、实践、准则和治理计划,团队成员必须遵循以防止走捷径和避免在整个开发过程中出现糟糕的代码。
通过持续学习和改进,软件开发团队成员可以对编写糟糕的代码产生厚厚的皮肤。糟糕或破损的设计部分中的示例代码片段可以重构为线程安全,并避免与线程相关的问题,如下所示:
public class SMTPGateway
{
private static SMTPGateway smtpGateway=null;
private static object lockObject= new object();
private SMTPGateway()
{
}
public static SMTPGateway SMTPGatewayObject
{
get
{
lock (lockObject)
{
if (smtpGateway==null)
{
smtpGateway = new SMTPGateway();
}
}
return smtpGateway;
}
}
}
预防和检测代码异味
应该避免导致代码异味的编程风格和编码格式。通过充分关注代码异味部分中讨论的糟糕代码指针,可以避免代码的重复。在代码异味部分提到的源代码的两种方法中的重复代码可以重构为第三种方法。这样可以避免代码的重复,并且可以轻松进行修改:
[HttpGet]
public ActionResult GetAllTransactions()
{
var yearsAndMonths=GetYearsAndMonths();
ViewBag.Transactions= GetTransactions(yearsAndMonths.Item1,yearsAndMonths.Item2);
return View();
}
[HttpGet]
public ActionResult SearchTransactions()
{
var yearsAndMonths=GetYearsAndMonths();
ViewBag.Years = yearsAndMonths.Item1;
ViewBag.Months = yearsAndMonths.Item2;
return View();
}
private (List<string>, List<string>) GetYearsAndMonths(){
List<string> years = new List<string>();
for (int i = DateTime.Now.Year; i >= 2015; i--)
years.Add(i.ToString());
List<string> months = new List<string>();
for (int j = 1; j <= 12; j++)
months.Add(j.ToString());
return (years,months);
}
此外,在代码异味部分中具有长参数列表的方法可以重构为使用C# Plain Old CLR Object(POCO)以实现清晰和可重用性:
public void ProcessTransaction(Transaction transaction)
{
//Do something
}
public class Transaction
{
public string Username{get;set;}
public string Password{get;set;}
public float TransactionAmount{get;set;}
public string TransactionType{get;set;}
public DateTime Time{get;set;}
public bool CanProcess{get;set;}
public bool RetryOnfailure{get;set;}
}
开发团队应该有由团队成员共同制定的准则、原则和编码约定和标准,并应不断更新和完善。有效使用这些将防止软件代码库中的代码异味,并允许团队成员轻松识别潜在的糟糕代码。
C#编码约定
遵循 C#编码约定指南有助于掌握编写清晰、可读、易于修改和易于维护的代码。使用描述性的变量名称,代表它们的用途,如下面的代码所示:
int accountNumber;
string firstName;
此外,一行上有多个语句或声明会降低可读性。注释应该在新的一行上,而不是在代码的末尾。您可以在以下链接了解更多关于 C#编码约定的信息:docs.microsoft.com/en-us/dotnet/csharp/programming-guide/inside-a-program/coding-conventions
。
简洁而恰当的文档
您应该始终尝试编写自解释的代码。这可以通过良好的编程风格实现。以这样一种方式编写代码,使得您的类、方法和其他对象都是自解释的。新的开发人员应该能够使用您的代码,而不必在理解代码及其内部结构之前感到紧张。
编码元素应该具有描述性和意义,以向读者提供洞察力。在必须记录方法或类以提供进一步澄清的情况下,采用“保持简单”的方法,简要说明某个决定的原因。检查以下代码片段;没有人希望为包含 200 行代码的类阅读两页文档:
///
/// This class uses SHA1 algorithm for encryption with randomly generated salt for uniqueness
///
public class AESEncryptor
{
//Code goes here
}
KISS,也称为“保持简单,愚蠢”,是一种设计原则,它指出大多数系统在保持简单而不是使其不必要地复杂时运行得最好。该原则旨在帮助程序员尽可能简化代码,以确保未来可以轻松维护代码。
为什么要进行测试驱动开发?
每当我与不实践测试驱动开发的人进行讨论时,他们通常有一个共同点,那就是它消耗时间和资源,而且并不能真正带来投资回报。我通常会回答他们,问哪个更好,即在应用程序开发过程中检测错误和潜在瓶颈并修复它们,还是在应用程序处于生产状态时进行热修复?测试驱动开发将为您节省大量问题,并确保您生成健壮且无故障的应用程序。
面向长期发展
为了避免由于用户需求变化而对系统进行修改时可能导致的未来问题,以及由于代码库中固有的糟糕代码和累积的技术债务而暴露的错误,您需要具有以未来为考量并接受变化的思维方式。
使用灵活的模式,并且在编写代码时始终遵循良好的面向对象开发和设计原则。大多数软件项目的需求在其生命周期内都会发生变化。假设某个组件或部分不会发生变化是错误的,因此请尝试建立一个机制,使应用程序能够优雅地接受未来的变化。
测试驱动开发的原则
测试驱动开发(TDD)是一种迭代的敏捷开发技术,强调先测试开发,这意味着在编写生产就绪的代码之前编写测试。TDD 技术侧重于通过不断重构代码来确保代码通过先前编写的测试,从而编写干净和高质量的代码。
TDD 作为一种先测试的开发方法,更加强调构建经过充分测试的软件应用程序。这使开发人员能够根据在经过深思熟虑后定义的测试任务来编写代码。在 TDD 中,常见的做法是在编写实际应用程序代码之前编写测试代码。
TDD 引入了一个全新的开发范式,并改变了你的思维方式,开始在甚至开始编写代码之前考虑测试你的代码。这与传统的开发技术相反,传统技术将代码测试推迟到开发周期的后期阶段,这种方法被称为最后测试开发(TLD)。
TDD 已经在多个会议和黑客马拉松上进行了讨论。许多技术倡导者和博客作者都在博客中讨论了 TDD、它的原则和好处。与此同时,也有许多关于 TDD 的演讲和文章。诚实的事实是 TDD 很棒,它有效,当正确和一贯地实践时,它提供了巨大的好处。
你可能会想,就像每个新接触 TDD 的开发人员一样,为什么要先写测试,因为你相信自己的编码直觉可以编写始终有效的干净代码,并且通常在编码完成后会测试整个代码。你的编码直觉可能是正确的,也可能不是。在代码通过一组书面测试用例并通过验证之前,没有办法验证这个假设;信任是好的,但控制更好。
TDD 中的测试用例是通过用户故事或正在开发的软件应用程序的用例来准备的。然后编写代码并进行迭代重构,直到测试通过。例如,编写用于验证信用卡长度的方法可能包含用例来验证正确长度、不正确长度,甚至当空或空信用卡作为参数传递给方法时。
自 TDD 最初被推广以来,已经提出了许多变体。其中一种是行为驱动开发(BDD)或验收测试驱动开发(ATDD),它遵循 TDD 的所有原则,而测试是基于预期的用户指定行为。
TDD 的起源
关于 TDD 实践是何时引入计算机编程或者是哪家公司首先使用的,实际上没有任何书面证据。然而,1957 年 D.D. McCracken 的《数字计算机编程》中有一段摘录,表明 TDD 的概念并不新鲜,早期的人们已经使用过,尽管名称显然不同。
在编码开始之前,可能会对结账问题进行第一次攻击。为了充分确定答案的准确性,有必要准备一个手工计算的检查案例,以便将来与机器计算的答案进行比较。这意味着存储程序机永远不会用于真正的一次性问题。总是必须有迭代的元素来使其付出。
此外,在 1960 年代初,IBM 的人们为 NASA 运行了一个项目(Project Mecury),他们利用了类似 TDD 的技术,进行了半天的迭代,并且开发团队对所做的更改进行了审查。这是一个手动过程,无法与我们今天拥有的自动化测试相比。
TDD 最初是由 Kent Beck 推广的。他将其归因于他在一本古老书中读到的一段摘录,其中 TDD 被描述为简单的陈述,你拿输入磁带,手动输入你期望的输出磁带,然后编程直到实际输出磁带与期望输出相匹配。当他在 Smalltalk 开发了第一个 xUnit 测试框架时,Kent Beck 重新定义了 TDD 的概念。
可以肯定地说,Smalltalk 社区在 TDD 变得普遍之前就已经使用了 TDD,因为社区中使用了SUnit。直到Kent Beck和其他爱好者将 SUnit 移植到JUnit之后,TDD 才变得广为人知。从那时起,不同的测试框架已经被开发出来。一个流行的工具是xUnit,可以为大量编程语言提供端口。
TDD 的误解
在涉及 TDD 时,开发人员有不同的观点。大多数开发人员抱怨完全实践 TDD 所需的时间和资源,以及实践 TDD 可能不可行,基于紧迫的截止日期和时间表。这种看法在刚刚采用该技术的开发人员中很常见,因为 TDD 需要编写双倍的代码,而这些时间本可以用来开发其他功能,而且 TDD 最适合具有小功能或任务的项目,对于大型项目来说,可能会浪费时间,回报很少。
此外,一些开发人员抱怨模拟可能会使 TDD 变得非常困难和令人沮丧,因为所需的依赖关系不应该在实现依赖代码的同时实现,而应该进行模拟。使用传统的测试最后的方法,可以实现依赖关系,然后可以测试代码的所有不同部分。
另一个常见的误解是,在真正意义上,直到确定设计依赖于代码实现之前,测试才不能被编写。这是不正确的,因为采用 TDD 将确保对代码实现的计划清晰明了,从而产生一个适当的设计,可以帮助编写高效可靠的测试。
有时候,一些人会将 TDD 和单元测试混为一谈,认为它们是一样的。TDD 和单元测试并不相同。单元测试涉及在最小的编码单元或级别上实践 TDD,这是一种方法或函数,而 TDD 是一种技术和设计方法,包括单元测试、集成测试以及验收测试。
刚接触 TDD 的开发人员经常认为在编写实际代码之前必须完全编写测试。事实恰恰相反,因为 TDD 是一种迭代技术。TDD 倾向于探索性过程,你编写测试并编写足够的代码。如果失败,就重构代码直到通过,然后可以继续实现应用程序的下一个功能。
TDD 并不是一个可以自动修复所有糟糕编码行为的灵丹妙药。你可以实践 TDD,但仍然编写糟糕的代码甚至糟糕的测试。如果没有正确使用 TDD 原则和实践,或者试图在不适合使用 TDD 的地方使用 TDD,这是可能的。
TDD 的好处
TDD,如果正确和适当地完成,可以带来良好的投资回报,因为它有助于开发自测代码,从而产生具有更少或没有错误的健壮软件应用程序。这是因为大部分可能出现在生产中的错误和问题在开发阶段已经被捕捉和修复了。
除了源代码文档,编写测试也是一种良好的编码实践,因为它们作为源代码的微型文档,可以快速理解代码的工作原理。测试将显示预期的输入以及预期的输出或结果。从测试中可以轻松理解应用程序的结构,因为所有对象都将有测试,以及对象方法的测试,显示它们的使用方式。
正确和持续地实践 TDD 有助于编写具有良好抽象、灵活设计和架构的优雅代码。这是因为,为了有效地测试应用程序的所有部分,各种依赖关系需要被分解成可以独立测试的组件,并在集成后进行测试。
代码的清晰性在于使用最佳行业标准编写代码,易于维护,可读性强,并且编写了用于验证其一致行为的测试。这表明没有测试的代码是糟糕的代码,因为没有直接验证其完整性的特定方式。
测试的类型
测试软件项目可以采用不同的形式,通常由开发人员和测试分析员或专家进行。测试是为了确定软件是否符合其指定的期望,如果可能的话,识别错误,并验证软件是否可用。大多数程序员通常认为测试和调试是一样的。调试是为了诊断软件中的错误和问题,并采取可能的纠正措施。
单元测试
这是测试的一个级别,涉及测试构成软件应用程序组件的每个单元。这是测试的最低级别,它在方法或函数级别进行。它主要由程序员完成,特别是为了显示代码的正确性和要求是否已经正确实现。单元测试通常具有一个或多个输入和输出。
这是通常在软件开发中进行的第一级测试,旨在隔离软件系统的单元并独立或隔离地测试它们。通过单元测试,系统中固有的问题和错误可以在开发过程的早期轻松检测到。
集成测试
集成测试是通过组合和测试不同的单元或组件来完成的,这些单元或组件必须在隔离状态下进行测试。这个测试是为了确保应用程序的不同单元可以共同工作以满足用户的需求。通过集成测试,您可以在不同组件交互和交换数据时发现系统中的错误。
这项测试可以由程序员、软件测试人员或质量保证分析员进行。可以使用不同的方法进行集成测试:
-
自上而下:在较低级别组件之前,先集成和测试顶层组件
-
自下而上:在顶层组件之前,先集成和测试较低级别的组件
-
大爆炸:所有组件一起集成并一次性测试
系统测试
这个测试级别是您验证整个集成系统以确保其符合指定的用户需求。这个测试通常在集成测试之后立即进行,由专门的测试人员或质量保证分析员进行。
整个软件系统套件是从用户的角度进行测试,以识别隐藏的问题或错误和可用性问题。对实施的系统进行了严格的测试,使用系统应处理的真实输入,并验证输出是否符合预期数据。
用户验收测试
用户验收测试通常用于指定软件应用程序的工作方式。这些测试是为业务用户和程序员编写的,用于确定系统是否符合期望和用户特定要求,以及系统是否根据规格完全和正确地开发。这项测试由最终用户与系统开发人员合作进行,以确定是否正式接受系统或进行调整或修改。
TDD 的原则
TDD 的实践有助于设计清晰的代码,并作为大型代码库中回归的缓冲。它允许开发人员轻松确定新实施的功能是否通过运行测试时获得的即时反馈破坏了先前正常工作的其他功能。TDD 的工作原理如下图所示:
编写测试
这是技术的初始步骤,您必须编写描述要开发的组件或功能的测试。组件可以是用户界面、业务规则或逻辑、数据持久性例程,或实现特定用户需求的方法。测试需要简洁,并应包含组件测试所需的数据输入和期望的预期结果。
在编写测试时,从技术上讲,你已经解决了一半的开发任务,因为通过编写测试来构思代码的设计。在编写的测试之后,更容易处理困难的代码,这就是已经编写的测试。在这一点上,作为 TDD 新手,不要期望测试是 100%完美或具有完整的代码覆盖率,但通过持续的练习和充分的重构,这是可以实现的。
编写代码
在编写完测试之后,你应该编写足够的代码来实现之前编写的测试所需的功能。请记住,这里的目标是尽量采用良好的实践和标准来编写代码,以使测试通过。应避免所有导致编写糟糕或糟糕代码的方法。
尽量避免测试过度拟合,即为了使测试通过而编写代码的情况。相反,你应该编写代码来实现功能或用户需求,以确保覆盖功能的每种可能用例,避免代码在测试用例执行和生产环境中执行时具有不同的行为。
运行测试
当你确信已经有足够的代码使测试通过时,你应该运行测试,使用你选择的测试套件。此时,测试可能会通过或失败。这取决于你如何编写代码。
TDD 的一个基本规则是多次运行测试,直到测试通过。最初,在代码完全实现之前运行测试时,测试将失败,这是预期的行为。
重构
为了实现完整的代码覆盖率,测试和源代码都必须进行重构和多次测试,以确保编写出健壮且干净的代码。重构应该是迭代的,直到实现完整的覆盖率。重构步骤应该删除代码中的重复部分,并尝试修复任何代码异味的迹象。
TDD 的本质是编写干净的代码,从而构建可靠的应用程序,这取决于所编写的测试类型(单元测试、验收测试或集成测试)。重构可以局部地影响一个方法,也可以影响多个类。例如,在重构一个接口或一个类中的多个方法时,建议您逐渐进行更改,一次一个测试,直到所有测试及其实现代码都被重构。
以错误的方式进行 TDD
尽管练习 TDD 可能很有趣,但也可能被错误地执行。对于 TDD 新手来说,有时可能会编写过大的怪物测试,这远远超出了测试简洁性和能够快速执行 TDD 循环的目的,导致了生产开发时间的浪费。
部分采用该技术也可能减少 TDD 的全部好处。在团队中只有少数开发人员使用该技术而其他人不使用的情况下,这将导致代码片段化,其中一部分代码经过测试,另一部分没有经过测试,从而导致应用程序不可靠。
应避免为自然微不足道或不需要的代码编写测试;例如,为对象访问器编写测试。测试应该经常运行,特别是通过测试运行器、构建工具或持续集成工具。不经常运行测试可能导致情况,即即使已经进行了更改并且组件可能失败,代码基地的真实状态也不为人所知。
TDD 循环
TDD 技术遵循一个被称为红-绿-重构循环的原则,红色状态是初始状态,表示 TDD 循环的开始。在红色状态下,测试刚刚被编写,并且在运行时将失败。
下一个状态是绿色状态,它显示在实际应用代码编写后测试已通过。重构代码是确保代码完整性和健壮性的重要步骤。重构将反复进行,直到代码满足性能和需求期望为止。
在周期开始时,尚未编写用于运行测试的生产代码,因此预计测试将失败。例如,在以下代码片段中,IsServerOnline
方法尚未实现,当运行Test_IsServerOnline_ShouldReturnTrue
单元测试方法时,它应该失败。
public bool IsServerOnline()
{
return false;
}
[Fact]
public void Test_IsServerOnline_ShouldReturnTrue()
{
bool isOnline=IsServerOnline();
Assert.True(isOnline);
}
为了使测试通过,您必须迭代实现生产代码。当实现以下IsServerOnline
方法时,预期Test_IsServerOnline_ShouldReturnTrue
测试方法将通过。
public bool IsServerOnline()
{
string address="localhost";
int port=8034;
SmppManager smppManager= new SmppManager(address, port);
bool isOnline=smppManager.TestConnection();
return isOnline;
}
[Fact]
public void Test_IsServerOnline_ShouldReturnTrue()
{
bool isOnline=IsServerOnline();
Assert.True(isOnline);
}
当测试运行并通过时,根据您使用的测试运行器显示绿色,这会立即向您提供有关代码状态的反馈。这让您对代码的正确运行和预期行为感到自信和内心的喜悦。
重构是一个迭代的努力,您将不断修改先前编写的代码以通过测试,直到它达到了生产就绪状态,并且完全实现了需求,并且适用于所有可能的用例和场景。
总结
通过本章讨论的原则和编码模式,可以避免大多数潜在的软件项目维护瓶颈。成为专业人士需要保持一致性,要有纪律性,并坚持良好的编码习惯、实践,并对 TDD 持有专业态度。
编写易于维护的清晰代码将在长期内得到回报,因为将需要更少的工作量来进行用户请求的更改,并且当应用程序始终可供使用且几乎没有错误时,用户将感到满意。
在下一章中,我们将探索.NET Core 框架及其能力和局限性。此外,我们将在审查 C#编程语言的第 7 版中介绍的新功能之前,先了解 Microsoft Visual Studio Code。
第二章:开始使用.NET Core
当微软发布第一个版本的.NET Framework 时,这是一个创建、运行和部署服务和应用程序的平台,它改变了游戏规则,是微软开发社区的一场革命。使用初始版本的框架开发了几个尖端应用程序,然后发布了几个版本。
多年来,.NET Framework 得到了蓬勃发展和成熟,支持多种编程语言,并包含了多个功能,使得在该平台上编程变得简单而有价值。但是,尽管框架非常强大和吸引人,但限制了开发和部署应用程序只能在微软操作系统变体上进行。
为了为开发人员解决.NET Framework 的限制,创建一个面向云的、跨平台的.NET Framework 实现,微软开始使用.NET Framework 开发.NET Core 平台。随着 2016 年版本 1.0 的推出,.NET 平台的应用程序开发进入了一个新的维度,因为.NET 开发人员现在可以轻松地构建在 Windows、Linux、macOS 和云、嵌入式和物联网设备上运行的应用程序。.NET Core 与.NET Framework、Xamarin 和 Mono 兼容,通过.NET 标准。
本章将介绍.NET Core 和 C# 7 的超酷新跨平台功能。我们将在 Ubuntu Linux 上使用 TDD 创建一个 ASP.NET MVC 应用程序来学习。在本章中,我们将涵盖以下主题:
-
.NET Core 框架
-
.NET Core 应用程序的结构
-
微软的 Visual Studio Code 编辑器之旅
-
C# 7 的新功能一览
-
创建 ASP.NET MVC Core 应用程序
.NET Core 框架
.NET Core是一个跨平台的开源开发框架,可以在 Windows、Linux 和 macOS 上运行,并支持 x86、x64 和 ARM 架构。.NET Core 是从.NET Framework 分叉出来的,从技术上讲,它是后者的一个子集,尽管是简化的、模块化的。.NET Core 是一个开发平台,可以让您在开发和部署应用程序时拥有很大的灵活性。新平台使您摆脱了通常在应用程序部署过程中遇到的麻烦。因此,您不必担心在部署服务器上管理应用程序运行时的版本。
目前,版本 2.0.7 中,.NET Core 包括具有出色性能和许多功能的.NET 运行时。微软声称这是最快的.NET 平台版本。它有更多的 API 和更多的项目模板,比如用于在.NET Core 上运行的 ReactJS 和 AngularJS 应用程序的模板。此外,版本 2.0.7 还有一组命令行工具,使您能够在不同平台上轻松构建和运行命令行应用程序,以及简化的打包和对 Macintosh 上的 Visual Studio 的支持。.NET Core 的一个重要副产品是跨平台模块化 Web 框架 ASP.NET Core,它是 ASP.NET 的全面重新设计,并在.NET Core 上运行。
.NET Framework 非常强大,并包含多个库用于应用程序开发。然而,一些框架的组件和库可能与 Windows 操作系统耦合。例如,System.Drawing
库依赖于 Windows GDI,这就是为什么.NET Framework 不能被认为是跨平台的,尽管它有不同的实现。
为了使.NET Core 真正跨平台,像 Windows Forms 和Windows Presentation Foundation(WPF)这样对 Windows 操作系统有很强依赖的组件已经从平台中移除。ASP.NET Web Forms 和Windows Communication Foundation(WCF)也已被移除,并用 ASP.NET Core MVC 和 ASP.NET Core Web API 替代。此外,Entity Framework(EF)已经被简化,使其跨平台,并命名为 Entity Framework Core。
此外,由于.NET Framework 对 Windows 操作系统的依赖,微软无法开放源代码。然而,.NET Core 是完全开源的,托管在 GitHub 上,并拥有一个不断努力开发新功能和扩展平台范围的蓬勃发展的开发者社区。
.NET 标准
.NET 标准是微软维护的一组规范和标准,所有.NET 平台都必须遵循和实现。它正式规定了所有.NET 平台变体都应该实现的 API。目前.NET 平台上有三个开发平台—.NET Core、.NET Framework 和 Xamarin。.NET 平台需要提供统一性和一致性,使得在这三个.NET 平台变体上更容易共享代码和重用库。
.NET 平台提供了一组统一的基类库 API 的定义,所有.NET 平台都必须实现,以便开发人员可以轻松地在.NET 平台上开发应用程序和可重用库。目前的版本是 2.0.7,.NET 标准提供了新的 API,这些 API 在.NET Core 1.0 中没有实现,但现在在 2.0 版本中已经实现。超过 20,000 个 API 已经添加到运行时组件中。
此外,.NET 标准是一个目标框架,这意味着你可以开发你的应用程序以针对特定版本的.NET 标准,使得应用程序可以在实现该标准的任何.NET 平台上运行,并且你可以轻松地在不同的.NET 平台之间共享代码、库和二进制文件。当构建应用程序以针对.NET 标准时,你应该知道较高版本的.NET 标准有更多可用的 API,但并不是许多平台都实现了。建议你始终针对较低版本的标准,这将保证它被许多平台实现:
.NET 核心组件
.NET Core 作为通用应用程序开发平台,由CoreCLR、CoreFX、SDK 和 CLI 工具、应用程序主机和dotnet 应用程序启动器组成:
CoreCLR,也称为.NET Core 运行时,是.NET Core 的核心,是 CLR 的跨平台实现;原始的.NET Framework CLR 已经重构为 CoreCLR。CoreCLR,即公共语言运行时,管理对象的使用和引用,不同编程语言中的对象的通信和交互,并通过在对象不再使用时释放内存来执行垃圾收集。CoreCLR 包括以下内容:
-
垃圾收集器
-
即时(JIT)编译器
-
本地互操作
-
.NET 基本类型
CoreFX 是.NET Core 的一组框架或基础库,它提供原始数据类型、文件系统、应用程序组合类型、控制台和基本实用工具。CoreFX 包含了一系列精简的类库。
.NET Core SDK 包含一组工具,包括命令行界面(CLI)工具和编译器,用于构建应用程序和库在.NET Core 上运行。SDK 工具和语言编译器提供功能,通过 CoreFX 库支持的语言组件,使编码更加简单和快速。
为了启动一个.NET Core 应用程序,dotnet 应用程序主机是负责选择和托管应用程序所需运行时的组件。.NET Core 有控制台应用程序作为主要应用程序模型,以及其他应用程序模型,如 ASP.NET Core、Windows 10 通用 Windows 平台和 Xamarin Forms。
支持的语言
.NET Core 1.0 仅支持C#和F#,但随着.NET Core 2.0 的发布,VB.NET现在也受到了平台的支持。支持的语言的编译器在.NET Core 上运行,并提供对平台基础功能的访问。这是可能的,因为.NET Core 实现了.NET 标准规范,并公开了.NET Framework 中可用的 API。支持的语言和.NET SDK 工具可以集成到不同的编辑器和 IDE 中,为您提供不同的编辑器选项,用于开发应用程序。
何时选择.NET Core 而不是.NET Framework
.NET Core 和.NET Framework 都非常适合用于开发健壮和可扩展的企业应用程序;这是因为这两个平台都建立在坚实的代码基础上,并提供了丰富的库和例程,简化了大多数开发任务。这两个平台共享许多相似的组件,因此可以在两个开发平台之间共享代码。然而,这两个平台是不同的,选择.NET Core 作为首选的开发平台应受开发方法以及部署需求和要求的影响。
跨平台要求
显然,当您开发的应用程序要在多个平台上运行时,应该使用.NET Core。由于.NET Core 是跨平台的,因此适用于开发可以在Windows、Linux和macOS上运行的服务和 Web 应用程序。此外,微软推出了Visual Studio Code,这是一个具有对.NET Core 的全面支持的编辑器,提供智能感知和调试功能,以及传统上仅在Visual Studio IDE中可用的其他 IDE 功能。
部署的便利性
使用.NET Core,您可以并排安装不同的版本,这是在使用.NET Framework 时不可用的功能。通过.NET Core 的并排安装,可以在单个服务器上安装多个应用程序,使每个应用程序都可以在其自己的.NET Core 版本上运行。最近,人们对容器和应用程序容器化引起了很多关注。容器用于创建软件应用程序的独立包,包括使应用程序在共享操作系统上与其他应用程序隔离运行所需的运行时。当使用.NET Core 作为开发平台时,将.NET 应用程序容器化要好得多。这是因为它具有跨平台支持,从而允许将应用程序部署到不同操作系统的容器中。此外,使用.NET Core 创建的容器映像更小、更轻量。
可扩展性和性能
使用.NET Core,开发使用微服务架构的应用程序相对较容易。使用微服务架构,您可以开发使用不同技术混合的应用程序,例如使用 PHP、Java 或 Rails 开发的服务。您可以使用.NET Core 开发微服务,以部署到云平台或容器中。使用.NET Core,您可以开发可扩展的应用程序,可以在高性能计算机或高端服务器上运行,从而使您的应用程序可以轻松为数十万用户提供服务。
.NET Core 的限制
虽然.NET Core 是强大的、易于使用的,并在应用程序开发中提供了几个好处,但它目前并不适用于所有的开发问题和场景。微软从.NET Framework 中删除了几项技术,以使.NET Core 变得简化和跨平台。因此,这些技术在.NET Core 中不可用。
当您的应用程序将使用.NET Core 中不可用的技术时,例如在表示层使用 WPF 或 Windows Forms,WCF 服务器实现,甚至目前没有.NET Core 版本的第三方库,建议您使用.NET Framework 开发应用程序。
.NET Core 应用程序的结构
随着.NET Core 2.0 的发布,添加了新的模板,为可以在平台上运行的不同应用程序类型提供了更多选项。除了现有的项目模板之外,还添加了以下单页应用程序(SPA)模板:
-
角度
-
ReactJS
-
ReactJS 和 Redux
.NET Core 中的控制台应用程序与.NET Framework 具有类似的结构,而 ASP.NET Core 具有一些新组件,包括以前版本的 ASP.NET 中没有的文件夹和文件。
ASP.NET Core MVC 项目结构
多年来,ASP.NET Web 框架已经完全成熟,从 Web 表单过渡到 MVC 和 Web API。ASP.NET Core 是一个新的 Web 框架,用于开发可以在.NET Core 上运行的 Web 应用程序和 Web API。它是 ASP.NET 的精简和更简化版本,易于部署,并具有内置的依赖注入。ASP.NET Core 可以与 AngularJS、Bootstrap 和 ReactJS 等框架集成。
ASP.NET Core MVC,类似于 ASP.NET MVC,是构建 Web 应用程序和 API 的框架,使用模型视图控制器模式。与 ASP.NET MVC 一样,它支持模型绑定和验证,标签助手,并使用Razor 语法用于 Razor 页面和 MVC 视图。
ASP.NET Core MVC 应用程序的结构与 ASP.NET MVC 不同,添加了新的文件夹和文件。当您从 Visual Studio 2017,Visual Studio for Mac 或通过解决方案资源管理器中的 CLI 工具创建新的 ASP.NET Core 项目时,您可以看到添加到项目结构的新组件。
wwwroot 文件夹
在 ASP.NET Core 中,新添加的wwwroot
文件夹用于保存库和静态内容,例如图像,JavaScript 文件和库,以及 CSS 和 HTML,以便轻松访问并直接提供给 Web 客户端。wwwroot
文件夹包含.css
,图像,.js
和.lib
文件夹,用于组织站点的静态内容。
模型,视图和控制器文件夹
与 ASP.NET MVC 项目类似,ASP.NET MVC 核心应用程序的根文件夹也包含模型,视图和控制器,遵循 MVC 模式的约定,以正确分离 Web 应用程序文件,代码和表示逻辑。
JSON 文件 - bower.json,appsettings.json,bundleconfig.json
引入的一些其他文件包括appsettings.json
,其中包含所有应用程序设置,bower.json
,其中包含用于管理项目中使用的客户端包括 CSS 和 JavaScript 框架的条目,以及bundleconfig.json
,其中包含用于配置项目的捆绑和最小化的条目。
Program.cs
与 C#控制台应用程序类似,ASP.NET Core 具有Program
类,这是一个重要的类,包含应用程序的入口点。该文件具有用于运行应用程序的Main()
方法,并用于创建WebHostBuilder
的实例,用于创建应用程序的主机。在Main
方法中指定要由应用程序使用的Startup
类:
public class Program
{
public static void Main(string[] args)
{
BuildWebHost(args).Run();
}
public static IWebHost BuildWebHost(string[] args) =>
WebHost.CreateDefaultBuilder(args)
.UseStartup<Startup>()
.Build();
}
Startup.cs
ASP.NET Core 应用程序需要Startup
类来管理应用程序的请求管道,配置服务和进行依赖注入。
不同的Startup
类可以为不同的环境创建;例如,您可以在应用程序中创建两个Startup
类,一个用于开发环境,另一个用于生产环境。您还可以指定一个Startup
类用于所有环境。
Startup
类有两个方法——Configure()
,这是必须的,用于确定应用程序如何响应 HTTP 请求,以及ConfigureServices()
,这是可选的,用于在调用Configure
方法之前配置服务。这两种方法在应用程序启动时都会被调用:
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc();
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Home/Error");
}
app.UseStaticFiles();
app.UseMvc(routes =>
{
routes.MapRoute(
name: "default",
template: "{controller=Home}/{action=Index}/{id?}");
});
}
}
微软的 Visual Studio Code 编辑器之旅
开发.NET Core 应用程序变得更加容易,不仅因为平台的流畅性和健壮性,还因为引入了Visual Studio Code,这是一个跨平台编辑器,可以在 Windows、Linux 和 macOS 上运行。在创建.NET Core 应用程序之前,您不需要在系统上安装 Visual Studio IDE。
Visual Studio Code 虽然没有 Visual Studio IDE 那么强大和功能丰富,但确实具有内置的生产力工具和功能,使得使用它轻松创建.NET Core 应用程序。您还可以在 Visual Studio Code 中安装用于多种编程语言的扩展,从 Visual Studio Marketplace 中获取,从而可以灵活地编辑其他编程语言编写的代码。
在 Linux 上安装.NET Core
为了展示.NET Core 的跨平台功能,让我们在 Ubuntu 17.04 桌面版上设置.NET Core 开发环境。在安装 Visual Studio Code 之前,让我们在Ubuntu OS上安装.NET Core。首先,您需要通过在添加 Microsoft 产品 feed 之前注册 Microsoft 签名密钥来进行一次性注册:
- 启动系统终端并运行以下命令注册微软签名密钥:
curl https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > microsoft.gpg
sudo mv microsoft.gpg /etc/apt/trusted.gpg.d/microsoft.gpg
- 使用此命令注册 Microsoft 产品 feed:
sudo sh -c 'echo "deb [arch=amd64] https://packages.microsoft.com/repos/microsoft-ubuntu-zesty-prod zesty main" > /etc/apt/sources.list.d/dotnetdev.list
- 要在 Linux 操作系统上安装.NET Core SDK 和其他开发.NET Core 应用程序所需的组件,请运行以下命令:
sudo apt-get update
sudo apt-get install dotnet-sdk-2.0.0
- 这些命令将更新系统,您应该会看到之前添加的 Microsoft 存储库在 Ubuntu 尝试从中获取更新的存储库列表中。更新后,.NET Core 工具将被下载并安装到系统上。您终端屏幕上显示的信息应该与以下截图中的信息类似:
- 安装完成后,在
Documents
文件夹内创建一个新文件夹,并将其命名为testapp
。将目录更改为新创建的文件夹,并创建一个新的控制台应用程序来测试安装。请参阅以下命令和命令的结果截图:
cd /home/user/Documents/testapp
dotnet new console
这将产生以下输出:
-
您会在终端上看到.NET Core 正在创建项目和所需的文件。项目成功创建后,终端上将显示
Restore succeeded
。在testapp
文件夹中,框架将添加一个obj
文件夹,Program.cs
和testapp.csproj
文件。 -
您可以继续使用
dotnet run
命令运行控制台应用程序。该命令将在终端上显示Hello World!
之前编译和运行项目。
在 Linux 上安装和设置 Visual Studio Code
由于 Visual Studio Code 是一个跨平台编辑器,可以安装在许多 Linux OS 的变体上,逐渐添加其他 Linux 发行版的软件包。要在Ubuntu上安装 Visual Studio Code,请执行以下步骤:
-
从
code.visualstudio.com/download
下载适用于 Ubuntu 和 Debian Linux 变体的.deb
软件包。 -
从终端安装下载的文件,这将安装编辑器、
apt
存储库和签名密钥,以确保在运行系统更新命令时可以自动更新编辑器:
sudo dpkg -i <package_name>.deb
sudo apt-get install -f
- 安装成功后,您应该能够启动新安装的 Visual Studio Code 编辑器。该编辑器的外观和感觉与 Visual Studio IDE 略有相似。
探索 Visual Studio Code
成功安装 Visual Studio Code 在您的 Ubuntu 实例上后,您需要在开始使用编辑器编写代码之前进行初始环境设置:
-
从“开始”菜单启动 Visual Studio Code,并从 Visual Studio Marketplace 安装 C#扩展到编辑器。您可以通过按下Ctrl + Shift + X来启动扩展,通过“查看”菜单并单击“扩展”,或直接单击“扩展”选项卡;这将加载一个可用扩展的列表,因此单击并安装 C#扩展。
-
安装扩展后,单击“重新加载”按钮以在编辑器中激活 C#扩展:
- 打开您之前创建的控制台应用程序的文件夹;要做到这一点,单击“文件”菜单并选择“打开文件夹”,或按下Ctrl + K,Ctrl + O. 这将打开文件管理器;浏览到文件夹的路径并单击打开。这将在 Visual Studio Code 中加载项目的内容。在后台,Visual Studio Code 将尝试下载 Linux 平台所需的依赖项,包括 Linux 的 Omnisharp 和.NET Core 调试器:
- 要创建一个新项目,您可以使用编辑器的集成终端,而无需通过系统终端。单击“查看”菜单,然后选择“集成终端”。这将在编辑器中打开终端选项卡,您可以在其中输入命令来创建新项目:
- 在打开的项目中,您将看到一个通知,需要构建和调试应用程序所需的资源缺失。如果单击“是”,在资源管理器选项卡中,您可以看到一个
.vscode
树,其中添加了launch.json
和tasks.json
文件。单击Program.cs
文件以将文件加载到编辑器中。从“调试”菜单中选择“开始调试”,或按下F5运行应用程序;您应该在编辑器的调试控制台上看到Hello World!
的显示:
当您启动 Visual Studio Code 时,它会加载上次关闭时的状态,打开您上次访问的文件和文件夹。编辑器的布局易于导航和使用,并带有诸如:
-
状态栏显示您当前打开文件的信息。
-
活动栏提供了访问资源管理器视图以查看项目文件夹和文件,以及源代码控制视图以管理项目的源代码版本控制。调试视图用于查看变量、断点和与调试相关的活动,搜索视图允许您搜索文件夹和文件。扩展视图允许您查看可以安装到编辑器中的可用扩展。
-
编辑区用于编辑项目文件,允许您同时打开最多三个文件进行编辑。
-
面板区域显示不同的面板,用于输出、调试控制台、终端和问题:
查看 C# 7 的新功能
多年来,C#编程语言已经成熟;随着每个版本的发布,越来越多的语言特性和构造被添加进来。这门语言最初只是由微软内部开发,并且只能在 Windows 操作系统上运行,现在已经成为开源和跨平台。这是通过.NET Core 和语言的 7 版(7.0 和 7.1)实现的,它增加了语言的特色并改进了可用的功能。特别是语言的 7.2 版和 8.0 版的路线图承诺为语言增加更多功能。
元组增强
元组在 C#语言中的第 4 版中引入,并以简化形式使用,以提供具有两个或更多数据元素的结构,允许您创建可以返回两个或更多数据元素的方法。在 C# 7 之前,引用元组的元素是通过使用Item1,Item2,...ItemN来完成的,其中N是元组结构中元素的数量。从 C# 7 开始,元组现在支持包含字段的语义命名,引入了更清晰和更有效的创建和使用元组的方法。
您现在可以通过直接为每个成员分配一个值来创建元组。此赋值将创建一个包含元素Item1,Item2的元组:
var names = ("John", "Doe");
您还可以创建具有元组中包含的元素的语义名称的元组:
(string firstName, string lastName) names = ("John", "Doe");
元组的名称,而不是具有Item1,Item2等字段,将在编译时具有可以作为firstName
和lastName
引用的字段。
当使用 POCO 可能过于繁琐时,您可以创建自己的方法来返回具有两个或更多数据元素的元组:
private (string, string) GetNames()
{
(string firstName, string lastName) names = ("John", "Doe");
return names;
}
Out 关键字
在 C#中,参数可以按引用或值传递。当您通过引用将参数传递给方法、属性或构造函数时,参数的值将被更改,并且在方法或构造函数超出范围时所做的更改将被保留。使用out
关键字,您可以在 C#中将方法的参数作为引用传递。在 C# 7 之前,要使用out
关键字,您必须在将其作为out
参数传递给方法之前声明一个变量:
class Program
{
static void Main(string[] args)
{
string firstName, lastName;
GetNames(out firstName, out lastName);
}
private static void GetNames(out string firstName, out string lastName)
{
firstName="John";
lastName="Doe";
}
}
在 C# 7 中,您现在可以将 out 变量传递给方法,而无需先声明变量,前面的代码片段现在看起来像以下内容,这样可以防止您在分配或初始化变量之前错误地使用变量,并使代码更加清晰:
class Program
{
static void Main(string[] args)
{
GetNames(out string firstName, out string lastName);
}
private static void GetNames(out string firstName, out string lastName)
{
firstName="John";
lastName="Doe";
}
}
语言中已添加了对隐式类型输出变量的支持,允许编译器推断变量的类型:
class Program
{
static void Main(string[] args)
{
GetNames(out var firstName, out var lastName);
}
private static void GetNames(out string firstName, out string lastName)
{
firstName="John";
lastName="Doe";
}
}
Ref 局部变量和返回
C#语言一直有ref
关键字,允许您使用并返回对其他地方定义的变量的引用。C# 7 添加了另一个功能,ref
局部变量和returns
,它提高了性能,并允许您声明在较早版本的语言中不可能的辅助方法。ref
局部变量和returns
关键字有一些限制——您不能在async
方法中使用它们,也不能返回具有相同执行范围的变量的引用。
Ref 局部变量
ref
局部关键字允许您通过使用ref
关键字声明局部变量来存储引用,并在方法调用或赋值之前添加ref
关键字。例如,在以下代码中,day
字符串变量引用dayOfWeek
;更改day
的值也会更改dayOfWeek
的值,反之亦然:
string dayOfWeek = "Sunday";
ref string day = ref dayOfWeek;
Console.WriteLine($"day-{day}, dayOfWeek-{dayOfWeek}");
day = "Monday";
Console.WriteLine($"day-{day}, dayOfWeek-{dayOfWeek}");
dayOfWeek = "Tuesday";
Console.WriteLine($"day-{day}, dayOfWeek-{dayOfWeek}");
-----------------
Output:
day: Sunday
dayOfWeek: Sunday
day: Monday
dayOfWeek: Monday
day: Tuesday
dayOfWeek: Tuesday
Ref 返回
您还可以将ref
关键字用作方法的返回类型。要实现这一点,将ref
关键字添加到方法签名中,并在方法体内,在return
关键字之后添加ref
。在以下代码片段中,声明并初始化了一个字符串数组。然后,该方法将字符串数组的第五个元素作为引用返回:
public ref string GetFifthDayOfWeek()
{
string [] daysOfWeek= new string [7] {"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"};
return ref daysOfWeek[4];
}
局部函数
局部或嵌套函数允许您在另一个函数内定义一个函数。这个特性在一些编程语言中已经有很多年了,但是在 C# 7 中才刚刚引入。当您需要一个小型且在container
方法的上下文之外不可重用的函数时,这是一个理想的选择:
class Program
{
static void Main(string[] args)
{
GetNames(out var firstName, out var lastName);
void GetNames(out string firstName, out string lastName)
{
firstName="John";
lastName="Doe";
}
}
}
模式匹配
C# 7 包括模式,这是一种语言元素特性,允许您在除了对象类型之外的属性上执行方法分派。它扩展了已经在覆盖和虚拟方法中实现的语言构造,用于实现类型和数据元素的分派。在语言的 7.0 版本中,is
和switch
表达式已经更新以支持模式匹配,因此您现在可以使用这些表达式来确定感兴趣的对象是否具有特定模式。
使用is
模式表达式,您现在可以编写包含处理不相关类型元素的算法例程的代码。is
表达式现在可以与模式一起使用,除了能够测试类型之外。
引入的模式匹配可以采用三种形式:
- 类型模式:这涉及检查对象是否是某种类型,然后将对象的值提取到表达式中定义的新变量中:
public void ProcessLoan(Loan loan)
{
if(loan is CarLoan carLoan)
{
// do something
}
}
- Var 模式:创建一个与对象相同类型的新变量并赋值:
public void ProcessLoan(Loan loan)
{
if(loan is var carLoan)
{
// do something
}
}
- 常量模式:检查提供的对象是否等同于一个常量表达式:
public void ProcessLoan(Loan loan)
{
if(loan is null)
{
// do something
}
}
通过更新的 switch 表达式,您现在可以在 case 语句中使用模式和条件,并且可以在除了基本或原始类型之外的任何类型上进行 switch,同时允许您使用 when 关键字来额外指定模式的规则:
public void ProcessLoan(Loan loan)
{
switch(loan)
{
case CarLoan carLoan:
// do something
break;
case HouseLoan houseLoan when (houseLoan.IsElligible==true):
//do something
break;
case null:
//throw some custom exception
break;
default:
// do something
}
}
数字分隔符和二进制字面量
在 C# 7 中添加了一种新的语法糖,即数字分隔符。这种构造极大地提高了代码的可读性,特别是在处理 C#支持的不同数值类型的大量数字时。在 C# 7 之前,操作大数值以添加分隔符有点混乱和难以阅读。引入数字分隔符后,您现在可以使用下划线(_
)作为数字的分隔符:
var longDigit = 2_300_400_500_78;
在这个版本中还新增了二进制字面量。现在可以通过简单地在二进制值前加上0b
来创建二进制字面量:
var binaryValue = 0b11101011;
创建一个 ASP.NET MVC Core 应用程序
ASP.NET Core 提供了一种优雅的方式来构建在 Windows、Linux 和 macOS 上运行的 Web 应用程序和 API,这要归功于.NET Core 平台的工具和 SDK,这些工具和 SDK 简化了开发尖端应用程序并支持应用程序版本的并行。使用 ASP.NET Core,您的应用程序的表面积更小,这可以提高性能,因为您只需要包含运行应用程序所需的 NuGet 包。ASP.NET Core 还可以与客户端库和框架集成,允许您使用您已经熟悉的 CSS 和 JS 库来开发 Web 应用程序。
ASP.NET Core 使用 Kestrel 运行,Kestrel 是包含在 ASP.NET Core 项目模板中的 Web 服务器。Kestrel 是一个基于libuv的进程内跨平台 HTTP 服务器实现,libuv 是一个跨平台的异步 I/O 库,使构建和调试 ASP.NET Core 应用程序变得更加容易。它监听 HTTP 请求,然后将请求的详细信息和特性打包到一个HttpContext
对象中。Kestrel 可以作为独立的 Web 服务器使用,也可以与 IIS 或 Apache Web 服务器一起使用,其他 Web 服务器接收到的请求将被转发到 Kestrel,这个概念被称为反向代理。
ASP.NET MVC Core为您提供了一个可测试的框架,用于使用Model View Controller模式进行现代 Web 应用程序开发,这使您可以充分实践测试驱动开发。在 ASP.NET 2.0 中新增的是对 Razor 页面的支持,这现在是开发 ASP.NET Core Web 应用程序用户界面的推荐方法。
要创建一个新的 ASP.NET MVC Core 项目:
- 打开 Visual Studio Code,并通过选择“视图”菜单中的“集成终端”来访问集成终端面板。在终端上,运行以下命令:
cd /home/<user>/Documents/
mkdir LoanApp
cd LoanApp
dotnet new mvc
- 创建应用程序后,使用 Visual Studio Code 打开项目文件夹,并选择
Startup.cs
文件。您应该注意到屏幕顶部的通知,提示“从'LoanApp'缺少构建和调试所需的资产。是否添加?”,选择是:
- 按下F5键来构建和运行 MVC 应用程序。这告诉 Kestrel web 服务器运行该应用程序,并在计算机上启动默认浏览器,地址为
http://localhost:5000
。
摘要
.NET Core 平台虽然新,但正在迅速成熟,2.0.7 版本引入了许多功能和增强功能,简化了构建不同类型的跨平台应用程序。在本章中,我们已经对平台进行了介绍,介绍了 C# 7 的新功能,并在 Ubuntu Linux 上设置了开发环境,同时创建了我们的第一个 ASP.NET MVC Core 应用程序。
在下一章中,我们将解释要注意避免编写不可测试代码,并且我们将带领您了解可以帮助您编写可测试和高质量代码的 SOLID 原则。
第三章:编写可测试的代码
在第一章中,探索测试驱动开发,解释了编写代码以防止代码异味的陷阱。编写良好的代码本身就是一种艺术,而编写可以有效测试的代码的过程需要开发人员额外的努力和承诺,以编写可以反复测试而不费吹灰之力的干净代码。
练习 TDD 可以提高代码生产效率,鼓励编写健壮且易于维护的良好代码是事实。然而,如果参与软件项目的开发人员编写不可测试的代码,那么花在 TDD 上的时间可能是浪费的,该技术的投资回报可能无法实现。这通常可以追溯到使用糟糕的代码设计架构,以及未充分或有效地使用面向对象设计原则。
编写测试和编写主要代码一样重要。为不可测试的代码编写测试非常累人且非常困难,这就是为什么首先应该避免不可测试的代码的原因。代码之所以不可测试,可能有不同的原因,比如代码做得太多(怪兽代码),违反了单一职责原则,架构使用错误,或者面向对象设计有缺陷。
在本章中,我们将涵盖以下主题:
-
编写不可测试代码的警告信号
-
迪米特法则
-
SOLID 架构原则
-
为 ASP.NET Core MVC 设置 DI 容器
编写不可测试代码的警告信号
有效和持续的 TDD 实践可以改善编写代码的过程,使测试变得更容易,从而提高代码质量和软件应用的健壮性。然而,当项目的代码库包含不可测试的代码部分时,编写单元测试或集成测试变得极其困难,甚至几乎不可能。
当软件项目的代码库中存在不可测试的代码时,软件开发团队无法明确验证应用程序功能和特性的一致行为。为了避免这种可预防的情况,编写可测试的代码不是一个选择,而是每个重视质量软件的严肃开发团队的必须。
不可测试的代码是由于违反了已被证明和测试可以提高代码质量的常见标准、实践和原则而产生的。虽然专业素养随着良好实践和经验的反复使用而来,但有一些常见的糟糕代码设计和编写方法即使对于初学者来说也是常识,比如在不需要时使用全局变量、代码的紧耦合、硬编码依赖关系或可能在代码中发生变化的值。
在本节中,我们将讨论一些常见的反模式和陷阱,当编写代码时应该注意,因为它们可能会使为生产代码编写测试变得困难。
紧耦合
耦合是对象相互依赖或密切相关的程度。进一步解释,当LoanProcessor
类与EligibilityChecker
紧密耦合时,更改后者可能会影响前者的行为或修改其状态。
大多数不可测试的代码通常是由于不同部分的代码中存在的固有依赖关系造成的,通常是通过使用依赖关系的具体实现,导致了本应在应用程序边界上分离的关注点混合在一起。
具有紧密耦合依赖关系的单元测试代码将导致测试紧密耦合的不同对象。在单元测试期间,应该在构造函数中注入的依赖关系理想情况下应该很容易模拟,但这将是不可能的。这通常会减慢整体测试过程,因为所有依赖关系都必须在受测试的代码中构建。
在以下代码片段中,LoanProcessor
与 EligibilityChecker
紧密耦合。这是因为 EligibilityChecker
在 LoanProcessor
构造函数中使用了 new 关键字进行实例化。对 EligibilityChecker
的更改将影响 LoanProcessor
,可能导致其出现故障。此外,对 LoanProcessor
中包含的任何方法进行单元测试都将导致 EligibilityChecker
被构造:
public class LoanProcessor
{
private EligibilityChecker eligibilityChecker;
public LoanProcessor()
{
eligibilityChecker= new EligibilityChecker();
}
public void ProcessCustomerLoan(Loan loan)
{
throw new NotImplementedException();
}
}
解决 LoanProcessor
中紧密耦合的一种方法是使用依赖注入(DI)。由于 LoanProcessor
无法在隔离环境中进行测试,因为 EligibilityChecker
对象将必须在构造函数中实例化,所以可以通过构造函数将 EligibilityChecker
注入到 LoanProcessor
中:
public class LoanProcessor
{
private EligibilityChecker eligibilityChecker;
public LoanProcessor(EligibilityChecker eligibilityChecker)
{
this.eligibilityChecker= eligibilityChecker;
}
public void ProcessCustomerLoan(Loan loan)
{
bool isEligible=eligibilityChecker.CheckLoan(loan);
throw new NotImplementedException();
}
}
通过注入 EligibilityChecker
,测试 LoanProcessor
变得更容易,因为这使您可以编写一个测试,其中模拟 EligibilityChecker
的实现,从而允许您在隔离环境中测试 LoanProcessor
。
另外,可以通过 LoanProcessor
类的属性或成员注入 EligibilityChecker
,而不是通过 LoanProcessor
构造函数传递依赖项:
public class LoanProcessor
{
private EligibilityChecker eligibilityChecker;
public EligibilityChecker EligibilityCheckerObject
{
set { eligibilityChecker = value; }
}
public void ProcessCustomerLoan(Loan loan)
{
bool isEligible=eligibilityChecker.CheckLoan(eligibilityChecker);
throw new NotImplementedException();
}
}
通过构造函数或属性注入依赖后,LoanProcessor
和 EligibilityChecker
现在变得松散耦合,从而使得编写单元测试和模拟 EligibilityChecker
变得容易。
要使类松散耦合且可测试,必须确保该类不实例化其他类和对象。在类的构造函数或方法中实例化对象可能会导致无法注入模拟或虚拟对象,从而使代码无法进行测试。
怪物构造函数
要测试一个方法,您必须实例化或构造包含该方法的类。开发人员最常见的错误之一是创建我所谓的怪物构造函数,它只是一个做了太多工作或真正工作的构造函数,比如执行 I/O 操作、数据库调用、静态初始化、读取一些大文件或与外部服务建立通信。
当一个类设计有一个构造函数,用于初始化或实例化除值对象(列表、数组和字典)之外的对象时,该类在技术上具有非灵活的结构。这是糟糕的类设计,因为该类自动与其实例化的类紧密耦合,使得单元测试变得困难。具有这种设计的任何类也违反了单一责任原则,因为对象图的创建是可以委托给另一个类的责任。
在具有做大量工作的构造函数的类中测试方法会带来巨大的成本。实质上,要测试具有上述设计的类中的方法,您被迫要经历在构造函数中创建依赖对象的痛苦。如果依赖对象在构造时进行数据库调用,那么每次测试该类中的方法时,这个调用都会被重复,使得测试变得缓慢和痛苦:
public class LoanProcessor
{
private EligibilityChecker eligibilityChecker;
private CurrencyConverter currencyConverter;
public LoanProcessor()
{
eligibilityChecker= new EligibilityChecker();
currencyConverter = new CurrencyConverter();
currencyConverter.DownloadCurrentRates();
eligibilityChecker.CurrentRates= currencyConverter.Rates;
}
}
在上述代码片段中,对象图的构建是在 LoanProcessor
构造函数中完成的,这肯定会使得该类难以测试。最好的做法是拥有一个精简的构造函数,它做很少的工作,并且对其他对象的了解很少,特别是它们能做什么,但不知道它们是如何做到的。
有时开发人员使用一种测试技巧,即为一个类创建多个构造函数。其中一个构造函数将被指定为仅用于测试的构造函数。虽然使用这种方法可以使类在隔离环境中进行测试,但也存在不好的一面。例如,使用多个构造函数创建的类可能会被其他类引用,并使用做大量工作的构造函数进行实例化。这可能会使得测试这些依赖类变得非常困难。
以下代码片段说明了为了测试类而创建单独构造函数的糟糕设计:
public class LoanProcessor
{
private EligibilityChecker eligibilityChecker;
private CurrencyConverter currencyConverter;
public LoanProcessor()
{
eligibilityChecker= new EligibilityChecker();
currencyConverter = new CurrencyConverter();
currencyConverter.DownloadCurrentRates();
eligibilityChecker.CurrentRates= currencyConverter.Rates;
}
// constructor for testing
public LoanProcessor(EligibilityChecker eligibilityChecker,CurrencyConverter currencyConverter)
{
this.eligibilityChecker= eligibilityChecker;
this.currencyConverter = currencyConverter;
}
}
有一些重要的警告信号可以帮助您设计一个构造函数工作量较小的松散耦合类。避免在构造函数中使用new
操作符,以允许注入依赖对象。您应该初始化并分配通过构造函数注入的所有对象到适当的字段中。轻量级值对象的实例化也应该在构造函数中完成。
此外,应避免静态方法调用,因为静态调用无法被注入或模拟。此外,应避免在构造函数中使用迭代或条件逻辑;每次测试类时,逻辑或循环都将被执行,导致过多的开销。
在设计类时要考虑测试,不要在构造函数中创建依赖对象或协作者。当您的类需要依赖其他类时,请注入依赖项。确保只创建值对象。在代码中创建对象图时,使用工厂方法来实现。工厂方法用于创建对象。
具有多个责任的类
理想情况下,一个类应该只有一个责任。当您设计的类具有多个责任时,可能会在类之间产生交互,使得代码修改变得困难,并且几乎不可能对交互进行隔离测试。
有一些指标可以清楚地表明一个类做了太多事情并且具有多个责任。例如,当您在为一个类命名时感到困难,最终可能会在类名中使用and
这个词,这表明该类做了太多事情。
一个具有多个责任的类的另一个标志是,类中的字段仅在某些方法中使用,或者类具有仅对参数而不是类字段进行操作的静态方法。此外,当一个类具有长列表的字段或方法以及许多依赖对象传递到类构造函数中时,表示该类做了太多事情。
在以下片段中,LoanProcessor
类的依赖项已经整洁地注入到构造函数中,使其与依赖项松散耦合。然而,该类有多个改变的原因;该类既包含用于数据检索的代码,又包含业务规则处理的代码:
public class LoanProcessor
{
private EligibilityChecker eligibilityChecker;
private DbContext dbContext;
public LoanProcessor(EligibilityChecker eligibilityChecker, DbContext dbContext)
{
this.eligibilityChecker= eligibilityChecker;
this.dbContext= dbContext;
}
public double CalculateCarLoanRate(Loan loan)
{
double rate=12.5F;
bool isEligible=eligibilityChecker.IsApplicantEligible(loan);
if(isEligible)
rate=rate-loan.DiscountFactor;
return rate;
}
public List<CarLoan> GetCarLoans()
{
return dbContext.CarLoan;
}
}
为了使类易于维护并且易于测试,GetCarLoans
方法不应该在LoanProcessor
中。应该将LoanProcessor
与GetCarLoans
一起重构到数据访问层类中。
具有本节描述的特征的类可能很难进行调试和测试。新团队成员可能很难快速理解类的内部工作原理。如果您的代码库中有具有这些属性的类,建议通过识别责任并将其分离到不同的类中,并根据其责任命名类来进行重构。
静态对象
在代码中使用静态变量、方法和对象可能是有用的,因为这些允许对象在所有实例中具有相同的值,因为只创建了一个对象的副本并放入内存中。然而,测试包含静态内容的代码,特别是静态方法的代码,可能会产生测试问题,因为您无法在子类中覆盖静态方法,并且使用模拟框架来模拟静态方法是一项非常艰巨的任务:
public static class LoanProcessor
{
private static EligibilityChecker eligibilityChecker= new EligibilityChecker();
public static double CalculateCarLoanRate(Loan loan)
{
double rate=12.5F;
bool isEligible=eligibilityChecker.IsApplicantEligible(loan);
if(isEligible)
rate=rate-loan.DiscountFactor;
return rate;
}
}
当您创建维护状态的静态方法时,例如在前面片段中的LoanProcessor
中的CalculateCarLoanRate
方法,静态方法无法通过多态进行子类化或扩展。此外,静态方法无法使用接口进行定义,因此使得模拟变得不可能,因为大多数模拟框架都有效地使用接口。
迪米特法则
软件应用程序是由不同组件组成的复杂系统,这些组件进行通信以实现解决现实生活问题和业务流程自动化的整体目的。实际上,这些组件必须共存、互动,并在组件边界之间共享信息,而不会混淆不同的关注点,以促进组件的可重用性和整体系统的灵活性。
在软件编程中,技术上没有严格遵循的硬性法律。然而,已经制定了各种原则和法律,作为指导方针,可以帮助软件开发人员和从业者,促进构建具有高内聚性和松耦合性的组件的软件应用程序,以充分封装数据,并确保产生易于理解和扩展的高质量源代码,从而降低软件的维护成本。其中之一就是迪米特法则(LoD)。
LoD,也称为最少知识原则,是开发面向对象软件应用程序的重要设计方法或规则。该规则于 1987 年由 Ian Holland 在东北大学制定。通过正确理解这一原则,软件开发人员可以编写易于测试的代码,并构建具有更少或没有错误的软件应用程序。该法则的制定是:
-
每个单元只应对当前单元“密切”相关的单元有限了解。
-
每个单元只能与其朋友交谈;不要与陌生人交谈。
LoD 强调低耦合,这实际上意味着一个对象对另一个对象的了解应该很少或非常有限。将 LoD 与典型的类对象联系起来,类中的方法只应对密切相关对象的其他方法有限了解。
LoD 作为软件开发人员的启发式,以促进软件模块和组件中的信息隐藏。LoD 有两种形式——对象或动态形式和类或静态形式。
LoD 的类形式被公式化为:
类(C)的方法(M)只能向以下类的对象发送消息:
-
M 的参数类,包括 C
-
C 的实例变量
-
在 M 中创建的实例的类
-
C 的属性或字段
LoD 的对象形式被公式化为:
在 M 中,消息只能发送到以下对象:
-
M 的参数,包括封闭对象。
-
M 调用封闭对象返回的即时部分对象,包括封闭对象的属性,或者封闭对象的属性集合的元素:
public class LoanProcessor
{
private CurrencyConverter currencyConverter;
public LoanProcessor(LoanCalculator loanCalculator)
{
currencyConverter = loanCalculator.GetCurrencyConverter();
}
}
前面的代码明显违反了 LoD,这是因为LoanProcessor
实际上并不关心LoanCalculator
,因为它没有保留任何对它的引用。在代码中,LoanProcessor
已经在与LoanCalculator
进行交流,一个陌生人。这段代码实际上并不可重用,因为任何试图重用它们的类或代码都将需要CurrencyConverter
和LoanProcessor
,尽管从技术上讲,LoanCalculator
在构造函数之外并未被使用。
为了对LoanProcessor
编写单元测试,需要创建对象图。必须创建LoanCalculator
以便CurrencyConverter
可用。这会在系统中创建耦合,如果LoanCalculator
被重构,这是可能的,那么可能会导致LoanProcessor
出现故障,导致单元测试停止运行。
LoanCalculator
类可以被模拟,以便单独测试LoanProcessor
,但这有时会使测试变得难以阅读,最好避免耦合,这样可以编写灵活且易于测试的代码。
要重构前面的代码片段,并使其符合 LoD 并从类构造函数中获取其依赖项,从而消除对LoanCalculator
的额外依赖,并减少代码的耦合:
public class LoanProcessor
{
private CurrencyConverter currencyConverter;
public LoanProcessor(CurrencyConverter currencyConverter)
{
this.currencyConverter = currencyConverter;
}
}
火车失事
另一个违反 LoD 的反模式是所谓的火车失事或链式调用。这是一系列函数的链,并且当你在一行代码中追加了一系列 C#方法时就会发生。当你花时间试图弄清楚一行代码的作用时,你就会知道你写了一个火车失事的代码:
loanCalculator.
CalculateHouseLoan(loanDTO).
GetPaymentRate().
GetMaximumYearsToPay();
你可能想知道这种现象如何违反了 LoD。首先,代码缺乏可读性,不易维护。此外,代码行不可重用,因为一行代码中有三个方法调用。
这行代码可以通过减少交互和消除方法链来进行重构,以使其符合“不要和陌生人说话”的原则。这个原则解释了调用点或方法应该一次只与一个对象交互。通过消除方法链,生成的代码可以在其他地方重复使用,而不必费力理解代码的作用:
var houseLoan=loanCalculator.CalculateHouseLoan(loanDTO);
var paymentRate=houseLoan.GetPaymentRate();
var maximumYears=paymentRate.GetMaximumYearsToPay();
一个对象应该对其他对象的知识和信息有限。此外,对象中的方法应该对应用程序的对象图具有很少的认识。通过有意识的努力,使用 LoD,你可以构建松散耦合且易于维护的软件应用程序。
SOLID 架构原则
软件应用程序开发的程序和方法,从第一步到最后一步,应该简单易懂,无论是新手还是专家都能理解。这些程序,当与正确的原则结合使用时,使开发和维护软件应用程序的过程变得简单和无缝。
开发人员不时采用和使用不同的开发原则和模式,以简化复杂性并使软件应用程序代码库易于维护。其中一个原则就是 SOLID 原则。这个原则已经被证明非常有用,是每个面向对象系统的严肃程序员必须了解的。
SOLID 是开发面向对象系统的五个基本原则的首字母缩写。这五个原则是用于类设计的,表示为:
-
S:单一职责原则
-
O:开闭原则
-
L:里氏替换原则
-
I:接口隔离原则
-
D:依赖反转原则
这些原则首次被整合成 SOLID 的首字母缩写,并在 2000 年代初由罗伯特·C·马丁(通常被称为鲍勃叔叔)推广。这五个原则是用于类设计的,遵守这些原则可以帮助管理依赖关系,避免创建混乱的、到处都是依赖的僵化代码库。
对 SOLID 原则的正确理解和运用可以使软件开发人员实现非常高的内聚度,并编写易于理解和维护的高质量代码。有了 SOLID 原则,你可以编写干净的代码,构建健壮且可扩展的软件应用程序。
事实上,鲍勃叔叔澄清了 SOLID 原则不是法律或规则,而是已经观察到在几种情况下起作用的启发式。要有效地使用这些原则,你必须搜索你的代码,检查违反原则的部分,然后进行重构。
单一职责原则
单一职责原则(SRP)是五个 SOLID 原则中的第一个。该原则规定一个类在任何时候只能有一个改变的理由。这简单地意味着一个类一次只能执行一个职责或有一个责任。
软件项目的业务需求通常不是固定的。在软件项目发布之前,甚至在软件的整个生命周期中,需求会不时地发生变化,开发人员必须根据变化调整代码库。为了使软件应用程序满足其业务需求并适应变化,必须使用灵活的设计模式,并且类始终只有一个责任。
此外,重要的是要理解,当一个类有多个责任时,即使进行最微小的更改也会对整个代码库产生巨大影响。对类的更改可能会导致连锁反应,导致之前工作的功能或其他方法出现故障。例如,如果你有一个解析.csv
文件的类,同时它还调用一个 Web 服务来检索与.csv
文件解析无关的信息,那么这个类就有多个改变的原因。对 Web 服务调用的更改将影响该类,尽管这些更改与.csv
文件解析无关。
以下代码片段中的LoanCalculator
类的设计明显违反了 SRP。LoanCalculator
有两个责任——第一个是计算房屋和汽车贷款,第二个是从 XML 文件和 XML 字符串中解析贷款利率:
public class LoanCalculator
{
public CarLoan CalculateCarLoan(LoanDTO loanDTO)
{
throw new NotImplementedException();
}
public HouseLoan CalculateHouseLoan(LoanDTO loanDTO)
{
throw new NotImplementedException();
}
public List<Rate> ParseRatesFromXmlString(string xmlString)
{
throw new NotImplementedException();
}
public List<Rate> ParseRatesFromXmlFile(string xmlFile)
{
throw new NotImplementedException();
}
}
LoanCalculator
类的双重责任状态会产生一些问题。首先,该类变得非常不稳定,因为对一个责任的更改可能会影响另一个责任。例如,对要解析的 XML 内容结构的更改可能需要重写、测试和重新部署该类;尽管如此,对第二个关注点——贷款计算——并没有进行更改。
LoanCalculator
类中的混乱代码可以通过重新设计类并分离责任来修复。新设计将是将 XML 利率解析的责任移交给一个新的RateParser
类,并将贷款计算的关注点留在现有类中:
public class RateParser : IRateParser
{
public List<Rate> ParseRatesFromXml(string xmlString)
{
throw new NotImplementedException();
}
public List<Rate> ParseRatesFromXmlFile(string xmlFile)
{
throw new NotImplementedException();
}
}
通过从LoanCalculator
中提取RateParser
类,RateParser
现在可以作为LoanCalculator
中的一个依赖使用。对RateParser
中的任何方法的更改不会影响LoanCalculator
,因为它们现在处理不同的关注点,每个类只有一个改变的原因:
public class LoanCalculator
{
private IRateParser rateParser;
public LoanCalculator(IRateParser rateParser)
{
this.rateParser=rateParser;
}
public CarLoan CalculateCarLoan(LoanDTO loanDTO)
{
throw new NotImplementedException();
}
public HouseLoan CalculateCarLoan(LoanDTO loanDTO)
{
throw new NotImplementedException();
}
}
将关注点分开在代码库中创造了很大的灵活性,并允许轻松测试这两个类。通过新的设计,对RateParser
的更改不会影响LoanCalculator
,这两个类可以独立进行单元测试。
责任不应该混在一个类中。你应该避免在一个类中混淆责任,这会导致做太多事情的怪兽类。相反,如果你能想到一个改变类的理由或动机,那么它已经有了多个责任;将类分成每个只包含单一责任的类。
类似地,对以下代码片段中的LoanRepository
类的第一印象可能不会直接表明关注点混淆。但是,如果仔细检查该类,数据访问和业务逻辑代码都混在一起,这使得它违反了 SRP:
public class LoanRepository
{
private DbContext dbContext;
private IEligibilityChecker eligibilityChecker;
public LoanRepository(DbContext dbContext,IEligibilityChecker eligibilityChecker)
{
this.dbContext=dbContext;
this.eligibilityChecker= eligibilityChecker;
}
public List<CarLoan> GetCarLoans()
{
return dbContext.CarLoan;
}
public List<HouseLoan> GetHouseLoans()
{
return dbContext.HouseLoan;
}
public double CalculateCarLoanRate(CarLoan carLoan)
{
double rate=12.5F;
bool isEligible=eligibilityChecker.IsApplicantEligible(carLoan);
if(isEligible)
rate=rate-carLoan.DiscountFactor;
return rate;
}
}
这个类可以通过将计算汽车贷款利率的业务逻辑代码分离到一个新的类——LoanService
中来重构,这将允许LoanRepository
类只包含与数据层相关的代码,从而使其符合 SRP:
public class LoanService
{
private IEligibilityChecker eligibilityChecker;
public LoanService(IEligibilityChecker eligibilityChecker)
{
this.eligibilityChecker= eligibilityChecker;
}
public double CalculateCarLoanRate(CarLoan carLoan)
{
double rate=12.5F;
bool isEligible=eligibilityChecker.IsApplicantEligible(carLoan);
if(isEligible)
rate=rate-carLoan.DiscountFactor;
return rate;
}
}
通过将业务逻辑代码分离到LoanService
类中,LoanRepository
类现在只有一个依赖,即DbContext
实体框架。未来,LoanRepository
可以很容易地进行维护和测试。新的LoanService
类也符合 SRP:
public class LoanRepository
{
private DbContext dbContext;
public LoanRepository(DbContext dbContext)
{
this.dbContext=dbContext;
}
public List<CarLoan> GetCarLoans()
{
return dbContext.CarLoan;
}
public List<HouseLoan> GetHouseLoans()
{
return dbContext.HouseLoan;
}
}
当您的代码中的问题得到很好的管理时,代码库将具有高内聚性,并且将来会更加灵活、易于测试和维护。有了高内聚性,类将松散耦合,对类的更改将很少可能破坏整个系统。
开闭原则
设计和最终编写生产代码的方法应该是允许向项目的代码库添加新功能,而无需进行多次更改、更改代码库的几个部分或类,或破坏已经正常工作且状态良好的现有功能。
如果由于对类中的方法进行更改而导致必须对多个部分或模块进行更改,这表明代码设计存在问题。这就是开闭原则(OCP)所解决的问题,允许您的代码库设计灵活,以便您可以轻松进行修改和增强。
OCP 规定软件实体(如类、方法和模块)应设计为对扩展开放,但对修改关闭。这个原则可以通过继承或设计模式(如工厂、观察者和策略模式)来实现。这是指类和方法可以被设计为允许添加新功能,以供现有代码使用,而无需实际修改或更改现有代码,而是通过扩展现有代码的行为。
在 C#中,通过正确使用对象抽象,您可以拥有封闭的类,这些类对修改关闭,而类的行为可以通过派生类进行扩展。派生类是封闭类的子类。使用继承,您可以创建通过扩展其基类添加更多功能的类,而无需修改基类。
考虑以下代码片段中的LoanCalculator
类,它具有一个CalculateLoan
方法,必须能够计算传递给它的任何类型的贷款的详细信息。在不使用 OCP 的情况下,可以使用if..else if
语句来计算要求。
LoanCalculator
类具有严格的结构,当需要支持新类型时需要进行大量工作。例如,如果您打算添加更多类型的客户贷款,您必须修改CalculateLoan
方法并添加额外的else if
语句以适应新类型的贷款。LoanCalculator
违反了 OCP,因为该类不是封闭的以进行修改:
public class LoanCalculator
{
private IRateParser rateParser;
public LoanCalculator(IRateParser rateParser)
{
this.rateParser=rateParser;
}
public Loan CalculateLoan(LoanDTO loanDTO)
{
Loan loan = new Loan();
if(loanDTO.LoanType==LoanType.CarLoan)
{
loan.LoanType=LoanType.CarLoan;
loan.InterestRate=rateParser.GetRateByLoanType(LoanType.CarLoan);
// do other processing
}
else if(loanDTO.LoanType==LoanType.HouseLoan)
{
loan.LoanType=LoanType.HouseLoan;
loan.InterestRate=rateParser.GetRateByLoanType(LoanType.HouseLoan);
// do other processing
}
return loan;
}
}
为了使LoanCalculator
类对扩展开放而对修改关闭,我们可以使用继承来简化重构。 LoanCalculator
将被重构以允许从中创建子类。将LoanCalculator
作为基类将有助于创建两个派生类,HouseLoanCalculator
和CarLoanCalulator
。计算不同类型贷款的业务逻辑已从CalculateLoan
方法中移除,并在两个派生类中实现,如下面的代码片段所示:
public class LoanCalculator
{
protected IRateParser rateParser;
public LoanCalculator(IRateParser rateParser)
{
this.rateParser=rateParser;
}
public Loan CalculateLoan(LoanDTO loanDTO)
{
Loan loan = new Loan();
// do some base processing
return loan;
}
}
LoanCalculator
类中的If
条件已从CalculateLoan
方法中移除。现在,新的CarLoanCaculator
类包含了获取汽车贷款计算的逻辑:
public class CarLoanCalculator : LoanCalculator
{
public CarLoanCalculator(IRateParser rateParser) :base(rateParser)
{
base.rateParser=rateParser;
}
public override Loan CalculateLoan(LoanDTO loanDTO)
{
Loan loan = new Loan();
loan.LoanType=loanDTO.LoanType;
loan.InterestRate=rateParser.GetRateByLoanType(loanDTO.LoanType);
// do other processing
return loan
}
}
HouseLoanCalculator
类是从LoanCalculator
创建的,具有覆盖基类LoanCalculator
中的CalculateLoan
方法的CalculateLoan
方法。对HouseLoanCalculator
进行的任何更改都不会影响其基类的CalculateLoan
方法:
public class HouseLoanCalculator : LoanCalculator
{
public HouseLoanCalculator(IRateParser rateParser) :base(rateParser)
{
base.rateParser=rateParser;
}
public override Loan CalculateLoan(LoanDTO loanDTO)
{
Loan loan = new Loan();
loan.LoanType=LoanType.HouseLoan;
loan.InterestRate=rateParser.GetRateByLoanType(LoanType.HouseLoan);
// do other processing
return loan;
}
}
如果引入了新类型的贷款,比如研究生学习贷款,可以创建一个新类PostGraduateStudyLoan
来扩展LoanCalculator
并实现CalculateLoan
方法,而无需对LoanCalculator
类进行任何修改。
从技术上讲,观察 OCP 意味着您的代码中的类和方法应该对扩展开放,这意味着可以扩展类和方法以添加新的行为来支持新的或不断变化的应用程序需求。而且类和方法对于修改是封闭的,这意味着您不能对源代码进行更改。
为了使LoanCalculator
对更改开放,我们将其作为其他类型的基类派生。或者,我们可以创建一个ILoanCalculator
抽象,而不是使用经典的对象继承:
public interface ILoanCalculator
{
Loan CalculateLoan(LoanDTO loanDTO);
}
CarLoanCalculator
类现在可以被创建来实现ILoanCalculator
接口。这将需要CarLoanCalculator
类明确实现接口中定义的方法和属性。
public class CarLoanCalculator : ILoanCalculator
{
private IRateParser rateParser;
public CarLoanCalculator(IRateParser rateParser)
{
this.rateParser=rateParser;
}
public Loan CalculateLoan(LoanDTO loanDTO)
{
Loan loan = new Loan();
loan.LoanType=loanDTO.LoanType;
loan.InterestRate=rateParser.GetRateByLoanType(loanDTO.LoanType);
// do other processing
return loan
}
}
HouseLoanCalculator
类也可以被创建来实现ILoanCalculator
,通过构造函数将IRateParser
对象注入其中,类似于CarLoanCalculator
。CalculateLoan
方法可以被实现为具有计算房屋贷款所需的特定代码。通过简单地创建类并使其实现ILoanCalculator
接口,可以添加任何其他类型的贷款:
public class HouseLoanCalculator : ILoanCalculator
{
private IRateParser rateParser;
public HouseLoanCalculator (IRateParser rateParser)
{
this.rateParser=rateParser;
}
public Loan CalculateLoan(LoanDTO loanDTO)
{
Loan loan = new Loan();
loan.LoanType=loanDTO.LoanType;
loan.InterestRate=rateParser.GetRateByLoanType(loanDTO.LoanType);
// do other processing
return loan
}
}
使用 OCP,您可以创建灵活的软件应用程序,其行为可以轻松扩展,从而避免代码基础僵化且缺乏可重用性。通过适当使用 OCP,通过有效使用代码抽象和对象多态性,可以对代码基础进行更改,而无需更改许多部分,并且付出很少的努力。您真的不必重新编译代码基础来实现这一点。
Liskov 替换原则
Liskov 替换原则(LSP),有时也称为按合同设计,是 SOLID 原则中的第三个原则,最初由Barbara Liskov提出。LSP 规定,派生类或子类应该可以替换基类或超类,而无需对基类进行修改或在系统中生成任何运行时错误。
LSP 可以通过以下数学符号进一步解释——假设 S 是 T 的子集,T 的对象可以替换 S 的对象,而不会破坏系统的现有工作功能或引起任何类型的错误。
为了说明 LSP 的概念,让我们考虑一个带有Drive
方法的Car
超类。如果Car
有两个派生类,SalonCar
和JeepCar
,它们都有Drive
方法的重写实现,那么无论何时需要Car
,都应该可以使用SalonCar
和JeepCar
来替代Car
类。派生类与Car
有一个是一个的关系,因为SalonCar
是Car
,JeepCar
是Car
。
为了设计您的类并实现它们以符合 LSP,您应该确保派生类的元素是按照合同设计的。派生类的方法定义应该与基类的相似,尽管实现可能会有所不同,因为不同的业务需求。
此外,重要的是派生类的实现不违反基类或接口中实现的任何约束。当您部分实现接口或基类时,通过具有未实现的方法,您正在违反 LSP。
以下代码片段具有LoanCalculator
基类,具有CalculateLoan
方法和两个派生类,HouseLoanCalculator
和CarLoanCalculator
,它们具有CalculateLoan
方法并且可以具有不同的实现:
public class LoanCalculator
{
public Loan CalculateLoan(LoanDTO loanDTO)
{
throw new NotImplementedException();
}
}
public class HouseLoanCalculator : LoanCalculator
{
public override Loan CalculateLoan(LoanDTO loanDTO)
{
throw new NotImplementedException();
}
}
public class CarLoanCalculator : LoanCalculator
{
public override Loan CalculateLoan(LoanDTO loanDTO)
{
throw new NotImplementedException();
}
}
如果在前面的代码片段中没有违反 LSP,那么HouseLoanCalculator
和CarLoanCalculator
派生类可以在需要LoanCalculator
引用的任何地方使用。这在以下代码片段中的Main
方法中得到了证明:
public static void Main(string [] args)
{
//substituting CarLoanCalulator for LoanCalculator
RateParser rateParser = new RateParser();
LoanCalculator loanCalculator= new CarLoanCalculator(rateParser);
Loan carLoan= loanCalulator.CalculateLoan();
//substituting HouseLoanCalculator for LoanCalculator
loanCalculator= new HouseLoanCalculator(rateParser);
Loan houseLoan= loanCalulator.CalculateLoan();
Console.WriteLine($"Car Loan Interest Rate - {carLoan.InterestRate}");
Console.WriteLine($"House Loan Interest Rate - {houseLoan.InterestRate}");
}
接口隔离原则
接口是一种面向对象的编程构造,被对象用来定义它们公开的方法和属性,并促进与其他对象的交互。接口包含相关方法,具有空的方法体但没有实现。接口是面向对象编程和设计中的有用构造;它允许创建灵活且松耦合的软件应用程序。
接口隔离原则(ISP)规定接口应该是适度的,只包含所需的属性和方法的定义,客户端不应被强制实现他们不使用的接口,或依赖他们不需要的方法。
要有效地在代码库中实现 ISP,您应该倾向于创建简单而薄的接口,这些接口具有逻辑上分组在一起以解决特定业务案例的方法。通过创建薄接口,类代码中包含的方法可以轻松实现,同时保持代码库的清晰和优雅。
另一方面,如果您的接口臃肿或臃肿,其中包含类不需要的功能的方法,您更有可能违反 ISP 并在代码中创建耦合,这将导致代码库无法轻松测试。
与其拥有臃肿或臃肿的接口,不如创建两个或更多个薄接口,将方法逻辑地分组,并让您的类实现多个接口,或让接口继承其他薄接口,这种现象被称为多重继承,在 C#中得到支持。
以下片段中的IRateCalculator
接口违反了 ISP。它可以被视为一个污染的接口,因为唯一实现它的类不需要FindLender
方法,因为RateCalculator
类不需要它:
public interface IRateCalculator
{
Rate GetYearlyCarLoanRate();
Rate GetYearlyHouseLoanRate();
Lender FindLender(LoanType loanType);
}
RateCalculator
类具有GetYearlyCarLoanRate
和GetYearlyHouseLoanRate
方法,这些方法是必需的以满足类的要求。通过实现IRateCalculator
,RateCalculator
被迫为FindLender
方法实现,而这并不需要:
public class RateCalculator :IRateCalculator
{
public Rate GetYearlyCarLoanRate()
{
throw new NotImplementedException();
}
public Rate GetYearlyHouseLoanRate()
{
throw new NotImplementedException();
}
public Lender FindLender(LoanType loanType)
{
throw new NotImplementedException();
}
}
前述的IRateCalculator
可以重构为两个具有可以逻辑分组在一起的方法的连贯接口。通过小接口,可以以极大的灵活性编写代码,并且易于对实现接口的类进行单元测试:
public interface IRateCalculator
{
Rate GetYearlyCarLoanRate();
Rate GetYearlyHouseLonaRate();
}
public interface ILenderManager
{
Lender FindLender(LoanType loanType);
}
通过将IRateCalculator
重构为两个接口,RateCalculator
可以被重构以删除不需要的FindLender
方法:
public class RateCalculator :IRateCalculator
{
public Rate GetYearlyCarLoanRate()
{
throw new NotImplementedException();
}
public Rate GetYearlyHouseLonaRate()
{
throw new NotImplementedException();
}
}
在实现符合 ISP 的接口时要注意的反模式是为每个方法创建一个接口,试图创建薄接口;这可能导致创建多个接口,从而导致难以维护的代码库。
依赖反转原则
刚性或糟糕的设计可能会使软件应用程序的组件或模块的更改变得非常困难,并创建维护问题。这些不灵活的设计通常会破坏先前正常工作的功能。这可能以原则和模式的错误使用、糟糕的代码和不同组件或层的耦合形式出现,从而使维护过程变得非常困难。
当应用程序代码库中存在严格的设计时,仔细检查代码将会发现模块之间紧密耦合,使得更改变得困难。对任何模块的更改可能会导致破坏先前正常工作的另一个模块的风险。观察 SOLID 原则中的最后一个——依赖反转原则(DIP)可以消除模块之间的任何耦合,使代码库灵活且易于维护。
DIP 有两种形式,都旨在实现代码的灵活性和对象及其依赖项之间的松耦合:
-
高级模块不应依赖于低级模块;两者都应依赖于抽象
-
抽象不应依赖于细节;细节应依赖于抽象
当高级模块或实体直接耦合到低级模块时,对低级模块进行更改通常会直接影响高级模块,导致它们发生变化,产生连锁反应。在实际情况下,当对高级模块进行更改时,低级模块应该发生变化。
此外,您可以在需要类与其他类通信或发送消息的任何地方应用 DIP。DIP 倡导应用程序开发中众所周知的分层原则或关注点分离原则:
public class AuthenticationManager
{
private DbContext dbContext;
public AuthenticationManager(DbContext dbContext)
{
this.dbContext=dbContext;
}
}
在上面的代码片段中,AuthenticationManager
类代表了一个高级模块,而传递给类构造函数的DbContext
Entity Framework 是一个负责 CRUD 和数据层活动的低级模块。虽然非专业的开发人员可能不会在代码结构中看到任何问题,但它违反了 DIP。这是因为AuthenticationManager
类依赖于DbContext
类,并且对DbContext
内部代码进行更改的尝试将会传播到AuthenticationManager
,导致它发生变化,从而违反 OCP。
我们可以重构AuthenticationManager
类,使其具有良好的设计并符合 DIP。这将需要创建一个IDbContext
接口,并使DbContext
实现该接口。
public interface IDbContext
{
int SaveChanges();
void Dispose();
}
public class DbContext : IDbContext
{
public int SaveChanges()
{
throw new NotImplementedException();
}
public void Dispose()
{
throw new NotImplementedException();
}
}
AuthenticationManager
可以根据接口编码,从而打破与DbContext
的耦合或直接依赖,并且依赖于抽象。对AuthenticationManager
进行编码,使其针对IDbContext
意味着接口将被注入到AuthenticationManager
的构造函数中,或者使用属性注入:
public class AuthenticationManager
{
private IDbContext dbContext;
public AuthenticationManager(IDbContext dbContext)
{
this.dbContext=dbContext;
}
}
重构完成后,AuthenticationManager
现在使用依赖反转,并依赖于抽象—IDbContext
。将来,如果对DbContext
类进行更改,将不再影响AuthenticationManager
类,并且不会违反 OCP。
虽然通过构造函数将IDbContext
注入到AutheticationManager
中非常优雅,但IDbcontext
也可以通过公共属性注入到AuthenticationManager
中:
public class AuthenticationManager
{
private IDbContext dbContext;
private IDbContext DbContext
{
set
{
dbContext=value;
}
}
}
此外,DI 也可以通过接口注入来实现,其中对象引用是使用接口操作传递的。这简单地意味着使用接口来注入依赖项。以下代码片段解释了使用接口注入来实现依赖的概念。
IRateParser
是使用ParseRate
方法定义创建的。第二个接口IRepository
包含InjectRateParser
方法,该方法接受IRateParser
作为参数,并将注入依赖项。
public interface IRateParser
{
Rate ParseRate();
}
public interface IRepository
{
void InjectRateParser(IRateParser rateParser);
}
现在,让我们创建LoanRepository
类来实现IRepository
接口,并为InjectRateParser
创建一个代码实现,以将IRateParser
存储库作为依赖项注入到LoanRepository
类中以供代码使用:
public class LoanRepository : IRepository
{
IRateParser rateParser;
public void InjectRateParser(IRateParser rateParser)
{
this.rateParser = rateParser;
}
public float GetCheapestRate(LoanType loanType)
{
return rateParser.GetRateByLoanType(loanType);
}
}
接下来,我们可以创建IRateParser
依赖的具体实现,XmlRateParser
和RestServiceRateParser
,它们都包含了从 XML 和 REST 源解析贷款利率的ParseRate
方法的实现:
public class XmlRateParser : IRateParser
{
public Rate ParseRate()
{
// Parse rate available from xml file
throw new NotImplementedException();
}
}
public class RestServiceRateParser : IRateParser
{
public Rate ParseRate()
{
// Parse rate available from REST service
throw new NotImplementedException();
}
}
总之,我们可以使用在前面的代码片段中创建的接口和类来测试接口注入概念。创建了IRateParser
的具体对象,它被注入到LoanRepository
类中,通过IRepository
接口,并且可以使用IRateParser
接口的两种实现之一来构造它。
IRateParser rateParser = new XmlRateParser();
LoanRepository loanRepository = new LoanRepository();
((IRepository)loanRepository).InjectRateParser(rateParser);
var rate= loanRepository.GetCheapestRate();
rateParser = new RestServiceRateParser();
((IRepository)loanRepository).InjectRateParser(rateParser);
rate= loanRepository.GetCheapestRate();
在本节中描述的任何三种技术都可以有效地用于在需要时将依赖项注入到代码中。适当有效地使用 DIP 可以促进创建易于维护的松散耦合的应用程序。
为 ASP.NET Core MVC 设置 DI 容器
ASP.NET Core 的核心是 DI。该框架提供了内置的 DI 服务,允许开发人员创建松散耦合的应用程序,并防止依赖关系的实例化或构造。使用内置的 DI 服务,您的应用程序代码可以设置为使用 DI,并且依赖项可以被注入到Startup
类中的方法中。虽然默认的 DI 容器具有一些很酷的功能,但您仍然可以在 ASP.NET Core 应用程序中使用其他已知的成熟的 DI 容器。
您可以将代码配置为以两种模式使用 DI:
-
构造函数注入:类所需的接口通过类的公共构造函数传递或注入。使用私有构造函数无法进行构造函数注入,当尝试这样做时,将引发
InvalidOperationException
。在具有重载构造函数的类中,只能使用一个构造函数进行 DI。 -
属性注入:通过在类中使用公共接口属性将依赖项注入到类中。可以使用这两种模式之一来请求依赖项,这些依赖项将由 DI 容器注入。
DI 容器,也称为控制反转(IoC)容器,通常是一个可以创建具有其关联依赖项的类的类或工厂。在成功构造具有注入依赖项的类之前,项目必须设计或设置为使用 DI,并且 DI 容器必须已配置为具有依赖类型。实质上,DI 将具有包含接口到其具体类的映射的配置,并将使用此配置来解析所需依赖项的类。
ASP.NET Core 内置的 IoC 容器由IServiceProvider
接口表示,您可以使用Startup
类中的ConfigureService
方法对其进行配置。容器默认支持构造函数注入。在ConfigureService
方法中,可以定义服务和平台功能,例如 Entity Framework Core 和 ASP.NET MVC Core:
public void ConfigureServices(IServiceCollection services)
{
// Add framework services.
services.AddDbContext<ApplicationDbContext>(options => options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));
services.AddIdentity<ApplicationUser, IdentityRole>().AddEntityFrameworkStores<ApplicationDbContext>().AddDefaultTokenProviders();
services.AddMvc();
// Configured DI
services.AddTransient<ILenderManager, LenderManager >();
services.AddTransient<IRateCalculator, RateCalculator>();
}
ASP.NET Core 内置容器具有一些扩展方法,例如AddDbContext
、AddIdentity
和AddMvc
,您可以使用这些方法添加其他服务。可以使用AddTransient
方法配置应用程序依赖项,该方法接受两个泛型类型参数,第一个是接口,第二个是具体类。AddTransient
方法将接口映射到具体类,因此每次请求时都会创建服务。容器使用此配置为在 ASP.NET MVC 项目中需要它的每个对象注入接口。
用于配置服务的其他扩展方法是AddScoped
和AddSingleton
方法。AddScoped
每次请求只创建一次服务:
services.AddScoped<ILenderManager, LenderManager >();
AddSingleton
方法只在首次请求时创建服务,并将其保存在内存中,使其可供后续请求使用。您可以自行实例化单例,也可以让容器来处理:
// instantiating singleton
services.AddSingleton<ILenderManager>(new LenderManager());
// alternative way of configuring singleton service
services.AddSingleton<IRateCalculator, RateCalculator>();
ASP.NET Core 的内置 IoC 容器轻量级且功能有限,但基本上您可以在应用程序中使用它进行 DI 配置。但是,您可以将其替换为.NET 中可用的其他 IoC 容器,例如Ninject或Autofac。
使用 DI 将简化应用程序开发体验,并使您能够编写松散耦合且易于测试的代码。在典型的 ASP.NET Core MVC 应用程序中,您应该使用 DI 来处理依赖项,例如存储库、控制器、适配器和服务,并避免对服务或HttpContext
进行静态访问。
摘要
本章中使用的面向对象设计原则将帮助您掌握编写清晰、灵活、易于维护和易于测试代码所需的技能。本章中解释的 LoD 和 SOLID 原则可以作为创建松散耦合的面向对象软件应用程序的指导原则。
为了获得 TDD 周期的好处,您必须编写可测试的代码。所涵盖的 SOLID 原则描述了适当的实践,可以促进编写可轻松维护并在需要时进行增强的可测试代码。本章的最后一节着重介绍了为 ASP.NET Core MVC 应用程序设置和使用依赖注入容器。
在下一章中,我们将讨论良好单元测试的属性,.NET 生态系统中可用于创建测试的单元测试框架,以及在单元测试 ASP.NET MVC Core 项目时需要考虑的内容,我们还将深入探讨在.NET Core 平台上使用 xUnit 库进行单元测试的属性。
第四章:.NET Core 单元测试
单元测试是软件开发领域最近几年讨论最多的概念之一。单元测试并不是软件开发中的新概念;它已经存在了相当长的时间,自 Smalltalk 编程语言的早期。基于对质量和健壮软件应用程序的增加倡导,软件开发人员和测试人员已经意识到单元测试在软件产品质量改进方面所能提供的巨大好处。
通过单元测试,开发人员能够快速识别代码中的错误,从而增加开发团队对正在发布的软件产品质量的信心。单元测试主要由程序员和测试人员进行,这项活动涉及将应用程序的要求和功能分解为可以单独测试的单元。
单元测试旨在保持小型并经常运行,特别是在对代码进行更改时,以确保代码库中的工作功能不会出现故障。在进行 TDD 时,必须在编写要测试的代码之前编写单元测试。测试通常用作设计和编写代码的辅助工具,并且有效地是代码设计和规范的文档。
在本章中,我们将解释如何创建基本单元测试,并使用 xUnit 断言证明我们的单元测试结果。本章将涵盖以下主题:
-
良好单元测试的属性
-
.NET Core 和 C#的当前单元测试框架生态系统
-
ASP.NET MVC Core 的单元测试考虑因素
-
使用 xUnit 构建单元测试
-
使用 xUnit 断言证明单元测试结果
-
.NET Core 和 Windows 上可用的测试运行器
良好单元测试的属性
单元测试是编写用于测试另一段代码的代码。有时它被称为最低级别的测试,因为它用于测试应用程序的最低级别的代码。单元测试调用要测试的方法或类来验证和断言有关被测试代码的逻辑、功能和行为的假设。
单元测试的主要目的是验证被测试代码单元,以确保代码片段执行其设计用途而不是其他用途。通过单元测试,可以证明代码单元的正确性,只有当单元测试编写得好时才能实现。虽然单元测试将证明正确性并有助于发现代码中的错误,但如果被测试的代码设计和编写不佳,代码质量可能不会得到改善。
当您正确编写单元测试时,您可以在一定程度上确信您的应用程序在发布时会正确运行。通过测试套件获得的测试覆盖率,您可以获得有关代码库中方法、类和其他对象的测试写入频率的指标,并且您将获得有关测试运行频率以及测试通过或失败次数的有意义信息。
通过可用的测试指标,参与软件开发的每个利益相关者都可以获得客观信息,这些信息可用于改进软件开发过程。迭代进行单元测试可以通过测试代码中的错误来增加代码的价值,从而提高代码的可靠性和质量。这是通过对代码进行错误测试来实现的——测试会多次重复运行,这是一个被称为回归测试的概念,以便在软件应用程序成熟并且之前工作的组件出现故障时找到可能发生的错误。
可读性
单元测试的这一特性不容忽视。与被测试的代码类似,单元测试应该易于阅读和理解。编码标准和原则也适用于测试。应该避免使用魔术数字或常量等反模式,因为它们会使测试混乱并且难以阅读。在下面的测试中,整数10
是一个魔术数字,因为它直接使用。这影响了测试的可读性和清晰度:
[Fact]
public void Test_CheckPasswordLength_ShouldReturnTrue() {
string password = "civic";
bool isValid=false;
if(password.Length >=10)
isValid=true;
Assert.True(isValid);
}
有一个良好的测试结构模式可以采用,它被广泛称为三 A 模式或3A 模式——安排
,行动
和断言
——它将测试设置与验证分开。您需要确保测试所需的数据被安排好,然后是对被测试方法进行操作的代码行,最后断言被测试方法的结果是否符合预期:
[Fact]
public void Test_CompareTwoStrings_ShouldReturnTrue() {
string input = "civic";
string reversed = new string(input.Reverse().ToArray());
Assert.Equal(reversed, input);
}
虽然测试没有严格的命名约定,但您应确保测试的名称代表特定的业务需求。测试名称应包含预期的输入以及预期的输出,Test_CheckPasswordLength_ShouldReturnTrue
,这是因为除了用于测试特定应用功能之外,单元测试还是源代码的丰富文档来源。
单元独立性
单元测试基本上应该是一个单元,它应该被设计和编写成可以独立运行的形式。在这种情况下,被测试的单元,即一个方法,应该已经被编写成微妙地依赖于其他方法。如果可能的话,方法所需的数据应该通过方法参数传递,或者应该在单元内提供,它不应该需要外部请求或设置数据来进行功能。
单元测试不应该依赖于或受到任何其他测试的影响。当单元测试相互依赖时,如果其中一个测试在运行时失败,所有其他依赖测试也会失败。代码所需的所有数据应该由单元测试提供。
与第二章中讨论的单一职责原则类似,开始使用.NET Core,一个单元应该只有一个职责,任何时候只有一个关注点。单元在任何时候应该只有一个任务,以便作为一个单元进行测试。当您有一个方法实际上执行多个任务时,它只是单元的包装器,应该分解为基本单元以便进行简单的测试:
[Fact]
public void Test_DeleteLoan_ShouldReturnNull() {
loanRepository.ArchiveLoan(12);
loanRepository.DeleteLoan(12);
var loan=loanRepository.GetById(12);
Assert.Null(loan);
}
此片段中测试的问题在于同时发生了很多事情。如果测试失败,没有特定的方法来检查哪个方法调用导致了失败。为了清晰和易于维护,这个测试可以分解成不同的测试。
可重复
单元测试应该易于运行,而无需每次运行时都进行修改。实质上,测试应该准备好重复运行而无需修改。在下面的测试中,Test_DeleteLoan_ShouldReturnNull
测试方法是不可重复的,因为每次运行测试都必须进行修改。为了避免这种情况,最好模拟loanRepository
对象:
[Fact]
public void Test_DeleteLoan_ShouldReturnNull() {
loanRepository.DeleteLoan(12);
var loan=loanRepository.GetLoanById(12);
Assert.Null(loan);
}
易维护且运行速度快
单元测试应该以一种允许它们快速运行的方式编写。测试应该易于实现,任何开发团队的成员都应该能够运行它。因为软件应用是动态的,不断发展的,所以代码库的测试应该易于维护,因为被测试的底层代码发生变化。为了使测试运行更快,尽量减少依赖关系。
很多时候,大多数程序员在单元测试方面做错了,他们编写具有固有依赖关系的单元测试,这反过来使得测试运行变得更慢。一个快速的经验法则可以给你一个线索,表明你在单元测试中做错了什么,那就是测试运行得非常慢。此外,当你的单元测试调用后端服务器或执行一些繁琐的 I/O 操作时,这表明存在测试问题。
易于设置,非琐碎,并具有良好的覆盖率
单元测试应该易于设置,并且与任何直接或外部依赖项解耦。应使用适当的模拟框架对外部依赖项进行模拟。适当的对象设置应在设置方法或测试类构造函数中完成。
避免冗余代码,这可能会使测试变得混乱,并确保测试只包含与被测试方法相关的代码。此外,应该为单元或方法编写测试。例如,为类的 getter 和 setter 编写测试可能被认为太琐碎。
最后,良好的单元测试应该具有良好的代码覆盖率。测试方法中的所有执行路径都应该被覆盖,所有测试都应该有定义的可测试标准。
.NET Core 和 C#的单元测试框架生态系统
.NET Core 开发平台已经被设计为完全支持测试。这可以归因于采用的架构。这使得在.NET Core 平台上进行 TDD 相对容易且值得。
在.NET 和.NET Core 中有几个可用的单元测试框架。这些框架基本上提供了从您喜欢的 IDE、代码编辑器、专用测试运行器,或者有时通过命令行直接编写和执行单元测试的简单和灵活的方式。
.NET 平台上存在着蓬勃发展的测试框架和套件生态系统。这些框架包含各种适配器,可用于创建单元测试项目以及用于持续集成和部署。
这个框架生态系统已经被.NET Core 平台继承。这使得在.NET Core 上实践 TDD 非常容易。Visual Studio IDE 是开放且广泛的,可以更快、更容易地从 NuGet 安装测试插件和适配器,用于测试项目。
有许多免费和开源的测试框架,用于各种类型的测试。最流行的框架是 MSTest、NUnit 和 xUnit.net。
.NET Core 测试与 MSTest
Microsoft MSTest 是随 Visual Studio 一起提供的默认测试框架,由微软开发,最初是.NET 框架的一部分,但也包含在.NET Core 中。MSTest 框架用于编写负载、功能、UI 和单元测试。
MSTest 可以作为统一的应用程序平台支持,也可以用于测试各种应用程序——桌面、商店、通用 Windows 平台(UWP)和 ASP.NET Core。MSTest 作为 NuGet 软件包提供。
基于 MSTest 的单元测试项目可以添加到包含要测试的项目的现有解决方案中,按照在 Visual Studio 2017 中向解决方案添加新项目的步骤进行操作:
-
在解决方案资源管理器中右键单击现有解决方案,选择添加,然后选择新项目。或者,要从头开始创建一个新的测试项目,点击“文件”菜单,选择“新建”,然后选择“项目”。
-
在显示的对话框中,选择 Visual C#,点击.NET Core 选项。
-
选择 MSTest 测试项目(.NET Core)并为项目指定一个名称。然后点击确定:
或者,在创建新项目或向现有解决方案添加新项目时,选择“类库(.NET Core)”选项,并从 NuGet 添加对 MSTest 的引用。从 NuGet 安装以下软件包到类库项目中,使用 NuGet 软件包管理器控制台或 GUI 选项。您可以从 NuGet 软件包管理器控制台运行以下命令:
Install-Package MSTest.TestFramework
Install-Package dotnet-test-mstest
无论使用哪种方法创建 MSTest 测试项目,Visual Studio 都会自动创建一个UnitTest1
或Class1.cs
文件。您可以重命名类或删除它以创建一个新的测试类,该类将使用 MSTest 的TestClass
属性进行修饰,表示该类将包含测试方法。
实际的测试方法将使用TestMethod
属性进行修饰,将它们标记为测试,这将使得 MSTest 测试运行器可以运行这些测试。MSTest 有丰富的Assert
辅助类集合,可用于验证单元测试的期望结果:
using Microsoft.VisualStudio.TestTools.UnitTesting;
using LoanApplication.Core.Repository;
namespace MsTest
{
[TestClass]
public class LoanRepositoryTest
{
private LoanRepository loanRepository;
public LoanRepositoryTest()
{
loanRepository = new LoanRepository();
}
[TestMethod]
public void Test_GetLoanById_ShouldReturnLoan()
{
var loan = loanRepository.GetLoanById(12);
Assert.IsNotNull(loan);
}
}
}
您可以从 Visual Studio 2017 的测试资源管理器窗口中运行Test_GetLoanById_ShouldReturnLoan
测试方法。可以从测试
菜单中打开此窗口,选择窗口
,然后选择测试资源管理器
。右键单击测试并选择运行选定的测试:
您还可以从控制台运行测试。打开命令提示窗口并将目录更改为包含测试项目的文件夹,或者如果要运行解决方案中的所有测试项目,则更改为解决方案文件夹。运行dotnet test
命令。项目将被构建,同时可用的测试将被发现和执行:
使用 NUnit 进行.NET Core 测试
NUnit是一个最初从 Java 的 JUnit 移植的测试框架,可用于测试.NET 平台上所有编程语言编写的项目。目前是第 3 版,其开源测试框架是在 MIT 许可下发布的。
NUnit 测试框架包括引擎和控制台运行器。此外,它还有用于测试在移动设备上运行的应用程序的测试运行器—Xamarin Runners。NUnit 测试适配器和生成器基本上可以使使用 Visual Studio IDE 进行测试变得无缝和相对容易。
使用 NUnit 测试.NET Core 或.NET 标准应用程序需要使用 Visual Studio 测试适配器的 NUnit 3 版本。需要安装 NUnit 测试项目模板,以便能够创建 NUnit 测试项目,通常只需要进行一次。
NUnit 适配器可以通过以下步骤安装到 Visual Studio 2017 中:
-
单击
工具
菜单,然后选择扩展和更新
-
单击在线选项,并在搜索文本框中键入
nunit
以过滤可用的 NUnit 适配器 -
选择 NUnit 3 测试适配器并单击下载
这将下载适配器并将其安装为 Visual Studio 2017 的模板,您必须重新启动 Visual Studio 才能生效:
或者,您可以每次要创建测试项目时直接从 NuGet 安装 NUnit 3 测试适配器。
要将 NUnit 测试项目添加到现有解决方案中,请按照以下步骤操作:
-
在解决方案资源管理器中右键单击解决方案,选择添加,新建项目。
-
在对话框中,选择 Visual C#,然后选择.NET Core 选项。
-
选择类库(.NET Core),然后为项目指定所需的名称。
-
从 NuGet 向项目添加
NUnit3TestAdapter
和NUnit.ConsoleRunner
包:
项目设置完成后,可以编写和运行单元测试。与 MSTest 类似,NUnit 也有用于设置测试方法和测试类的属性。
TestFixture
属性用于标记一个类作为测试方法的容器。Test
属性用于修饰测试方法,并使这些方法可以从 NUnit 测试运行器中调用。
NUnit 还有其他用于一些设置和测试目的的属性。OneTimeSetup
属性用于修饰一个方法,该方法仅在运行所有子测试之前调用一次。类似的属性是SetUp
,用于修饰在运行每个测试之前调用的方法:
using LoanApplication.Core.Repository;
using NUnit;
using NUnit.Framework;
namespace MsTest
{
[TestFixture]
public class LoanRepositoryTest
{
private LoanRepository loanRepository;
[OneTimeSetUp]
public void SetupTest()
{
loanRepository = new LoanRepository();
}
[Test]
public void Test_GetLoanById_ShouldReturnLoan()
{
var loan = loanRepository.GetLoanById(12);
Assert.IsNotNull(loan);
}
}
}
测试可以从“测试资源管理器”窗口运行,类似于在 MSTest 测试项目中运行的方式。此外,可以使用dotnet test
从命令行运行测试。但是,您必须将Microsoft.NET.Test.Sdk Version 15.5.0添加为 NUnit 测试项目的引用:
xUnit.net
xUnit.net是用于测试使用 F#,VB.NET,C#和其他符合.NET 的编程语言编写的项目的.NET 平台的开源单元测试框架。xUnit.net 是由 NUnit 的第 2 版的发明者编写的,并根据 Apache 2 许可证获得许可。
xUnit.net 可用于测试传统的.NET 平台应用程序,包括控制台和 ASP.NET 应用程序,UWP 应用程序,移动设备应用程序以及包括 ASP.NET Core 的.NET Core 应用程序。
与 NUnit 或 MSTest 不同,测试类分别使用TestFixture
和TestClass
属性进行装饰,xUnit.net 测试类不需要属性装饰。该框架会自动检测测试项目或程序集中所有公共类中的所有测试方法。
此外,在 xUnit.net 中不提供测试设置和拆卸属性,可以使用无参数构造函数来设置测试对象或模拟依赖项。测试类可以实现IDisposable
接口,并在Dispose
方法中清理对象或依赖项:
public class TestClass : IDisposable
{
public TestClass()
{
// do test class dependencies and object setup
}
public void Dispose()
{
//do cleanup here
}
}
xUnit.net 支持两种主要类型的测试-事实和理论。事实是始终为真的测试;它们是没有参数的测试。理论是只有在传递特定数据集时才为真的测试;它们本质上是参数化测试。分别使用[Fact]
和[Theory]
属性来装饰事实和理论测试:
[Fact]
public void TestMethod1()
{
Assert.Equal(8, (4 * 2));
}
[Theory]
[InlineData("name")]
[InlineData("word")]
public void TestMethod2(string value)
{
Assert.Equal(4, value.Length);
}
[InlineData]
属性用于在TestMethod2
中装饰理论测试,以向测试方法提供测试数据,以在测试执行期间使用。
如何配置 xUnit.net
xUnit.net 的配置有两种类型。xUnit.net 允许配置文件为基于 JSON 或 XML。必须为要测试的每个程序集进行 xUnit.net 配置。用于 xUnit.net 的配置文件取决于被测试应用程序的开发平台,尽管 JSON 配置文件可用于所有平台。
要使用 JSON 配置文件,在 Visual Studio 2017 中创建测试项目后,应向测试项目的根文件夹添加一个新的 JSON 文件,并将其命名为xunit.runner.json
:
将文件添加到项目后,必须指示 Visual Studio 将.json
文件复制到项目的输出文件夹中,以便 xUnit 测试运行程序找到它。为此,应按照以下步骤操作:
-
从“解决方案资源管理器”中右键单击 JSON 配置文件。从菜单选项中选择“属性”,这将显示一个名为 xunit.runner.json 属性页的对话框。
-
在“属性”窗口页面上,将“复制到输出目录”选项从“从不”更改为“如果较新则复制”,然后单击“确定”按钮:
这将确保在更改时配置文件始终被复制到输出文件夹。 xUnit 中支持的配置元素放置在配置文件中的顶级 JSON 对象中,如此处所见:
{
"appDomain": "ifAvailable",
"methodDisplay": "classAndMethod",
"diagnosticMessages": false,
"internalDiagnosticMessages": false,
"maxParallelThreads": 8
}
使用支持 JSON 的 Visual Studio 版本时,它将根据配置文件名称自动检测模式。此外,在编辑xunit.runner.json
文件时,Visual Studio IntelliSense 中将提供上下文帮助。此表中解释了各种配置元素及其可接受的值:
键 | 值 |
---|---|
appDomain |
appDomain 配置元素是enum JSON 模式类型,可以采用三个值来确定是否使用应用程序域——ifAvailable 、required 和denied 。应用程序域仅由桌面运行器使用,并且将被非桌面运行器忽略。默认值应始终为ifAvailable ,表示如果可用应该使用应用程序域。当设置为required 时,将需要使用应用程序域,如果设置为denied ,将不使用应用程序域。 |
diagnosticMessages |
diagnosticMessages 配置元素是boolean JSON 模式类型,如果要在测试发现和执行期间启用诊断消息,应将其设置为true 。 |
internalDiagnosticMessages |
internalDiagnosticMessages 配置元素是boolean JSON 模式类型,如果要在测试发现和执行期间启用内部诊断消息,应将其设置为true 。 |
longRunningTestSeconds |
longRunningTestSeconds 配置元素是integer JSON 模式类型。如果要启用长时间运行的测试,应将此值设置为正整数;将值设置为0 会禁用该配置。您应该启用diagnosticMessages 以获取长时间运行测试的通知。 |
maxParallelThreads |
maxParallelThreads 配置元素是integer JSON 模式类型。将值设置为要在并行化时使用的最大线程数。将值设置为0 将保持默认行为,即计算机上的逻辑处理器数量。设置为-1 意味着您不希望限制用于测试并行化的线程数。 |
methodDisplay |
methodDisplay 配置元素是enum JSON 模式类型。当设置为method 时,显示名称将是方法,不包括类名。将值设置为classAndMethod ,这是默认值,表示将使用默认显示名称,即类名和方法名。 |
parallelizeAssembly |
parallelizeAssembly 配置元素是boolean JSON 模式类型。将值设置为true 将使测试程序集与其他程序集并行化。 |
parallelizeTestCollections |
parallelizeTestCollections 配置元素是boolean JSON 模式类型。将值设置为 true 将使测试在程序集中并行运行,这允许不同测试集中的测试并行运行。同一测试集中的测试仍将按顺序运行。将其设置为false 将禁用测试程序集中的并行化。 |
preEnumerateTheories |
preEnumerateTheories 配置元素是boolean JSON 模式类型,如果要预先枚举理论以确保每个理论数据行都有一个单独的测试用例,应将其设置为true 。当设置为false 时,将返回每个理论的单个测试用例,而不会提前枚举数据。 |
shadowCopy |
shadowCopy 配置元素是boolean JSON 模式类型,如果要在不同应用程序域中运行测试时启用影子复制,应将其设置为true 。如果测试在没有应用程序域的情况下运行,则将忽略此配置元素。 |
xUnit.net 用于桌面和 PCL 测试项目的另一个配置文件选项是 XML 配置。如果测试项目尚未具有App.Config
文件,则应将其添加到测试项目中。
在App.Config
文件的appSettings
部分下,您可以添加配置元素及其值。在使用 XML 配置文件时,必须在前面表中解释的配置元素后面添加 xUnit。例如,JSON 配置文件中的appDomain
元素将写为xunit.appDomain
:
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<appSettings>
<add key="xunit.appDomain" value="ifAvailable"/>
<add key="xunit.diagnosticMessages" value="false"/>
</appSettings>
</configuration>
xUnit.net 测试运行器
在 xUnit.net 中,有两个负责运行使用该框架编写的单元测试的角色——xUnit.net 运行器和测试框架。测试运行器是一个程序,也可以是搜索程序集中的测试并激活发现的测试的第三方插件。xUnit.net 测试运行器依赖于xunit.runner.utility
库来发现和执行测试。
测试框架是实现测试发现和执行的代码。测试框架将发现的测试链接到xunit.core.dll
和xunit.execution.dll
库。这些库与单元测试一起存在。xunit.abstractions.dll
是 xUnit.net 的另一个有用的库,其中包含测试运行器和测试框架在通信中使用的抽象。
测试并行
测试并行化是在 xUnit.net 的 2.0 版本中引入的。这个功能允许开发人员并行运行多个测试。测试并行化是必要的,因为大型代码库通常有数千个测试运行,需要多次运行。
这些代码库有大量的测试,因为需要确保功能代码的工作正常且没有问题。它们还利用了现在可用的超快计算资源来运行并行测试,这要归功于计算机硬件技术的进步。
您可以编写使用并行化的测试,并利用计算机上可用的核心,从而使测试运行更快,或者让 xUnit.net 并行运行多个测试。通常情况下,后者更受欢迎,这可以确保测试以计算机运行它们的速度运行。在 xUnit.net 中,测试并行可以在框架级别进行,其中框架支持在同一程序集中并行运行多个测试,或者在测试运行器中进行并行化,其中运行器可以并行运行多个测试程序集。
测试是使用测试集合并行运行的。每个测试类都是一个测试集合,测试集合内的测试不会相互并行运行。例如,如果运行LoanCalculatorTest
中的测试,测试运行器将按顺序运行类中的两个测试,因为它们属于同一个测试集合:
public class LoanCalculatorTest
{
[Fact]
public void TestCalculateLoan()
{
Assert.Equal(16, (4*4));
}
[Fact]
public void TestCalculateRate()
{
Assert.Equal(12, (4*3));
}
}
不同的测试类中的测试可以并行运行,因为它们属于不同的测试集合。让我们修改LoanCalculatorTest
,将TestCalculateRate
测试方法放入一个单独的测试类RateCalculatorTest
中:
public class LoanCalculatorTest
{
[Fact]
public void TestCalculateLoan()
{
Assert.Equal(16, (4*4));
}
}
public class RateCalculatorTest
{
[Fact]
public void TestCalculateRate()
{
Assert.Equal(12, (4*3));
}
}
如果我们运行测试,运行TestCalculateLoan
和TestCalculateRate
的总时间将会减少,因为它们位于不同的测试类中,这将使它们位于不同的测试集合中。此外,从测试资源管理器窗口,您可以观察到用于标记两个测试正在运行的图标:
不同的测试类中的测试可以配置为不并行运行。这可以通过使用相同名称的Collection
属性对类进行装饰来实现。如果将Collection
属性添加到LoanCalculatorTest
和RateCalculatorTest
中:
[Collection("Do not run in parallel")]
public class LoanCalculatorTest
{
[Fact]
public void TestCalculateLoan()
{
Assert.Equal(16, (4*4));
}
}
[Collection("Do not run in parallel")]
public class RateCalculatorTest
{
[Fact]
public void TestCalculateRate()
{
Assert.Equal(12, (4*3));
}
}
LoanCalculatorTest
和RateCalculatorTest
类中的测试不会并行运行,因为这些类基于属性装饰属于同一个测试集合。
ASP.NET MVC Core 的单元测试考虑
ASP.NET Core MVC 开发范式将 Web 应用程序分解为三个不同的部分——Model
、View
和Controller
,符合 MVC 架构模式的原则。Model-View-Controller(MVC)模式有助于创建易于测试和维护的 Web 应用程序,并且具有明确的关注点和边界分离。
MVC 模式提供了清晰的演示逻辑和业务逻辑之间的分离,易于扩展和维护。它最初是为桌面应用程序设计的,但后来在 Web 应用程序中得到了广泛的使用和流行。
ASP.NET Core MVC 项目可以以与测试其他类型的 .NET Core 项目相同的方式进行测试。ASP.NET Core 支持对控制器类、razor 页面、页面模型、业务逻辑和应用程序数据访问层进行单元测试。为了构建健壮的 MVC 应用程序,各种应用程序组件必须在隔离环境中进行测试,并在集成后进行测试。
控制器单元测试
ASP.NET Core MVC 控制器类处理用户交互,这转化为浏览器上的请求。控制器获取适当的模型并选择要呈现的视图,以显示用户界面。控制器从视图中读取用户的输入数据、事件和交互,并将其传递给模型。控制器验证来自视图的输入,然后执行修改数据模型状态的业务操作。
Controller
类应该轻量级,并包含渲染视图所需的最小逻辑,以便进行简单的测试和维护。控制器应该验证模型的状态并确定有效性,调用执行业务逻辑验证和管理数据持久性的适当代码,然后向用户显示适当的视图。
在对 Controller
类进行单元测试时,主要目的是在隔离环境中测试控制器动作方法的行为,这应该在不混淆测试与其他重要的 MVC 构造(如模型绑定、路由、过滤器和其他自定义控制器实用对象)的情况下进行。这些其他构造(如果是自定义编写的)应该以不同的方式进行单元测试,并在集成测试中与控制器一起进行整体测试。
审查 LoanApplication
项目的 HomeController
类,Controller
类包含在 Visual Studio 中创建项目时添加的四个动作方法:
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using LoanApplication.Models;
namespace LoanApplication.Controllers
{
public class HomeController : Controller
{
public IActionResult Index()
{
return View();
}
public IActionResult About()
{
ViewData["Message"] = "Your application description page.";
return View();
}
}
}
HomeController
类当前包含具有返回视图的基本逻辑的动作方法。为了对 MVC 项目进行单元测试,应向解决方案添加一个新的 xUnit.net 测试项目,以便将测试与实际项目代码分开。将 HomeControllerTest
测试类添加到新创建的测试项目中。
将要编写的测试方法将验证 HomeController
类的 Index
和 About
动作方法返回的 viewResult
对象:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using LoanApplication.Controllers;
using Xunit;
namespace LoanApplication.Tests.Unit.Controller
{
public class HomeControllerTest
{
[Fact]
public void TestIndex()
{
var homeController = new HomeController();
var result = homeController.Index();
var viewResult = Assert.IsType<ViewResult>(result);
}
[Fact]
public void TestAbout()
{
var homeController = new HomeController();
var result = homeController.About();
var viewResult = Assert.IsType<ViewResult>(result);
}
}
}
在前面的控制器测试中编写的测试是基本的和非常简单的。为了进一步演示控制器单元测试,可以更新 Controller
类代码以支持依赖注入,这将允许通过对象模拟来测试方法。此外,通过使用 AddModelError
来添加错误,可以测试无效的模型状态:
public class HomeController : Controller
{
private ILoanRepository loanRepository;
public HomeController(ILoanRepository loanRepository)
{
this.loanRepository = loanRepository;
}
public IActionResult Index()
{
var loanTypes=loanRepository.GetLoanTypes();
ViewData["LoanTypes"]=loanTypes;
return View();
}
}
ILoanRepository
通过类构造函数注入到 HomeController
中,在测试类中,ILoanRepository
将使用 Moq 框架进行模拟。在 TestIndex
测试方法中,使用 LoanType
列表设置了 HomeController
类中 Index
方法所需的模拟对象:
public class HomeControllerTest
{
private Mock<ILoanRepository> loanRepository;
private HomeController homeController;
public HomeControllerTest()
{
loanRepository = new Mock<ILoanRepository>();
loanRepository.Setup(x => x.GetLoanTypes()).Returns(GetLoanTypes());
homeController = new HomeController(loanRepository.Object);
}
[Fact]
public void TestIndex()
{
var result = homeController.Index();
var viewResult = Assert.IsType<ViewResult>(result);
var loanTypes = Assert.IsAssignableFrom<IEnumerable<LoanType>>(viewResult.ViewData["LoanTypes"]);
Assert.Equal(2, loanTypes.Count());
}
private List<LoanType> GetLoanTypes()
{
var loanTypes = new List<LoanType>();
loanTypes.Add(new LoanType()
{
Id = 1,
Name = "Car Loan"
});
loanTypes.Add(new LoanType()
{
Id = 2,
Name = "House Loan"
});
return loanTypes;
}
}
razor 页面单元测试
在 ASP.NET MVC 中,视图是用于呈现 Web 应用程序用户界面的组件。视图以适当且易于理解的输出格式(如 HTML、XML、XHTML 或 JSON)呈现模型中包含的信息。视图根据对模型执行的更新向用户生成输出。
Razor 页面使得在页面上编写功能相对容易。Razor 页面类似于 Razor 视图,但增加了@page
指令。@page
指令必须是页面中的第一个指令,它会自动将文件转换为 MVC 操作,处理请求而无需经过控制器。
在 ASP.NET Core 中,可以测试 Razor 页面,以确保它们在隔离和集成应用程序中正常工作。Razor 页面测试可以涉及测试数据访问层代码、页面组件和页面模型。
以下代码片段显示了一个单元测试,用于验证页面模型是否正确重定向:
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Routing;
using Xunit;
public class ViewTest
{
[Fact]
public void TestResultView()
{
var httpContext = new DefaultHttpContext();
var modelState = new ModelStateDictionary();
var actionContext = new ActionContext(httpContext, new RouteData(), new PageActionDescriptor(), modelState);
var modelMetadataProvider = new EmptyModelMetadataProvider();
var viewData = new ViewDataDictionary(modelMetadataProvider, modelState);
var pageContext = new PageContext(actionContext);
pageContext.ViewData = viewData;
var pageModel = new ResultModel();
pageModel.PageContext = pageContext;
pageModel.Url = new UrlHelper(actionContext);
var result = pageModel.RedirectToPage();
Assert.IsType<RedirectToPageResult>(result);
}
}
public class ResultModel : PageModel
{
public string Message { get; set; }
}
使用 xUnit 构建单元测试
与应用程序代码库结构化方式类似,以便易于阅读和有效地维护源代码,单元测试也应该被结构化。这是为了便于维护和使用 Visual Studio IDE 中的测试运行器快速运行测试。
测试用例是包含测试方法的测试类。通常,每个被测试类都有一个测试类。开发人员在测试中构建测试的另一种常见做法是为每个被测试的方法创建一个嵌套类,或者为被测试的类创建一个基类测试类,为每个被测试的方法创建一个子类。此外,还有每个功能一个测试类的方法,其中所有共同验证应用程序功能的测试方法都分组在一个测试用例中。
这些测试结构方法促进了 DRY 原则,并在编写测试时实现了代码的可重用性。没有一种方法适用于所有目的,选择特定的方法应该基于应用程序开发周围的情况,并在与团队成员进行有效沟通后进行。
选择每个测试一个类或每个方法一个类的路线取决于个人偏好,有时也取决于团队合作时的惯例或协议,每种方法都有其利弊。当您使用每个测试一个类的方法时,您会在测试类中为被测试的类中的方法编写测试,而不是每个方法一个类的方法,其中您只在类中编写一个与被测试方法相关的测试,尽管有时可能会在类中编写多个测试,只要它们与方法相关即可:
public class HomeControllerTest
{
private Mock<ILoanRepository> loanRepository;
private HomeController homeController;
public HomeControllerTest()
{
loanRepository = new Mock<ILoanRepository>();
loanRepository.Setup(x => x.GetLoanTypes()).Returns(GetLoanTypes());
homeController = new HomeController(loanRepository.Object);
}
private List<LoanType> GetLoanTypes()
{
var loanTypes = new List<LoanType>();
loanTypes.Add(new LoanType()
{
Id = 1,
Name = "Car Loan"
});
loanTypes.Add(new LoanType()
{
Id = 2,
Name = "House Loan"
});
return loanTypes;
}
}
将创建两个测试类IndexMethod
和AboutMethod
。这两个类都将扩展HomeControllerTest
类,并将分别拥有一个方法,遵循每个测试类一个方法的单元测试方法:
public class IndexMethod :HomeControllerTest
{
[Fact]
public void TestIndex()
{
var result = homeController.Index();
var viewResult = Assert.IsType<ViewResult>(result);
var loanTypes = Assert.IsAssignableFrom<IEnumerable<LoanType>>(viewResult.ViewData["LoanTypes"]);
Assert.Equal(3, loanTypes.Count());
}
}
public class AboutMethod : HomeControllerTest
{
[Fact]
public void TestAbout()
{
var result = homeController.About();
var viewResult = Assert.IsType<ViewResult>(result);
}
}
重要的是要注意,给测试用例和测试方法赋予有意义和描述性的名称可以在使它们有意义和易于理解方面起到很大作用。测试方法的名称应包含被测试的方法或功能的名称。可选地,可以在测试方法的名称中进一步描述性地添加预期结果,以Should
为前缀:
[Fact]
public void TestAbout_ShouldReturnViewResult()
{
var result = homeController.About();
var viewResult = Assert.IsType<ViewResult>(result);
}
xUnit.net 共享测试上下文
测试上下文设置是在测试类构造函数中完成的,因为测试设置在 xUnit 中不适用。对于每个测试,xUnit 会创建测试类的新实例,这意味着类构造函数中的代码将为每个测试运行。
往往,单元测试类希望共享测试上下文,因为创建和清理测试上下文可能很昂贵。xUnit 提供了三种方法来实现这一点:
-
构造函数和 dispose:共享设置或清理代码,而无需共享对象实例
-
类装置:在单个类中跨测试共享对象实例
-
集合装置:在多个测试类之间共享对象实例
当您希望每个测试类中的每个测试都有一个新的测试上下文时,您应该使用构造函数和 dispose。在下面的代码中,上下文对象将为LoanModuleTest
类中的每个测试方法构造和处理:
public class LoanModuleTest : IDisposable
{
public LoanAppContext Context { get; private set; }
public LoanModuleTest()
{
Context = new LoanAppContext();
}
public void Dispose()
{
Context=null;
}
[Fact]
public void TestSaveLoan_ShouldReturnTrue()
{
Loan loan= new Loan{Description = "Car Loan"};
Context.Loan.Add(loan);
var isSaved=Context.Save();
Assert.True(isSaved);
}
}
当您打算创建将在类中的所有测试之间共享的测试上下文,并在所有测试运行完成后进行清理时,可以使用类装置方法。要使用类装置,您必须创建一个具有包含要共享的对象代码的构造函数的装置类。测试类应该实现IClassFixture<>
,并且您应该将装置类作为测试类的构造函数参数添加:
public class EFCoreFixture : IDisposable
{
public LoanAppContext Context { get; private set; }
public EFCoreFixture()
{
Context = new LoanAppContext();
}
public void Dispose()
{
Context=null;
}
}
以下片段中的LoanModuleTest
类实现了IClassFixture
,并将EFCoreFixture
作为参数传递。EFCoreFixture
被注入到测试类构造函数中:
public class LoanModuleTest : IClassFixture<EFCoreFixture>
{
EFCoreFixture efCoreFixture;
public LoanModuleTest(EFCoreFixture efCoreFixture)
{
this.efCoreFixture = efCoreFixture;
}
[Fact]
public void TestSaveLoan_ShouldReturnTrue()
{
// test to persist using EF Core context
}
}
与类装置类似,集合装置用于创建在多个类中共享的测试上下文。测试上下文的创建将一次性完成所有测试类,并且如果实现了清理,则将在测试类中的所有测试运行完成后执行。
使用集合装置:
-
创建一个与类装置类似的构造函数的装置类。
-
如果应该进行代码清理,则可以在装置类上实现
IDisposable
,这将放在Dispose
方法中:
public class EFCoreFixture : IDisposable
{
public LoanAppContext Context { get; private set; }
public EFCoreFixture()
{
Context = new LoanAppContext();
}
public void Dispose()
{
Context=null;
}
}
- 将创建一个定义类,该类将没有代码,并添加
ICollectionFixture<>
,因为其目的是定义集合定义。使用[CollectionDefinition]
属性装饰类,并为测试集合指定名称:
[CollectionDefinition("Context collection")]
public class ContextCollection : ICollectionFixture<EFCoreFixture>
{
}
-
向测试类添加
[Collection]
属性,并使用先前用于集合定义类属性的名称。 -
如果测试类需要实例化的装置,则添加一个以装置为参数的构造函数:
[Collection("Context collection")]
public class LoanModuleTest
{
EFCoreFixture efCoreFixture;
public LoanModuleTest(EFCoreFixture efCoreFixture)
{
this.efCoreFixture = efCoreFixture;
}
[Fact]
public void TestSaveLoan_ShouldReturnTrue()
{
// test to persist using EF Core context
}
}
[Collection("Context collection")]
public class RateModuleTest
{
EFCoreFixture efCoreFixture;
public RateModuleTest(EFCoreFixture efCoreFixture)
{
this.efCoreFixture = efCoreFixture;
}
[Fact]
public void TestUpdateRate_ShouldReturnTrue()
{
// test to persist using EF Core context
}
}
使用 Visual Studio 2017 企业版进行实时单元测试
Visual Studio 2017 企业版具有实时单元测试功能,可以自动运行受您对代码库所做更改影响的测试。测试在后台运行,并且结果在 Visual Studio 中呈现。这是一个很酷的 IDE 功能,可以为您对项目源代码所做的更改提供即时反馈。
Visual Studio 2017 企业版目前支持 NUnit、MSTest 和 xUnit 的实时单元测试。可以从工具菜单配置实时单元测试——从顶级菜单选择选项,并在选项对话框的左窗格中选择实时单元测试。可以从选项对话框调整可用的实时单元测试配置选项:
可以通过选择实时单元测试并选择开始来从测试菜单启用实时单元测试:
启用实时单元测试后,实时单元测试菜单上的其他可用选项将显示。除了开始,还将有暂停、停止和重置清理。菜单功能在此处描述:
-
暂停:这暂时暂停实时单元测试,保留单元测试数据,但隐藏测试覆盖
visualization.rk
以赶上在暂停时所做的所有编辑,并相应地更新图标 -
停止:停止实时单元测试并删除所有收集的单元测试数据
-
重置清理:通过停止并重新启动来重新启动实时单元测试
-
选项:打开选项对话框以配置实时单元测试
在下面的屏幕截图中,可以在启用实时单元测试时看到覆盖可视化。每行代码都会更新并用绿色、红色和蓝色装饰,以指示该行代码是由通过的测试、失败的测试覆盖还是未被任何测试覆盖的:
使用 xUnit.net 断言证明单元测试结果
xUnit.net 断言验证测试方法的行为。断言验证了预期结果应为真的条件。当断言失败时,当前测试的执行将终止,并抛出异常。以下表格解释了 xUnit.net 中可用的断言:
断言 | 描述 |
---|---|
相等 |
验证对象是否等于另一个对象 |
NotEqual |
验证对象不等于另一个对象 |
相同 |
验证两个对象是否是相同类型的 |
NotSame |
验证两个对象不是相同类型的 |
包含 |
是一个重载的断言/方法,验证字符串包含给定的子字符串或集合包含对象 |
DoesNotContain |
是一个重载的断言/方法,验证字符串不包含给定的子字符串或集合不包含对象 |
DoesNotThrow |
验证代码不会抛出异常 |
InRange |
验证值在给定的包容范围内 |
IsAssignableFrom |
验证对象是否是给定类型或派生类型的 |
空 |
验证集合为空 |
NotEmpty |
验证集合不为空 |
假 |
验证表达式是否为假 |
真 |
验证表达式是否为真 |
IsType<T> |
验证对象是否是给定类型的 |
IsNotType<T> |
验证对象不是给定类型的 |
空 |
验证对象引用是否为空 |
NotNull |
验证对象引用不为空 |
NotInRange |
验证值不在给定的包容范围内 |
Throws<T> |
验证代码是否抛出精确异常 |
以下代码片段使用了前面表格中描述的一些 xUnit.net 断言方法。Assertions
单元测试方法展示了在 xUnit.net 中进行单元测试时如何使用断言方法来验证方法的行为:
[Fact]
public void Assertions()
{
Assert.Equal(8 , (4*2));
Assert.NotEqual(6, (4 * 2));
List<string> list = new List<String> { "Rick", "John" };
Assert.Contains("John", list);
Assert.DoesNotContain("Dani", list);
Assert.Empty(new List<String>());
Assert.NotEmpty(list);
Assert.False(false);
Assert.True(true);
Assert.NotNull(list);
Assert.Null(null);
}
在.NET Core 和 Windows 上可用的测试运行器
.NET 平台有一个庞大的测试运行器生态系统,可以与流行的测试平台 NUnit、MSTest 和 xUnit 一起使用。测试框架都有随附的测试运行器,可以促进测试的顺利运行。此外,还有几个开源和商业测试运行器可以与可用的测试平台一起使用,其中之一就是 ReSharper。
ReSharper
ReSharper是 JetBrains 开发的.NET 开发人员的 Visual Studio 扩展。它的测试运行器是.NET 平台上可用的测试运行器中最受欢迎的,ReSharper 生产工具提供了增强程序员生产力的其他功能。它有一个单元测试运行器,可以帮助您基于 xUnit.net、NUnit、MSTest 和其他几个测试框架运行和调试单元测试。
ReShaper 可以检测到.NET 和.NET Core 平台上使用的测试框架编写的测试。ReSharper 在编辑器中添加图标,可以单击以调试或运行测试:
ReSharper 使用Unit Test Sessions窗口运行单元测试。ReSharper 的单元测试会话窗口允许您并行运行任意数量的单元测试会话,彼此独立。但是在调试模式下只能运行一个会话。
您可以使用单元测试树来过滤测试,这样可以获得测试的结构。它显示了哪些测试失败、通过或尚未运行。此外,通过双击测试,您可以直接导航到源代码:
摘要
单元测试可以提高代码的质量和应用程序的整体质量。这些测试也可以作为源代码的丰富评论和文档。创建高质量的单元测试是一个应该有意识学习的技能,遵循本章讨论的准则。
在本章中,讨论了良好单元测试的属性。我们还广泛讨论了使用 xUnit.net 框架中可用的测试功能的单元测试程序。解释了 Visual Studio 2017 中的实时单元测试功能,并使用 xUnit.net 的Fact
属性,使用断言来创建基本的单元测试。
在下一章中,我们将探讨数据驱动的单元测试,这是单元测试的另一个重要方面,它可以方便地使用来自不同来源的数据,比如来自数据库或 CSV 文件,来执行单元测试。这是通过 xUnit.net 的Theory
属性实现的。
第五章:数据驱动单元测试
在上一章中,我们讨论了良好单元测试的属性,以及 xUnit.net 支持的两种测试类型Fact和Theory。此外,我们还通过 xUnit.net 单元测试框架中可用的丰富测试断言集合创建了单元测试。
为软件项目编写的单元测试应该从开发阶段开始反复运行,在部署期间,维护期间,以及在项目的整个生命周期中都应该有效地运行。通常情况下,这些测试应该在不同的数据输入上运行相同的执行步骤,而测试和被测试的代码都应该在不同的数据输入下表现出一致的行为。
通过使用不同的数据集运行测试可以通过创建或复制具有相似步骤的现有测试来实现。这种方法的问题在于维护,因为必须在各种复制的测试中影响测试逻辑的更改。xUnit.net 通过其数据驱动单元测试功能解决了这一挑战,称为theories,它允许在不同的测试数据集上运行测试。
数据驱动单元测试,也可以称为 xUnit.net 中的数据驱动测试自动化,是用Theory
属性装饰的测试,并将数据作为参数传递给这些测试。传递给数据驱动单元测试的数据可以来自各种来源,可以通过使用InlineData
属性进行内联。数据也可以来自特定的数据源,例如从平面文件、Web 服务或数据库中获取数据。
在第四章中解释的示例数据驱动单元测试使用了内联方法。还有其他属性可以用于向测试提供数据,如MemberData
和ClassData
。
在本章中,我们将通过使用 xUnit.net 框架创建数据驱动单元测试,并涵盖以下主题:
-
数据驱动单元测试的好处
-
用于创建数据驱动测试的 xUnit.net
Theory
属性 -
内联数据驱动单元测试
-
属性数据驱动单元测试
-
整合来自其他来源的数据
数据驱动单元测试的好处
数据驱动单元测试是一个概念,因为它能够使用不同的数据集执行测试,所以它能够对代码行为提供深入的见解。通过数据驱动单元测试获得的见解可以帮助我们对应用程序开发方法做出明智的决策,并且可以识别出需要改进的潜在领域。可以从数据单元测试的报告和代码覆盖率中制定策略,这些策略可以后来用于重构具有潜在性能问题和应用程序逻辑中的错误的代码。
数据驱动单元测试的一些好处在以下部分进行了解释。
测试简洁性
通过数据驱动测试,可以更容易地减少冗余,同时保持全面的测试覆盖。这是因为可以避免测试代码的重复。传统上需要为不同数据集重复测试的测试现在可以用于不同的数据集。当存在具有相似结构但具有不同数据的测试时,这表明可以将这些测试重构为数据驱动测试。
让我们在以下片段中回顾CarLoanCalculator
类和相应的LoanCalculatorTest
测试类。与传统的编写测试方法相比,这将为我们提供宝贵的见解,说明为什么数据驱动测试可以简化测试,同时在编写代码时提供简洁性。
CarLoanCalculator
扩展了LoanCalculator
类,覆盖了CalculateLoan
方法,执行与汽车贷款相关的计算,并返回一个Loan
对象,该对象将使用 xUnit.net 断言进行验证:
public class CarLoanCalculator : LoanCalculator
{
public CarLoanCalculator(RateParser rateParser)
{
base.rateParser=rateParser;
}
public override Loan CalculateLoan(LoanDTO loanDTO)
{
Loan loan = new Loan();
loan.LoanType=loanDTO.LoanType;
loan.InterestRate=rateParser.GetRateByLoanType(loanDTO.LoanType, loanDTO.LocationType, loanDTO.JobType);
// do other processing
return loan
}
}
为了验证CarLoanCalculator
类的一致行为,将使用以下测试场景验证CalculateLoan
方法返回的Loan
对象,当方法参数LoanDTO
具有不同的LoanType
、LocationType
和JobType
组合时。CarLoanCalculatorTest
类中的Test_CalculateLoan_ShouldReturnLoan
测试方法验证了描述的每个场景:
public class CarLoanCalculatorTest
{
private CarLoanCalculator carLoanCalculator;
public CarLoanCalculatorTest()
{
RateParser rateParser= new RateParser();
this.carLoanCalculator=new CarLoanCalculator(rateParser);
}
[Fact]
public void Test_CalculateLoan_ShouldReturnLoan()
{
// first scenario
LoanDTO loanDTO1 = new LoanDTO();
loanDTO1.LoanType=LoanType.CarLoan;
loanDTO1.LocationType=LocationType.Location1;
loanDTO1.JobType=JobType.Professional
Loan loan1=carLoanCalculator.CalculateLoan(loanDTO1);
Assert.NotNull(loan1);
Assert.Equal(8,loan1.InterestRate);
// second scenario
LoanDTO loanDTO2 = new LoanDTO();
loanDTO2.LoanType=LoanType.CarLoan;
loanDTO2.LocationType=LocationType.Location2;
loanDTO2.JobType=JobType.Professional;
Loan loan2=carLoanCalculator.CalculateLoan(loanDTO2);
Assert.NotNull(loan2);
Assert.Equal(10,loan2.InterestRate);
}
}
在上述片段中的Test_CalculateLoan_ShouldReturnLoan
方法包含了用于测试CalculateLoan
方法两次的代码行。这个测试明显包含了重复的代码,测试与测试数据紧密耦合。此外,测试代码不够清晰,因为当添加更多的测试场景时,测试方法将不得不通过添加更多的代码行来进行修改,从而使测试变得庞大而笨拙。通过数据驱动测试,可以避免这种情况,并且可以消除测试中的重复代码。
包容性测试
当业务人员和质量保证测试人员参与自动化测试过程时,可以改善软件应用程序的质量。他们可以使用数据文件作为数据源,无需太多的技术知识,就可以向数据源中填充执行测试所需的数据。可以使用不同的数据集多次运行测试,以彻底测试代码,以确保其健壮性。
使用数据驱动测试,您可以清晰地分离测试和数据。原本可能会与数据混在一起的测试现在将使用适当的逻辑进行分离。这确保了数据源可以在不更改使用它们的测试的情况下进行修改。
通过数据驱动单元测试,应用程序的整体质量得到改善,因为您可以使用各种数据集获得良好的覆盖率,并具有用于微调和优化正在开发的应用程序以获得改进性能的指标。
xUnit.net 理论属性用于创建数据驱动测试
在 xUnit.net 中,数据驱动测试被称为理论。它们是使用Theory
属性装饰的测试。当测试方法使用Theory
属性装饰时,必须另外使用数据属性装饰,测试运行器将使用该属性确定要在执行测试时使用的数据源:
[Theory]
public void Test_CalculateRates_ShouldReturnRate()
{
// test not implemented yet
}
当测试标记为数据理论时,从数据源中提供的数据直接映射到测试方法的参数。与使用Fact
属性装饰的常规测试不同,数据理论的执行次数基于从数据源获取的可用数据行数。
至少需要传递一个数据属性作为测试方法参数,以便 xUnit.net 将测试视为数据驱动并成功执行。要传递给测试的数据属性可以是InlineData
、MemberData
和ClassData
中的任何一个。这些数据属性源自Xunit.sdk.DataAttribute
。
内联数据驱动单元测试
内联数据驱动测试是使用xUnit.net 框架编写数据驱动测试的最基本或最简单的方式。内联数据驱动测试使用InlineData
属性编写,该属性用于装饰测试方法,除了Theory
属性之外:
[Theory, InlineData("arguments")]
当测试方法需要简单的参数并且不接受类实例化作为InlineData
参数时,可以使用内联数据驱动测试。使用内联数据驱动测试的主要缺点是缺乏灵活性。不能将内联数据与另一个测试重复使用。
当在数据理论中使用InlineData
属性时,数据行是硬编码的,并内联传递到测试方法中。要用于测试的所需数据可以是任何数据类型,并作为参数传递到InlineData
属性中:
public class TheoryTest
{
[Theory,
InlineData("name")]
public void TestCheckWordLength_ShouldReturnBoolean(string word)
{
Assert.Equal(4, word.Length);
}
}
内联数据驱动测试可以有多个InlineData
属性,指定测试方法的参数。多个InlineData
数据理论的语法在以下代码中指定:
[Theory, InlineData("argument1"), InlineData("argument2"), InlineData("argumentn")]
TestCheckWordLength_ShouldReturnBoolean
方法可以更改为具有三个内联数据行,并且可以根据需要添加更多数据行到测试中。为了保持测试的清晰,建议每个测试不要超过必要或所需的内联数据:
public class TheoryTest
{
[Theory,
InlineData("name"),
InlineData("word"),
InlineData("city")
]
public void TestCheckWordLength_ShouldReturnBoolean(string word)
{
Assert.Equal(4, word.Length);
}
}
在编写内联数据驱动单元测试时,必须确保测试方法中的参数数量与传递给InlineData
属性的数据行中的参数数量匹配;否则,xUnit 测试运行器将抛出System.InvalidOperationException
。以下代码片段中TestCheckWordLength_ShouldReturnBoolean
方法中的InlineData
属性已被修改为接受两个参数:
public class TheoryTest
{
[Theory,
InlineData("word","name")]
public void TestCheckWordLength_ShouldReturnBoolean(string word)
{
Assert.Equal(4, word.Length);
}
}
当您在前面的代码片段中运行数据理论测试时,xUnit 测试运行器会因为传递了两个参数"word"
和"name"
给 InlineData 属性,而不是预期的一个参数,导致测试失败并显示InvalidOperationException
,如下面的屏幕截图所示:
当运行内联数据驱动测试时,xUnit.net 将根据添加到测试方法的InlineData
属性或数据行的数量创建测试的数量。在以下代码片段中,xUnit.net 将创建两个测试,一个用于InlineData
属性的参数"name"
,另一个用于参数"city"
:
[Theory,
InlineData("name"),
InlineData("city")]
public void TestCheckWordLength_ShouldReturnBoolean(string word)
{
Assert.Equal(4, word.Length);
}
如果您在 Visual Studio 中使用测试运行器运行TestCheckWordLength_ShouldReturnBoolean
测试方法,测试应该成功运行并通过。基于属性创建的两个测试可以通过从InlineData
属性传递给它们的参数来区分:
现在,让我们修改数据驱动单元测试的好处部分中的Test_CalculateLoan_ShouldReturnCorrectRate
测试方法,使用InlineData
来加载测试数据,而不是直接在测试方法的代码中硬编码测试数据:
[Theory,InlineData(new LoanDTO{ LoanType =LoanType.CarLoan, JobType =JobType.Professional, LocationType=LocationType.Location1 })]
public void Test_CalculateLoan_ShouldReturnCorrectRate(LoanDTO loanDTO)
{
Loan loan = carLoanCalculator.CalculateLoan(loanDTO);
Assert.NotNull(loan);
Assert.Equal(8, loan.InterestRate);
}
在 Visual Studio 中,上述代码片段将导致语法错误,IntelliSense 上下文菜单显示错误——属性参数必须是常量表达式、表达式类型或属性参数类型的数组创建表达式:
在InlineData
属性中使用属性或自定义类型作为参数类型是不允许的,这表明LoanDTO
类的新实例不能作为InlineData
属性的参数。这是InlineData
属性的限制,因为它不能用于从属性、类、方法或自定义类型加载数据。
属性数据驱动单元测试
在编写内联数据驱动测试时遇到的灵活性不足可以通过使用属性数据驱动测试来克服。属性数据驱动单元测试是通过使用MemberData
和ClassData
属性在 xUnit.net 中编写的。使用这两个属性,可以创建从不同数据源(如文件或数据库)加载数据的数据理论。
MemberData 属性
当要创建并加载来自以下数据源的数据行的数据理论时,使用MemberData
属性:
-
静态属性
-
静态字段
-
静态方法
在使用MemberData
时,数据源必须返回与IEnumerable<object[]>
兼容的独立对象集。这是因为在执行测试方法之前,return
属性会被.ToList()
方法枚举。
Test_CalculateLoan_ShouldReturnCorrectRate
测试方法在数据驱动单元测试的好处部分中,可以重构以使用MemberData
属性来加载测试的数据。创建一个静态的IEnumerable
方法GetLoanDTOs
,使用yield
语句返回一个LoanDTO
对象给测试方法:
public static IEnumerable<object[]> GetLoanDTOs()
{
yield return new object[]
{
new LoanDTO
{
LoanType = LoanType.CarLoan,
JobType = JobType.Professional,
LocationType = LocationType.Location1
}
};
yield return new object[]
{
new LoanDTO
{
LoanType = LoanType.CarLoan,
JobType = JobType.Professional,
LocationType = LocationType.Location2
}
};
}
MemberData
属性要求将数据源的名称作为参数传递给它,以便在后续调用中加载测试执行所需的数据行。静态方法、属性或字段的名称可以作为字符串传递到MemberData
属性中,形式为MemberData("methodName")
:
[Theory, MemberData("GetLoanDTOs")]
public void Test_CalculateLoan_ShouldReturnCorrectRate(LoanDTO loanDTO)
{
Loan loan = carLoanCalculator.CalculateLoan(loanDTO);
Assert.NotNull(loan);
Assert.InRange(loan.InterestRate, 8, 12);
}
另外,数据源名称可以通过nameof
表达式传递给MemeberData
属性,nameof
是 C#关键字,用于获取变量、类型或成员的字符串名称。语法是MemberData(nameof(methodName))
:
[Theory, MemberData(nameof(GetLoanDTOs))]
public void Test_CalculateLoan_ShouldReturnCorrectRate(LoanDTO loanDTO)
{
Loan loan = carLoanCalculator.CalculateLoan(loanDTO);
Assert.NotNull(loan);
Assert.InRange(loan.InterestRate, 8, 12);
}
与MemberData
属性一起使用静态方法类似,静态字段和属性可以用于提供数据理论的数据集。
Test_CalculateLoan_ShouldReturnCorrectRate
可以重构以使用静态属性代替方法:
[Theory, MemberData("LoanDTOs")]
public void Test_CalculateLoan_ShouldReturnCorrectRate(LoanDTO loanDTO)
{
Loan loan = carLoanCalculator.CalculateLoan(loanDTO);
Assert.NotNull(loan);
Assert.InRange(loan.InterestRate, 8, 12);
}
创建一个静态属性LoanDTOs
,返回IEnumerable<object[]>
,这是作为MemberData
属性参数的资格要求。LoanDTOs
随后用作属性的参数:
public static IEnumerable<object[]> LoanDTOs
{
get
{
yield return new object[]
{
new LoanDTO
{
LoanType = LoanType.CarLoan,
JobType = JobType.Professional,
LocationType = LocationType.Location1
}
};
yield return new object[]
{
new LoanDTO
{
LoanType = LoanType.CarLoan,
JobType = JobType.Professional,
LocationType = LocationType.Location2
}
};
}
每当运行Test_CalculateLoan_ShouldReturnCorrectRate
时,将创建两个测试,对应于作为数据源返回的两个数据集。
遵循上述方法要求静态方法、字段或属性用于加载测试数据的位置与数据理论相同。为了使测试组织良好,有时需要将测试方法与用于加载数据的静态方法或属性分开放在不同的类中:
public class DataClass
{
public static IEnumerable<object[]> LoanDTOs
{
get
{
yield return new object[]
{
new LoanDTO
{
LoanType = LoanType.CarLoan,
JobType = JobType.Professional,
LocationType = LocationType.Location1
}
};
yield return new object[]
{
new LoanDTO
{
LoanType = LoanType.CarLoan,
JobType = JobType.Professional,
LocationType = LocationType.Location2
}
};
}
}
}
当测试方法写在与静态方法不同的单独类中时,必须在MemberData
属性中指定包含方法的类,使用MemberType
,并分配包含类,使用类名,如下面的代码片段所示:
[Theory, MemberData(nameof(LoanDTOs), MemberType = typeof(DataClass))]
public void Test_CalculateLoan_ShouldReturnCorrectRate(LoanDTO loanDTO)
{
Loan loan = carLoanCalculator.CalculateLoan(loanDTO);
Assert.NotNull(loan);
Assert.InRange(loan.InterestRate, 8, 12);
}
在使用静态方法时,该方法也可以有一个参数,当处理数据时可能需要使用该参数。例如,可以将整数值传递给方法,以指定要返回的记录数。该参数可以直接从MemberData
属性传递给静态方法:
[Theory, MemberData(nameof(GetLoanDTOs), parameters: 1, MemberType = typeof(DataClass))]
public void Test_CalculateLoan_ShouldReturnCorrectRate3(LoanDTO loanDTO)
{
Loan loan = carLoanCalculator.CalculateLoan(loanDTO);
Assert.NotNull(loan);
Assert.InRange(loan.InterestRate, 8, 12);
}
DataClass
中的GetLoanDTOs
方法可以重构为接受一个整数参数,用于限制要返回的记录数,以填充执行Test_CalculateLoan_ShouldReturnCorrectRate
所需的数据行:
public class DataClass
{
public static IEnumerable<object[]> GetLoanDTOs(int records)
{
var loanDTOs = new List<object[]>
{
new object[]
{
new LoanDTO
{
LoanType = LoanType.CarLoan,
JobType = JobType.Professional,
LocationType = LocationType.Location1
}
},
new object[]
{
new LoanDTO
{
LoanType = LoanType.CarLoan,
JobType = JobType.Professional,
LocationType = LocationType.Location2
}
}
};
return loanDTOs.TakeLast(records);
}
}
ClassData 属性
ClassData
是另一个属性,可以使用它来通过来自类的数据创建数据驱动测试。ClassData
属性接受一个可以实例化以获取将用于执行数据理论的数据的类。具有数据的类必须实现IEnumerable<object[]>
,每个数据项都作为object
数组返回。还必须实现GetEnumerator
方法。
让我们创建一个LoanDTOData
类,用于提供数据以测试Test_CalculateLoan_ShouldReturnCorrectRate
方法。LoanDTOData
将返回LoanDTO
的IEnumerable
对象:
public class LoanDTOData : IEnumerable<object[]>
{
private IEnumerable<object[]> data => new[]
{
new object[]
{
new LoanDTO
{
LoanType = LoanType.CarLoan,
JobType = JobType.Professional,
LocationType = LocationType.Location1
}
},
new object[]
{
new LoanDTO
{
LoanType = LoanType.CarLoan,
JobType = JobType.Professional,
LocationType = LocationType.Location2
}
}
};
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
public IEnumerator<object[]> GetEnumerator()
{
return data.GetEnumerator();
}
}
实现了LoanDTOData
类之后,可以使用ClassData
属性装饰Test_CalculateLoan_ShouldReturnCorrectRate
,并将LoanDTOData
作为属性参数传递,以指定LoanDTOData
将被实例化以返回测试方法执行所需的数据:
[Theory, ClassData(typeof(LoanDTOData))]
public void Test_CalculateLoan_ShouldReturnCorrectRate(LoanDTO loanDTO)
{
Loan loan = carLoanCalculator.CalculateLoan(loanDTO);
Assert.NotNull(loan);
Assert.InRange(loan.InterestRate, 8, 12);
}
使用任何合适的方法,都可以灵活地实现枚举器,无论是使用类属性还是方法。在运行测试之前,xUnit.net 框架将在类上调用.ToList()
。在使用ClassData
属性将数据传递给您的测试时,您总是需要创建一个专用类来包含您的数据。
整合来自其他来源的数据
虽然您可以使用前面讨论过的 xUnit.net 理论属性编写基本的数据驱动测试,但有时您可能希望做更多的事情,比如连接到 SQL Server 数据库表,以获取用于执行测试的数据。xUnit.net 的早期版本具有来自xUnit.net.extensions
的其他属性,允许您轻松地从不同来源获取数据,以用于您的测试。xUnit.net.extensions
包在xUnit.net v2中不再可用。
但是,xUnit.net.extensions
中的类在示例项目中可用:github.com/xUnit.net/samples.xUnit.net.
如果您希望使用此属性,可以将示例项目中的代码复制到您的项目中。
SqlServerData 属性
在项目的SqlDataExample
文件夹中,有一些文件可以复制到您的项目中,以便为您提供直接连接到 SQL Server 数据库或可以使用OLEDB访问的任何数据源的功能。该文件夹中的四个类是DataAdapterDataAttribute
,DataAdapterDataAttributeDiscoverer
,OleDbDataAttribute
和SqlServerDataAttribute
。
需要注意的是,由于.NET Core 不支持 OLEDB,因此无法在.NET Core 项目中使用前面的扩展。这是因为 OLEDB 技术是基于 COM 的,依赖于仅在 Windows 上可用的组件。但是您可以在常规.NET 项目中使用此扩展。
GitHub 上的 xUnit.net 存储库中提供了SqlServerData
属性的代码清单,该属性可用于装饰数据理论,以直接从 Microsoft SQL Server 数据库表中获取测试执行所需的数据。
为了测试SqlServerData
属性,您应该在您的 SQL Server 实例中创建一个名为TheoryDb
的数据库。创建一个名为Palindrome
的表;它应该有一个名为varchar
的列。用样本数据填充表,以便用于测试:
CREATE TABLE [dbo].Palindrome NOT NULL
) ;
INSERT INTO [dbo].[Palindrome] ([word]) VALUES ('civic')
GO
INSERT INTO [dbo].[Palindrome] ([word]) VALUES ('dad')
GO
INSERT INTO [dbo].[Palindrome] ([word]) VALUES ('omo')
GO
PalindronmeChecker
类运行一个IsWordPalindrome
方法来验证一个单词是否是回文,如下面的代码片段所示。回文是一个可以在两个方向上阅读的单词,例如dad
或civic
。在不使用算法实现的情况下,快速检查这一点的方法是反转单词并使用字符串SequenceEqual
方法来检查这两个单词是否相等:
public class PalindromeChecker
{
public bool IsWordPalindrome(string word)
{
return word.SequenceEqual(word.Reverse());
}
}
为了测试IsWordPalindrome
方法,将实现一个测试方法Test_IsWordPalindrome_ShouldReturnTrue
,并用SqlServerData
属性进行装饰。此属性需要三个参数——数据库服务器地址、数据库名称和用于从包含要加载到测试中的数据的表或视图中检索数据的选择语句:
public class PalindromeCheckerTest
{
[Theory, SqlServerData(@".\sqlexpress", "TheoryDb", "select word from Palindrome")]
public void Test_IsWordPalindrome_ShouldReturnTrue(string word)
{
PalindromeChecker palindromeChecker = new PalindromeChecker();
Assert.True(palindromeChecker.IsWordPalindrome(word));
}
}
当运行Test_IsWordPalindrome_ShouldReturnTrue
时,将执行SqlServerData
属性,以从数据库表中获取记录,用于执行测试方法。要创建的测试数量取决于表中可用的记录。在这种情况下,将创建并执行三个测试:
自定义属性
与 xUnit.net GitHub 存储库中可用的SqlServerData
属性类似,您可以创建一个自定义属性来从任何源加载数据。自定义属性类必须实现DataAttribute
,这是一个表示理论要使用的数据源的抽象类。自定义属性类必须重写并实现GetData
方法。该方法返回IEnumerable<object[]>
,用于包装要返回的数据集的内容。
让我们创建一个CsvData
自定义属性,可以用于从.csv
文件中加载数据,用于数据驱动的单元测试。该类将具有一个构造函数,它接受两个参数。第一个是包含.csv
文件的完整路径的字符串参数。第二个参数是一个布尔值,当为true
时,指定是否应使用包含在.csv
文件中的数据的第一行作为列标题,当为false
时,指定忽略文件中的列标题,这意味着 CSV 数据从第一行开始。
自定义属性类是CsvDataAttribute
,它实现了DataAttribute
类。该类用AttributeUsage
属性修饰,该属性具有以下参数—AttributeTargets
用于指定应用属性的有效应用元素,AllowMultiple
用于指定是否可以在单个应用元素上指定属性的多个实例,Inherited
用于指定属性是否可以被派生类或覆盖成员继承:
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)]
public class CsvDataAttribute : DataAttribute
{
private readonly string filePath;
private readonly bool hasHeaders;
public CsvDataAttribute(string filePath, bool hasHeaders)
{
this.filePath = filePath;
this.hasHeaders = hasHeaders;
}
// To be followed by GetData implementation
}
下一步是实现GetData
方法,该方法将覆盖DataAttribute
类中可用的实现。此方法使用System.IO
命名空间中的StreamReader
类逐行读取.csv
文件的内容。实现了第二个实用方法ConverCsv
,用于将 CSV 数据转换为整数值:
public override IEnumerable<object[]> GetData(MethodInfo methodInfo)
{
var methodParameters = methodInfo.GetParameters();
var parameterTypes = methodParameters.Select(x => x.ParameterType).ToArray();
using (var streamReader = new StreamReader(filePath))
{
if(hasHeaders)
streamReader.ReadLine();
string csvLine=string.Empty;
while ((csvLine = streamReader.ReadLine()) != null)
{
var csvRow = csvLine.Split(',');
yield return ConvertCsv((object[])csvRow, parameterTypes);
}
}
}
private static object[] ConvertCsv(IReadOnlyList<object> csvRow, IReadOnlyList<Type> parameterTypes)
{
var convertedObject = new object[parameterTypes.Count];
//convert object if integer
for (int i = 0; i < parameterTypes.Count; i++)
convertedObject[i] = (parameterTypes[i] == typeof(int)) ? Convert.ToInt32(csvRow[i]) : csvRow[i];
return convertedObject;
}
创建的自定义属性现在可以与 xUnit.net 的Theory
属性一起使用,以从.csv
文件中提供数据给理论。
Test_IsWordPalindrome_ShouldReturnTrue
测试方法将被修改以使用新创建的CsvData
属性,以从.csv
文件中获取测试执行的数据:
public class PalindromeCheckerTest
{
[Theory, CsvData(@"C:\data.csv", false)]
public void Test_IsWordPalindrome_ShouldReturnTrue(string word)
{
PalindromeChecker palindromeChecker = new PalindromeChecker();
Assert.True(palindromeChecker.IsWordPalindrome(word));
}
}
当您在 Visual Studio 中运行前面片段中的Test_IsWordPalindrome_ShouldReturnTrue
测试方法时,测试运行器将创建三个测试。这应该对应于从.csv
文件中检索到的记录或数据行数。测试信息可以从测试资源管理器中查看:
CsvData
自定义属性可以从任何.csv
文件中检索数据,无论单行上存在多少列。记录将被提取并传递给测试方法中的Theory
属性。
让我们创建一个具有两个整数参数firstNumber
和secondNumber
的方法。该方法将计算整数值firstNumber
和secondNumber
的最大公约数。这两个整数的最大公约数是能够整除这两个整数的最大值:
public int GetGcd(int firstNumber, int secondNumber)
{
if (secondNumber == 0)
return firstNumber;
else
return GetGcd(secondNumber, firstNumber % secondNumber);
}
现在,让我们编写一个测试方法来验证GetGcd
方法。Test_GetGcd_ShouldRetunTrue
将是一个数据理论,并具有三个整数参数—firstNumber
、secondNumber
和gcdValue
。该方法将检查在调用时gdcValue
参数中提供的值是否与调用时GetGcd
方法返回的值匹配。测试的数据将从.csv
文件中加载:
[Theory, CsvData(@"C:\gcd.csv", false)]
public void Test_GetGcd_ShouldRetunTrue(int firstNumber, int secondNumber, int gcd)
{
int gcdValue=GetGcd(firstNumber,secondNumber);
Assert.Equal(gcd,gcdValue);
}
根据.csv
文件中提供的值,将创建测试。以下屏幕截图显示了运行时Test_GetGcdShouldReturnTrue
的结果。创建了三个测试;一个通过,两个失败:
摘要
数据驱动的单元测试是 TDD 的重要概念,它带来了许多好处,可以让您使用来自多个数据源的真实数据广泛测试代码库,为您提供调整和重构代码以获得更好性能和健壮性所需的洞察力。
在本章中,我们介绍了数据驱动测试的好处,以及如何使用 xUnit.net 的内联和属性属性编写有效的数据驱动测试。此外,我们还探讨了在 xUnit.net 中使用的Theory
属性进行数据驱动的单元测试。这使您能够针对来自不同数据源的广泛输入对代码进行适当的验证和验证。
虽然 xUnit.net 提供的默认数据源属性非常有用,但您可以进一步扩展DataAttribute
类,并创建一个自定义属性来从另一个源加载数据。我们演示了CsvData
自定义属性的实现,以从.csv
文件加载测试数据。
在下一章中,我们将深入探讨另一个重要且有用的 TDD 概念,即依赖项模拟。模拟允许您在不必直接构造或执行依赖项代码的情况下,有效地对方法和类进行单元测试。
第六章:模拟依赖
在第五章中,我们讨论了使用 xUnit 框架进行数据驱动的单元测试,这使我们能够创建从不同来源(如平面文件、数据库或内联数据)获取数据的测试。现在,我们将解释模拟依赖的概念,并探讨如何使用 Moq 框架来隔离正在测试的类与其依赖关系,使用 Moq 创建的模拟对象。
在软件项目的代码库中通常存在对象依赖,无论是简单项目还是复杂项目。这是因为各种对象需要相互交互并在边界之间共享信息。然而,为了有效地对对象进行单元测试并隔离它们的行为,每个对象必须在隔离的环境中进行测试,而不考虑它们对其他对象的依赖。
为了实现这一点,类中的依赖对象被替换为模拟对象,以便在测试时能够有效地进行隔离测试,而无需经历构造依赖对象的痛苦,有时这些依赖对象可能并未完全实现,或者在编写被测试对象时构造它们可能是不切实际的。
模拟对象用于模拟真实对象以进行代码测试。模拟对象用于替换真实对象;它们是从真实接口或类创建的,并用于验证交互。模拟对象是另一个类中引用的必要实例,用于模拟这些类的行为。由于软件系统的组件需要相互交互和协作,模拟对象用于替换协作者。使用模拟对象时,可以验证使用是否正确且符合预期。模拟对象可以使用模拟框架或库创建,或者通过手工编写模拟对象的代码生成。
本章将详细探讨 Moq 框架,并将用它来创建模拟对象。Moq 是一个功能齐全的模拟框架,可以轻松设置。它可用于创建用于单元测试的模拟对象。Moq 具有模拟框架应具备的几个基本和高级特性,以创建有用的模拟对象,并基本上编写良好的单元测试。
本章将涵盖以下主题:
-
模拟对象的好处
-
模拟框架的缺点
-
手动编写模拟对象与使用模拟框架
-
使用 Moq 框架进行模拟对象
模拟对象的好处
在良好架构的软件系统中,通常有相互交互和协调以实现基于业务或自动化需求的设定目标的对象。这些对象往往复杂,并依赖于其他外部组件或系统,如数据库、SOAP 或 REST 服务,用于数据和内部状态更新。
大多数开发人员开始采用 TDD,因为它可以提供许多好处,并且意识到程序员有责任编写质量良好、无错误且经过充分测试的代码。然而,一些开发人员反对模拟对象,因为存在一些假设。例如,向单元测试中添加模拟对象会增加编写单元测试所需的总时间。这种假设是错误的,因为使用模拟对象提供了几个好处,如下节所述。
快速运行测试
单元测试的主要特征是它应该运行非常快,并且即使使用相同的数据集多次执行,也应该给出一致的结果。然而,为了有效地运行单元测试并保持具有高效和快速运行的单元测试的属性,重要的是在被测试的代码中存在依赖关系时设置模拟对象。
例如,在以下代码片段中,LoanRepository
类依赖于 Entity Framework 的DbContext
类,后者创建与数据库服务器的连接以进行数据库操作。要为LoanRepository
类中的GetCarLoans
方法编写单元测试,将需要构造DbContext
对象。可以对DbContext
对象进行模拟,以避免每次对该类运行单元测试时打开和关闭数据库连接的昂贵操作:
public class LoanRepository
{
private DbContext dbContext;
public LoanRepository(DbContext dbContext)
{
this.dbContext=dbContext;
}
public List<CarLoan> GetCarLoans()
{
return dbContext.CarLoan;
}
}
在软件系统中,根据需求,将需要访问外部系统,如大型文件、数据库或 Web 连接。在单元测试中直接与这些外部系统交互会增加测试的运行时间。因此,最好对这些外部系统进行模拟,以便测试能够快速运行。当您有长时间运行的测试时,单元测试的好处可能会丧失,因为这显然会浪费生产时间。在这种情况下,开发人员可以停止运行测试,或者完全停止单元测试,并断言单元测试是浪费时间。
依赖项隔离
使用依赖项模拟,您在代码中实际上创建了依赖项的替代方案,可以进行实验。当您在适当位置有依赖项的模拟实现时,您可以进行更改并测试更改的效果,因为测试将针对模拟对象而不是真实对象运行。
当您将依赖项隔离时,您可以专注于正在运行的测试,从而将测试的范围限制在对测试真正重要的代码上。实质上,通过减少范围,您可以轻松重构被测试的代码以及测试本身,从而清晰地了解代码可以改进的地方。
为了在以下代码片段中隔离地测试LoanRepository
类,可以对该类依赖的DbContext
对象进行模拟。这将限制单元测试的范围仅限于LoanRepository
类:
public class LoanRepository
{
private DbContext dbContext;
public LoanRepository(DbContext dbContext)
{
this.dbContext=dbContext;
}
}
此外,通过隔离依赖项来保持测试范围较小,使得测试易于理解并促进了易于维护。通过不模拟依赖项来增加测试范围,最终会使测试维护变得困难,并减少测试的高级详细覆盖。由于必须对依赖项进行测试,这可能导致由于范围增加而导致测试的细节减少。
重构遗留代码
遗留源代码是由您或其他人编写的代码,通常没有测试或使用旧的框架、架构或技术。这样的代码库可能很难重写或维护。它有时可能是难以阅读和理解的混乱代码,因此很难更改。
面对维护遗留代码库的艰巨任务,特别是没有充分或适当测试的代码库,为这样的代码编写单元测试可能很困难,也可能是浪费时间,并且可能需要大量的辛苦工作。然而,使用模拟框架可以极大地简化重构过程,因为正在编写的新代码可以与现有代码隔离,并使用模拟对象进行测试。
更广泛的测试覆盖
通过模拟,您可以确保进行广泛的测试覆盖,因为您可以轻松使用模拟对象来模拟可能的异常、执行场景和条件,否则这些情况将很难实现。例如,如果您有一个清除或删除数据库表的方法,使用模拟对象测试这个方法比每次运行单元测试时在实时数据库上运行更安全。
模拟框架的缺点
虽然模拟框架在 TDD 期间非常有用,因为它们通过使用模拟对象简化了单元测试,但它们也有一些限制和缺点,可能会影响代码的设计,或者通过过度使用导致包含不相关模拟对象的混乱测试的创建。
接口爆炸
大多数嘲弄框架的架构要求必须创建接口来模拟对象。实质上,你不能直接模拟一个类;必须通过类实现的接口来进行。为了在单元测试期间模拟依赖关系,为每个要模拟的对象或依赖关系创建一个接口,即使在生产代码中使用该依赖关系时并不需要该接口。这导致创建了太多的接口,这种情况被称为接口爆炸。
额外的复杂性
大多数模拟框架使用反射或创建代理来调用方法并创建单元测试中所需的模拟。这个过程很慢,并给单元测试过程增加了额外的开销。特别是当希望使用模拟来模拟所有类和依赖关系之间的交互时,这一点尤其明显,这可能导致模拟返回其他模拟的情况。
模拟爆炸
有了几种模拟框架,更容易熟悉模拟概念并为单元测试创建模拟。然而,开发人员可能会开始过度模拟,即每个对象似乎都是模拟候选对象的情况。此外,拥有太多的模拟可能会导致编写脆弱的测试,使你的测试容易在接口更改时出现问题。当你有太多的模拟时,最终会减慢测试套件的速度,并因此增加开发时间。
手动编写模拟与使用模拟框架
使用模拟框架可以促进流畅的单元测试体验,特别是在单元测试具有依赖关系的代码部分时,模拟对象被创建并替代依赖关系。虽然使用模拟框架更容易,但有时你可能更喜欢手动编写模拟对象进行单元测试,而不向项目或代码库添加额外的复杂性或附加库。
手动编写的模拟是为了测试而创建的类,用于替换生产对象。这些创建的类将具有与生产类相同的方法和定义,以及返回值,以有效模拟生产类并用作单元测试中依赖关系的替代品。
模拟概念
创建模拟的第一步应该是识别依赖关系。单元测试的目标应该是编写清晰的代码,并尽可能快地运行具有良好覆盖率的测试。你应该识别可能减慢测试速度的依赖关系。例如,Web 服务或数据库调用就是模拟的候选对象。
创建模拟对象的方法可以根据被模拟的依赖关系的类型而变化。然而,模拟的概念可以遵循模拟对象在调用方法时应返回特定预定义值的基本概念。应该有适当的验证机制来确保模拟的方法被调用,并且如果根据测试要求进行配置,模拟对象可以抛出异常。
了解模拟对象的类型对于有效地手动编写模拟对象非常重要。可以创建两种类型的模拟对象——动态和静态模拟对象。动态对象可以通过反射或代理类创建。这类似于模拟框架的工作方式。静态模拟对象可以通过实现接口的类以及有时作为要模拟的依赖关系的实际具体类来创建。当你手动编写模拟对象时,实质上你正在创建静态模拟对象。
反射可以用来创建模拟对象。C#中的反射是一个有用的构造,允许你创建一个类型的实例对象,以及获取或绑定类型到现有对象,并调用类型中可用的字段和方法。此外,你可以使用反射来创建描述模块和程序集的对象。
手动编写模拟的好处
手动编写您的模拟有时可能是一种有效的方法,当您打算完全控制测试设置并指定测试设置的行为时。此外,当测试相对简单时,使用模拟框架不是一个选择;最好手动编写模拟并保持一切简单。
使用模拟框架时,对被模拟的真实对象进行更改将需要更改在其使用的任何地方的模拟对象。这是因为对依赖项进行的更改将破坏测试。例如,如果依赖对象上的方法名称发生更改,您必须在动态模拟中进行更改。因此,必须在代码库的几个部分进行更改。使用手动编写的模拟,您只需要在一个地方进行更改,因为您可以控制向测试呈现的方法。
模拟和存根
模拟和存根都很相似,因为它们用于替换类依赖项或协作者,并且大多数模拟框架都提供创建两者的功能。存根可以以与手动编写模拟相同的方式手动编写。
那么模拟和存根真正的区别是什么?模拟用于测试协作。这包括验证实际协作者的期望。模拟被编程为具有包含要接收的方法调用详细信息的期望,而存根用于模拟协作者。让我们通过一个例子进一步解释这一点。
存根可用于表示来自数据库的结果。可以创建一个 C#列表,其中包含可用于执行测试的数据,以替代数据库调用返回一组数据。如果未验证测试的依赖项交互上方的存根,则测试将仅关注数据。
以下片段中的LoanService
类具有一个GetBadCarLoans
方法,该方法接受要从数据库中检索的Loan
对象列表:
public class LoanService
{
public List<Loan> GetBadCarLoans(List<Loan> carLoans)
{
List<Loan> badLoans= new List<Loan>();
//do business logic computations on the loans
return badLoans;
}
}
以下片段中Test_GetBadCarLoans_ShouldReturnLoans
的GetBadCarLoans
方法的测试使用了存根,这是一个Loan
对象列表,作为参数传递给GetBadCarLoans
方法,而不是调用数据库以获取用于Test
类的Loan
对象列表:
[Fact]
public void Test_GetBadCarLoans_ShouldReturnLoans()
{
List<Loan> loans= new List<Loan>();
loans.Add(new Loan{Amount=120000, Rate=12.5, ServiceYear=5, HasDefaulted=false});
loans.Add(new Loan{Amount=150000, Rate=12.5, ServiceYear=4, HasDefaulted=true});
loans.Add(new Loan{Amount=200000, Rate=12.5, ServiceYear=5, HasDefaulted=false});
LoanService loanService= new LoanService();
List<Loan> badLoans = loanService.GetBadCarLoans(loanDTO);
Assert.NotNull(badLoans);
}
以下片段中的LoanService
类具有连接到数据库以获取记录的LoanRepository
DI。该类具有一个构造函数,在该构造函数中注入了ILoanRepository
对象。LoanService
类具有一个GetBadCarLoans
方法,该方法调用依赖项上的GetCarLoan
方法,后者又调用数据库获取Loan
对象列表:
public class LoanService
{
private ILoanRepository loanRepository;
public LoanService(ILoanRepository loanRepository)
{
this.loanRepository=loanRepository;
}
public List<Loan> GetBadCarLoans()
{
List<Loan> badLoans= new List<Loan>();
var carLoans=loanRepository.GetCarLoans();
//do business logic computations on the loans
return badLoans;
}
}
与使用存根时不同,模拟将验证调用依赖项中的方法。这意味着模拟对象将设置依赖项中要调用的方法。在以下片段中的LoanServiceTest
类中,从ILoanRepository
创建了一个模拟对象:
public class LoanServiceTest
{
private Mock<ILoanRepository> loanRepository;
private LoanService loanService;
public LoanServiceTest()
{
loanRepository= new Mock<ILoanRepository>();
List<Loan> loans = new List<Loan>
{
new Loan{Amount = 120000, Rate = 12.5, ServiceYear = 5, HasDefaulted = false },
new Loan {Amount = 150000, Rate = 12.5, ServiceYear = 4, HasDefaulted = true },
new Loan { Amount = 200000, Rate = 12.5, ServiceYear = 5, HasDefaulted = false }
};
loanRepository.Setup(x => x.GetCarLoans()).Returns(loans);
loanService= new LoanService(loanRepository.Object);
}
[Fact]
public void Test_GetBadCarLoans_ShouldReturnLoans()
{
List<Loan> badLoans = loanService.GetBadCarLoans();
Assert.NotNull(badLoans);
}
}
在LoanServiceTest
类的构造函数中,首先创建了模拟对象要返回的数据,然后设置了依赖项中的方法,如loanRepository.Setup(x => x.GetCarLoans()).Returns(loans);
。然后将模拟对象传递给LoanService
构造函数,loanService= new loanService(loanRepository.Object);
。
手动编写模拟
我们可以手动编写一个模拟对象来测试LoanService
类。要创建的模拟对象将实现ILoanRepository
接口,并且仅用于单元测试,因为在生产代码中不需要它。模拟对象将返回一个Loan
对象列表,这将模拟对数据库的实际调用。
public class LoanRepositoryMock : ILoanRepository
{
public List<Loan> GetCarLoans()
{
List<Loan> loans = new List<Loan>
{
new Loan{Amount = 120000, Rate = 12.5, ServiceYear = 5, HasDefaulted = false },
new Loan {Amount = 150000, Rate = 12.5, ServiceYear = 4, HasDefaulted = true },
new Loan { Amount = 200000, Rate = 12.5, ServiceYear = 5, HasDefaulted = false }
};
return loans;
}
}
现在可以在LoanService
类中使用创建的LoanRepositoryMock
类来模拟ILoanRepository
,而不是使用从模拟框架创建的模拟对象。在LoanServiceTest
类的构造函数中,将实例化LoanRepositoryMock
类并将其注入到LoanService
类中,该类在Test
类中使用:
public class LoanServiceTest
{
private ILoanRepository loanRepository;
private LoanService loanService;
public LoanServiceTest()
{
loanRepository= new LoanRepositoryMock();
loanService= new LoanService(loanRepository);
}
[Fact]
public void Test_GetBadCarLoans_ShouldReturnLoans()
{
List<Loan> badLoans = loanService.GetBadCarLoans();
Assert.NotNull(badLoans);
}
}
因为LoanRepositoryMock
被用作ILoanRepository
接口的具体类,是LoanService
类的依赖项,所以每当在ILoanRepository
接口上调用GetCarLoans
方法时,LoanRepositoryMock
的GetCarLoans
方法将被调用以返回测试运行所需的数据。
使用 Moq 框架模拟对象
选择用于模拟对象的模拟框架对于顺利进行单元测试是很重要的。然而,并没有必须遵循的书面规则。在选择用于测试的模拟框架时,您可以考虑一些因素和功能。
在选择模拟框架时,性能和可用功能应该是首要考虑因素。您应该检查模拟框架创建模拟的方式;使用继承、虚拟和静态方法的框架无法被模拟。要注意的其他功能可能包括方法、属性、事件,甚至是框架是否支持 LINQ。
此外,没有什么比库的简单性和易用性更好。您应该选择一个易于使用的框架,并且具有良好的可用功能文档。在本章的后续部分中,将使用 Moq 框架来解释模拟的其他概念,这是一个易于使用的强类型库。
使用 Moq 时,模拟对象是一个实际的虚拟类,它是使用反射为您创建的,其中包含了被模拟的接口中包含的方法的实现。在 Moq 设置中,您将指定要模拟的接口以及测试类需要有效运行测试的方法。
要使用 Moq,您需要通过 NuGet 包管理器或 NuGet 控制台安装该库:
Install-Package Moq
为了解释使用 Moq 进行模拟,让我们创建一个ILoanRepository
接口,其中包含两种方法,GetCarLoan
用于从数据库中检索汽车贷款列表,以及GetLoanTypes
方法,用于返回LoanType
对象的列表:
public interface ILoanRepository
{
List<LoanType> GetLoanTypes();
List<Loan> GetCarLoans();
}
LoanRepository
类使用 Entity Framework 作为数据访问和检索的 ORM,并实现了ILoanRepository
。GetLoanTypes
和GetCarLoans
两种方法已经被LoanRepository
类实现:
public class LoanRepository :ILoanRepository
{
public List<LoanType> GetLoanTypes()
{
List<LoanType> loanTypes= new List<LoanType>();
using (LoanContext context = new LoanContext())
{
loanTypes=context.LoanType.ToList();
}
return loanTypes;
}
public List<Loan> GetCarLoans()
{
List<Loan> loans = new List<Loan>();
using (LoanContext context = new LoanContext())
{
loans = context.Loan.ToList();
}
return loans;
}
}
让我们为ILoanRepository
创建一个模拟对象,以便在不依赖任何具体类实现的情况下测试这两种方法。
使用 Moq 很容易创建一个模拟对象:
Mock<ILoanRepository> loanRepository = new Mock<ILoanRepository>();
在上一行代码中,已经创建了一个实现ILoanRepository
接口的模拟对象。该对象可以被用作ILoanRepository
的常规实现,并注入到任何具有ILoanRepository
依赖的类中。
模拟方法、属性和回调
在测试中使用模拟对象的方法之前,它们需要被设置。这个设置最好是在测试类的构造函数中完成,模拟对象创建后,但在将对象注入到需要依赖的类之前。
首先,需要创建要由设置的方法返回的数据;这是测试中要使用的虚拟数据:
List<Loan> loans = new List<Loan>
{
new Loan{Amount = 120000, Rate = 12.5, ServiceYear = 5, HasDefaulted = false },
new Loan {Amount = 150000, Rate = 12.5, ServiceYear = 4, HasDefaulted = true },
new Loan { Amount = 200000, Rate = 12.5, ServiceYear = 5, HasDefaulted = false }
};
在设置方法的时候,返回数据将被传递给它,以及任何方法参数(如果适用)。在下一行代码中,GetCarLoans
方法被设置为以Loan
对象的列表作为返回数据。这意味着每当在单元测试中使用模拟对象调用GetCarLoans
方法时,之前创建的列表将作为方法的返回值返回:
Mock<ILoanRepository> loanRepository = new Mock<ILoanRepository>();
loanRepository.Setup(x => x.GetCarLoans()).Returns(loans);
您可以对方法返回值进行延迟评估。这是使用 LINQ 提供的语法糖:
loanRepository.Setup(x => x.GetCarLoans()).Returns(() => loans);
Moq 有一个It
对象,它可以用来指定方法中参数的匹配条件。It
指的是被匹配的参数。假设GetCarLoans
方法有一个字符串参数loanType
,那么方法设置的语法可以改变以包括参数和返回值:
loanRepository.Setup(x => x.GetCarLoans(It.IsAny<string>())).Returns(loans);
可以设置一个方法,每次调用时返回不同的返回值。例如,可以设置GetCarLoans
方法的设置,以便在每次调用该方法时返回不同大小的列表:
Random random = new Random();
loanRepository.Setup(x => x.GetCarLoans()).Returns(loans).Callback(() => loans.GetRange(0,random.Next(1, 3));
在上面的片段中,生成了1
和3
之间的随机数,以设置。这将确保由GetCarLoans
方法返回的列表的大小随每次调用而变化。第一次调用GetCarLoans
方法时,将调用Returns
方法,而在随后的调用中,将执行Callback
中的代码。
Moq 的一个特性是提供异常测试的功能。您可以设置方法以测试异常。在以下方法设置中,当调用时,GetCarLoans
方法会抛出InvalidOperationException
:
loanRepository.Setup(x => x.GetCarLoans()).Throws<InvalidOperationException>();
属性
如果您有一个具有要在方法调用中使用的属性的依赖项,可以使用 Moq 的SetupProperty
方法为这些属性设置虚拟值。让我们向ILoanRepository
接口添加两个属性,LoanType
和Rate
:
public interface ILoanRepository
{
LoanType LoanType{get;set;}
float Rate {get;set;}
List<LoanType> GetLoanTypes();
List<Loan> GetCarLoans();
}
使用 Moq 的SetupProperty
方法,您可以指定属性应具有的行为,这实质上意味着每当请求属性时,将返回在SetupProperty
方法中设置的值:
Mock<ILoanRepository> loanRepository = new Mock<ILoanRepository>();
loanRepository.Setup(x => x.LoanType, LoanType.CarLoan);
loanRepository.Setup(x => x.Rate, 12.5);
在上面的片段中的代码将LoanType
属性设置为枚举值CarLoan
,并将Rate
设置为12.5
。在测试中请求属性时,将返回设置的值到调用点。
使用SetupProperty
方法设置属性会自动将属性设置为存根,并允许跟踪属性的值并为属性提供默认值。
此外,在设置属性时,还可以使用SetupSet
方法,该方法接受 lambda 表达式来指定对属性设置器的调用类型,并允许您将值传递到表达式中:
loanRepository.SetupSet(x => x.Rate = 12.5F);
SetupSet
类似于SetupGet
,用于为属性的调用指定类型的设置:
loanRepository.SetupGet(x => x.Rate);
递归模拟允许您模拟复杂的对象类型,特别是嵌套的复杂类型。例如,您可能希望模拟Loan
类型中Person
复杂类型的Age
属性。Moq 框架可以以优雅的方式遍历此图以模拟属性:
loanRepository.SetupSet(x => x.CarLoan.Person.Age= 40);
您可以使用SetupAllProperties
方法存根模拟对象上的所有属性。此方法将指定模拟上的所有属性都具有属性行为设置。通过在模拟中为每个属性生成默认值,使用 Moq 框架的Mock.DefaultProperty
属性生成默认属性:
loanRepository.SetupAllProperties();
匹配参数
在使用 Moq 创建模拟对象时,您可以匹配参数以确保在测试期间传递了预期的参数。使用此功能,您可以确定在测试期间调用方法时传递的参数的有效性。这仅适用于具有参数的方法,并且匹配将在方法设置期间进行。
使用 Moq 的It
关键字,您可以在设置期间为方法参数指定不同的表达式和验证。让我们向ILoanRepository
接口添加一个GetCarLoanDefaulters
方法定义。LoanRepository
类中的实现接受一个整数参数,该参数是贷款的服务年限,并返回汽车贷款拖欠者的列表。以下片段显示了GetCarLoanDefaulters
方法的代码:
public List<Person> GetCarLoanDefaulters(int year)
{
List<Person> defaulters = new List<Person>();
using (LoanContext context = new LoanContext())
{
defaulters = context.Loan.Where(c => c.HasDefaulted
&& c.ServiceYear == year).Select(c => c.Person).ToList();
}
return defaulters;
}
现在,让我们在LoanServiceTest
构造函数中设置GetCarLoanDefaulters
方法,以使用 Moq 的It
关键字接受不同的year
参数值:
List<Person> people = new List<Person>
{
new Person { FirstName = "Donald", LastName = "Duke", Age =30},
new Person { FirstName = "Ayobami", LastName = "Adewole", Age =20}
};
Mock<ILoanRepository> loanRepository = new Mock<ILoanRepository>();
loanRepository.Setup(x => x.GetCarLoanDefaulters(It.IsInRange<int>(1, 5, Range.Inclusive))).Returns(people);
已创建了一个Person
对象列表,将传递给模拟设置的Returns
方法。GetCarLoanDefaulters
方法现在将接受指定范围内的值,因为It.IsInRange
方法已经使用了上限和下限值。
It
类有其他有用的方法,用于在设置期间指定方法的匹配条件,而不必指定特定的值:
-
IsRegex
用于指定一个正则表达式来匹配一个字符串参数 -
Is
用于指定与给定谓词匹配的值 -
IsAny<>
用于匹配指定类型的任何值 -
Ref<>
用于匹配在ref
参数中指定的任何值
您可以创建一个自定义匹配器,并在方法设置中使用它。例如,让我们为 GetCarLoanDefaulters
方法创建一个自定义匹配器 IsOutOfRange
,以确保不会提供大于 12
的值作为参数。通过使用 Match.Create
来创建自定义匹配器:
public int IsOutOfRange()
{
return Match.Create<int>(x => x > 12);
}
现在可以在模拟对象的方法设置中使用创建的 IsOutOfRange
匹配器:
loanRepository.Setup(x => x.GetCarLoanDefaulters(IsOutOfRange())).Throws<ArgumentException>();
事件
Moq 有一个功能,允许您在模拟对象上引发事件。要引发事件,您使用 Raise
方法。该方法有两个参数。第一个是 Lambda 表达式,用于订阅事件以在模拟上引发事件。第二个参数提供将包含在事件中的参数。要在 loanRepository
模拟对象上引发 LoanDefaulterNotification
事件,并使用空参数,您可以使用以下代码行:
Mock<ILoanRepository> loanRepository = new Mock<ILoanRepository>();
loanRepository.Raise(x => x.LoanDefaulterNotification+=null, EventArgs.Empty);
真实用例是当您希望模拟对象响应动作引发事件或响应方法调用引发事件时。在模拟对象上设置方法以允许引发事件时,模拟上的 Returns
方法将被替换为 Raises
方法,该方法指示在测试中调用方法时,应该引发事件:
loanRepository.Setup(x => x.GetCarLoans()).Raises(x=> x.LoanDefaulterNotification+=null, new LoanDefualterEventArgs{OK=true});
回调
使用 Moq 的 Callback
方法,您可以指定在调用方法之前和之后要调用的回调。有一些测试场景可能无法使用简单的模拟期望轻松测试。在这种复杂的情况下,您可以使用回调来执行特定的操作,当调用模拟对象时。Callback
方法接受一个动作参数,根据回调是在方法调用之前还是之后设置,将执行该动作。该动作可以是要评估的表达式或要调用的另一个方法。
例如,您可以设置一个回调,在调用特定方法之后更改数据。此功能允许您创建提供更大灵活性的测试,同时简化测试复杂性。让我们向 loanRepository
模拟对象添加一个回调。
回调可以是一个将被调用的方法,或者是您需要设置值的属性:
List<Person> people = new List<Person>
{
new Person { FirstName = "Donald", LastName = "Duke", Age =30},
new Person { FirstName = "Ayobami", LastName = "Adewole", Age =20}
};
Mock<ILoanRepository> loanRepository = new Mock<ILoanRepository>();
loanRepository.Setup(x => x.GetCarLoanDefaulters())
.Callback(() => CarLoanDefaultersCallbackAfter ())
.Returns(() => people)
.Callback(() => CarLoanDefaultersCallbackAfter());
上面的片段为方法设置设置了两个回调。CarLoanDefaultersCallback
方法在实际调用 GetCarLoanDefaulters
方法之前被调用,CarLoanDefaultersCallbackAfter
在在模拟对象上调用 GetCarLoanDefaulters
方法之后被调用。CarLoanDefaultersCallback
向 List
添加一个新的 Person
对象,CarLoanDefaultersCallback
删除列表中的第一个元素:
public void CarLoanDefaultersCallback()
{
people.Add(new Person { FirstName = "John", LastName = "Doe", Age =40});
}
public void CarLoanDefaultersCallbackAfter()
{
people.RemoveAt(0);
}
模拟定制
在使用 Moq 框架时,您可以进一步定制模拟对象,以增强有效的单元测试体验。可以将 MockBehavior
枚举传递到 Moq 的 Mock
对象构造函数中,以指定模拟的行为。枚举成员有 Default
、Strict
和 Loose
:
loanRepository= new Mock<ILoanRepository>(MockBehavior.Loose);
当选择 Loose
成员时,模拟将不会抛出任何异常。默认值将始终返回。这意味着对于引用类型,将返回 null,对于值类型,将返回零或空数组和可枚举类型:
loanRepository= new Mock<ILoanRepository>(MockBehavior.Strict);
选择 Strict
成员将使模拟对于每次在模拟上没有适当设置的调用都抛出异常。最后,Default
成员是模拟的默认行为,从技术上讲等同于 Loose
枚举成员。
CallBase
在模拟构造期间初始化CallBase
时,用于指定是否在没有匹配的设置时调用基类虚拟实现。默认值为false
。这在模拟System.Web
命名空间的 HTML/web 控件时非常有用:
loanRepository= new Mock<ILoanRepository>{CallBase=true};
模拟存储库
通过使用 Moq 中的MockRepository
,可以避免在测试中分散创建模拟对象的代码,从而避免重复的代码。MockRepository
可用于在单个位置创建和验证模拟,从而确保您可以通过设置CallBase
、DefaultValue
和MockBehavior
进行模拟配置,并在一个地方验证模拟:
var mockRepository = new MockRepository(MockBehavior.Strict) { DefaultValue = DefaultValue.Mock };
var loanRepository = repository.Create<ILoanRepository>(MockBehavior.Loose);
var userRepository = repository.Create<IUserRepository>();
mockRepository.Verify();
在上述代码片段中,使用MockBehaviour.Strict
创建了一个模拟存储库,并创建了两个模拟对象,每个对象都使用loanRepository
模拟,覆盖了存储库中指定的默认MockBehaviour
。最后一条语句是对Verify
方法的调用,以验证存储库中创建的所有模拟对象的所有期望。
在模拟中实现多个接口
此外,您可以在单个模拟中实现多个接口。例如,我们可以创建一个模拟,实现ILoanRepository
,然后使用As<>
方法实现IDisposable
接口,该方法用于向模拟添加接口实现并为其指定设置:
var loanRepository = new Mock<ILoanRepository>();
loanRepository.Setup(x => x.GetCarLoanDefaulters(It.IsInRange<int>(1, 5, Range.Inclusive))).Returns(people);
loanRepository.As<IDisposable>().Setup(disposable => disposable.Dispose());
使用 Moq 进行验证的方法和属性调用
模拟行为在设置期间指定。这是对象和协作者的预期行为。在单元测试时,模拟不完整,直到验证了所有模拟依赖项的调用。了解方法执行的次数或属性访问的次数可能会有所帮助。
Moq 框架具有有用的验证方法,可用于验证模拟的方法和属性。此外,Times
结构包含有用的成员,显示可以在方法上允许的调用次数。
Verify
方法可用于验证在模拟上执行的方法调用及提供的参数是否与先前在模拟设置期间配置的内容匹配,并且使用了默认的MockBehaviour
,即Loose
。为了解释 Moq 中的验证概念,让我们创建一个依赖于ILoanRepository
的LoanService
类,并向其添加一个名为GetOlderCarLoanDefaulters
的方法,以返回年龄大于20
岁的贷款拖欠人的列表。ILoanRepository
通过构造函数注入到LoanService
中:
public class LoanService
{
private ILoanRepository loanRepository;
public LoanService(ILoanRepository loanRepository)
{
this.loanRepository = loanRepository;
}
public List<Person> GetOlderCarLoanDefaulters(int year)
{
List<Person> defaulters = loanRepository.GetCarLoanDefaulters(year);
var filteredDefaulters = defaulters.Where(x => x.Age > 20).ToList();
return filteredDefaulters;
}
}
为了测试LoanService
类,我们将创建一个LoanServiceTest
测试类,该类使用依赖模拟来隔离LoanService
进行单元测试。LoanServiceTest
将包含一个构造函数,用于设置LoanService
类所需的ILoanRepository
的模拟:
public class LoanServiceTest
{
private Mock<ILoanRepository> loanRepository;
private LoanService loanService;
public LoanServiceTest()
{
loanRepository= new Mock<ILoanRepository>();
List<Person> people = new List<Person>
{
new Person { FirstName = "Donald", LastName = "Duke", Age =30},
new Person { FirstName = "Ayobami", LastName = "Adewole", Age =20}
};
loanRepository.Setup(x => x.GetCarLoanDefaulters(It.IsInRange<int>(1,12,Range.Inclusive))).Returns(() => people);
loanService = new LoanService(loanRepository.Object);
}
}
LoanServiceTest
构造函数包含对ILoanRepository
接口的GetCarLoanDefaulters
方法的模拟设置,包括参数期望和返回值。让我们创建一个名为Test_GetOlderCarLoanDefaulters_ShouldReturnList
的测试方法,以测试GetCarLoanDefaulters
。在断言语句之后,有Verify
方法来检查GetCarLoanDefaulters
是否被调用了一次:
[Fact]
public void Test_GetOlderCarLoanDefaulters_ShouldReturnList()
{
List<Person> defaulters = loanService.GetOlderCarLoanDefaulters(12);
Assert.NotNull(defaulters);
Assert.All(defaulters, x => Assert.Contains("Donald", x.FirstName));
loanRepository.Verify(x => x.GetCarLoanDefaulters(It.IsInRange<int>(1, 12, Range.Inclusive)), Times.Once());
}
Verify
方法接受两个参数:要验证的方法和Time
结构。使用了Time.Once
,指定模拟方法只能被调用一次。
Times.AtLeast(int callCount)
用于指定模拟方法应该被调用的最小次数,该次数由callCount
参数的值指定。这可用于验证方法被调用的次数:
[Fact]
public void Test_GetOlderCarLoanDefaulters_ShouldReturnList()
{
List<Person> defaulters = loanService.GetOlderCarLoanDefaulters(12);
Assert.NotNull(defaulters);
Assert.All(defaulters, x => Assert.Contains("Donald", x.FirstName));
loanRepository.Verify(x => x.GetCarLoanDefaulters(It.IsInRange<int>(1, 12, Range.Inclusive)), Times.AtLeast(2));
}
在上述测试片段中,将Times.AtLeast(2)
传递给Verify
方法。当运行测试时,由于被测试的代码中的GetCarLoanDefaulters
方法只被调用了一次,测试将失败,并显示Moq.MoqException
。
Times.AtLeastOnce
可用于指定模拟方法应至少调用一次,这意味着该方法可以在被测试的代码中被多次调用。我们可以修改Test_GetOlderCarLoanDefaulters_ShouldReturnList
中的Verify
方法,以将第二个参数设置为Time.AtLeastOnce
,以验证测试运行后GetCarLoanDefaulters
至少在被测试的代码中被调用一次:
[Fact]
public void Test_GetOlderCarLoanDefaulters_ShouldReturnList()
{
List<Person> defaulters = loanService.GetOlderCarLoanDefaulters(12);
Assert.NotNull(defaulters);
Assert.All(defaulters, x => Assert.Contains("Donald", x.FirstName));
loanRepository.Verify(x => x.GetCarLoanDefaulters(It.IsInRange<int>(1, 12, Range.Inclusive)), Times.AtLeastOnce);
}
Times.AtMost(int callCount)
可用于指定在被测试的代码中应调用模拟方法的最大次数。 callCount
参数用于传递方法的最大调用次数的值。这可用于限制允许对模拟方法的调用。如果调用方法的次数超过指定的callCount
值,则会抛出 Moq 异常:
loanRepository.Verify(x => x.GetCarLoanDefaulters(It.IsInRange<int>(1, 12, Range.Inclusive)), Times.AtMost(1));
Times.AtMostOnce
类似于Time.Once
或Time.AtLeastOnce
,但不同之处在于模拟方法最多只能调用一次。如果方法被调用多次,则会抛出 Moq 异常,但如果在运行代码时未调用该方法,则不会抛出异常:
loanRepository.Verify(x => x.GetCarLoanDefaulters(It.IsInRange<int>(1, 12, Range.Inclusive)), Times.AtMostOnce);
Times.Between(callCountFrom,callCountTo, Range)
可用于在Verify
方法中指定模拟方法应在callCountFrom
和callCountTo
之间调用,并且Range
枚举用于指定是否包括或排除指定的范围:
loanRepository.Verify(x => x.GetCarLoanDefaulters(It.IsInRange<int>(1, 12, Range.Inclusive)), Times.Between(1,2,Range.Inclusive));
Times.Exactly(callCount)
在您希望指定模拟方法应在指定的callCount
处调用时非常有用。如果模拟方法的调用次数少于指定的callCount
或多次,将生成 Moq 异常,并提供期望和失败的详细描述:
[Fact]
public void Test_GetOlderCarLoanDefaulters_ShouldReturnList()
{
List<Person> defaulters = loanService.GetOlderCarLoanDefaulters(12);
Assert.NotNull(defaulters);
Assert.All(defaulters, x => Assert.Contains("Donald", x.FirstName));
loanRepository.Verify(x => x.GetCarLoanDefaulters(It.IsInRange<int>(1, 12, Range.Inclusive)), Times.Exactly(2));
}
现在让我们检查代码:
还有一个重要的是Times.Never
。当使用时,它可以验证模拟方法从未被使用。当您不希望调用模拟方法时,可以使用此选项:
loanRepository.Verify(x => x.GetCarLoanDefaulters(It.IsInRange<int>(1, 12, Range.Inclusive)), Times.Never);
模拟属性验证与使用VerifySet
和VerifyGet
方法的模拟方法类似进行。VerifySet
方法用于验证在模拟对象上设置了属性。此外,VerifyGet
方法用于验证在模拟对象上读取了属性,而不管属性中包含的值是什么:
loanRepository.VerifyGet(x => x.Rate);
要验证在模拟对象上设置了属性,而不管设置了什么值,可以使用VerifySet
方法,语法如下:
loanRepository.VerifySet(x => x.Rate);
有时,您可能希望验证在模拟对象上分配了特定值给属性。您可以通过将值分配给VerifySet
方法中的属性来执行此操作:
loanRepository.VerifySet(x => x.Rate = 12.5);
Moq 4.8 中引入的VerifyNoOtherCalls()
方法可用于确定除了已经验证的调用之外没有进行其他调用。VerifyAll()
方法用于验证所有期望,无论它们是否已被标记为可验证。
LINQ 到模拟
语言集成查询(LINQ)是在.NET 4.0 中引入的一种语言构造,它提供了.NET Framework 中的查询功能。 LINQ 具有以声明性查询语法编写的查询表达式。有不同的 LINQ 实现-LINQ 到 XML,用于查询 XML 文档,LINQ 到实体,用于 ADO.NET 实体框架操作,LINQ 到对象用于查询.NET 集合,文件,字符串等。
在本章中,我们使用 Lambda 表达式语法创建了模拟对象。 Moq 框架中提供的另一个令人兴奋的功能是LINQ 到模拟,它允许您使用类似 LINQ 的语法设置模拟。
LINQ 到模拟非常适用于简单的模拟,并且在您真的不关心验证依赖关系时。使用Of<>
方法,您可以创建指定类型的模拟对象。
您可以使用 LINQ 到模拟来在单个模拟和递归模拟上进行多个设置,使用类似 LINQ 的语法:
var loanRepository = Mock.Of<ILoanRepository>
(x => x.Rate==12.5F &&
x.LoanType.Name=="CarLoan"&& LoanType.Id==3 );
在前面的模拟初始化中,Rate
和LoanType
属性被设置为存根,在测试调用期间访问这些属性时,它们将使用属性的默认值。
高级的 Moq 功能
有时,Moq 提供的默认值可能不适用于某些测试场景,您需要创建自定义的默认值生成方法来补充 Moq 当前提供的DefaultValue.Empty
和DefaultValue.Mock
。这可以通过扩展 Moq 4.8 及更高版本中提供的DefaultValueProvider
或LookupOrFallbackDefaultValueProvider
来实现:
public class TestDefaultValueProvider : LookupOrFallbackDefaultValueProvider
{
public TestDefaultValueProvider()
{
base.Register(typeof(string), (type, mock) => string.empty);
base.Register(typeof(List<>), (type, mock) => Activator.CreateInstance(type));
}
}
TestDefaultValueProvider
类创建了子类LookupOrFallbackDefaultValueProvider
,并为string
和List
的默认值进行了实现。对于任何类型的string
,都将返回string.empty
,并创建一个空列表,其中包含任何类型的List
。TestDefaultValueProvider
现在可以在Mock
构造函数中用于模拟创建:
var loanRepository = new Mock<ILoanRepository> { DefaultValueProvider = new TestDefaultValueProvider()};
var objectName = loanRepository.Object.Name;
在前面的代码片段中,objectName
变量将包含一个零长度的字符串,因为TestDefaultValueProvider
中的实现表明应该为string
类型分配一个空字符串。
模拟内部类型
根据项目的要求,您可能需要为内部类型创建模拟对象。在 C#中,内部类型或成员只能在同一程序集中的文件中访问。可以通过向相关项目的AssemblyInfo.cs
文件添加自定义属性来模拟内部类型。
如果包含内部类型的程序集尚未具有AssemblyInfo.cs
文件,您可以添加它。此外,当程序集没有强名称时,您可以添加InternalsVisibleTo
属性,其中排除了公钥。您必须指定要与之共享可见性的项目名称,在这种情况下应该是测试项目。
如果将LoanService
的访问修饰符更改为 internal,您将收到错误消息,LoanService
由于其保护级别而无法访问。为了能够测试LoanService
,而不更改访问修饰符,我们需要将AssemblyInfo.cs
文件添加到项目中,并添加所需的属性,指定测试项目名称,以便与包含LoanService
的程序集共享:
AssemblyInfo.cs
文件中添加的属性如下所示:
[assembly:InternalsVisibleTo("LoanApplication.Tests.Unit")
总结
Moq 框架与 xUnit.net 框架一起使用时,可以提供流畅的单元测试体验,并使整个 TDD 过程变得有价值。Moq 提供了强大的功能,有效使用时,可以简化单元测试的依赖项模拟的创建。
使用 Moq 创建的模拟对象可以让您在单元测试中替换具体的依赖项,以便通过您创建的模拟对象来隔离代码中的不同单元进行测试和后续重构,这有助于编写优雅的生产就绪代码。此外,您可以使用模拟对象来实验和测试依赖项中可用的功能,否则可能无法轻松地使用实际依赖项来完成。
在本章中,我们探讨了模拟的基础知识,并在单元测试中广泛使用了模拟。此外,我们配置了模拟以设置方法和属性,并返回异常。还解释了 Moq 库提供的一些其他功能,并介绍了模拟验证。
项目托管和持续集成将在下一章中介绍。这将包括测试和企业方法来自动运行测试,以确保能够提供有关代码覆盖率的质量反馈。
第七章:持续集成和项目托管
在第四章中,我们探讨了.NET Core 和 C#可用的各种单元测试框架,然后详细探讨了 xUnit.net 框架。然后我们转向第五章中的数据驱动单元测试,这有助于创建可以使用来自不同数据源加载的数据执行的单元测试。在第六章中,我们详细解释了依赖项模拟,其中我们通过Moq 框架创建了模拟对象。
有效的 TDD 实践可以帮助提供有用和深刻的反馈,评估软件项目的代码库质量。通过持续集成,构建自动化和代码自动化测试的过程被提升到了一个新的水平,允许开发团队充分利用现代源代码版本控制系统中提供的基本和高级功能。
正确的持续集成设置和实践会产生有益的持续交付,使软件项目的开发过程能够在项目的生命周期中被交付或部署到生产环境。
在本章中,我们将探讨持续集成和持续交付的概念。本章将涵盖以下主题:
-
持续集成
-
持续交付
-
GitHub 在线项目托管
-
基本的 Git 命令
-
配置 GitHub WebHooks
-
TeamCity 持续集成平台
持续集成
持续集成(CI)是软件开发实践,软件项目的源代码每天由软件开发团队的成员集成到存储库中。最好在开发过程的早期阶段开始。代码集成通常由 CI 工具执行,该工具使用自动构建脚本对代码进行验证。
在开发团队中,通常有多个开发人员在项目的不同部分上工作,项目的源代码托管在存储库中。每个开发人员可以在他们的计算机上拥有主分支或主线的本地版本或工作副本。
负责某个功能的开发人员会对本地副本进行更改,并使用一组准备好的自动化测试来测试代码,以确保代码能够正常工作并不会破坏任何现有的工作功能。一旦可以验证,本地副本将更新为存储库中的最新版本。如果更新导致任何冲突,这些冲突需要在最终提交或集成工作之前解决。
源代码存储库通过保留源文件的快照和版本以及随时间所做的更改,有助于充分对项目的代码库进行版本控制。开发人员可以在必要时恢复或检出以前的提交版本。存储库可以在团队基础设施上本地托管,例如拥有现场Microsoft Team Foundation Server或云存储库,例如GitHub、Bitbucket和其他许多存储库。
CI 工作流
CI 要求建立适当的工作流程。CI 的第一个重要组成部分是建立一个可工作的源代码存储库。这是为了跟踪项目贡献者所做的所有更改,并协调不同的活动。
为了实现一个稳健和有效的 CI 设置,需要涵盖并正确设置以下领域。
单一的源代码存储库
为了有效地使用源代码存储库,所有成功构建项目的所需文件都应该放在一个单一的源代码存储库中。这些文件应该包括源文件、属性文件、数据库脚本和架构,以及第三方库和使用的资产。
其他配置文件也可以放在存储库中,特别是开发环境配置。这将确保项目上的开发人员拥有一致的环境设置。开发团队的新成员可以轻松地使用存储库中可用的配置来设置他们的环境。
构建自动化
CI 工作流程的构建自动化步骤是为了确保项目代码库中的更改被检测并自动进行测试和构建。构建自动化通常是通过构建脚本完成的,这些脚本分析需要进行的更改和编译。源代码应该经常构建,最好是每天或每晚。提交的成功与否是根据代码库是否成功构建来衡量的。
构建自动化脚本应该能够在有或没有测试的情况下构建系统。这应该在构建中进行配置。无论开发人员的集成开发环境是否具有内置的构建管理,都应该在服务器上配置一个中央构建脚本,以确保项目可以构建并在开发服务器上轻松运行。
自动化测试
代码库应该具有自动化测试,覆盖了大部分可能的测试组合,使用相关的测试数据。自动化测试应该使用适当的测试框架,可以覆盖软件项目的所有层或部分。
通过适当的自动化测试,源代码中的错误可以在自动化构建脚本运行时轻松被检测到。将自动化测试整合到构建过程中将确保良好的测试覆盖率,并提供失败或通过测试的报告,以便便于重构代码。
相同的测试和生产环境
为了确保顺利的 CI 体验,重要的是要确保测试和生产环境是相同的。两个环境应该具有类似的硬件和操作系统配置,以及环境设置。
此外,对于使用数据库的应用程序,测试和生产环境应该具有相同的版本。运行时和库也应该是相似的。然而,有时可能无法在每个生产环境实例中进行测试,比如桌面应用程序,但必须确保在测试中使用生产环境的副本。
每日提交
代码库的整体健康状况取决于成功运行的构建过程。项目的主干应该经常更新,以便开发人员提交。提交代码的开发人员有责任确保在推送到存储库之前对代码进行测试。
在开发人员的提交导致构建失败的情况下,不应该拖延。可以回滚以在提交更改之前独立修复问题。项目的主干或主分支应该始终保持良好状态。通常更喜欢每日提交更改。
CI 的好处
将 CI 纳入开发流程中对开发团队非常有价值。CI 流程提供了许多好处,下面将解释其中一些。
快速发现错误
通过 CI 流程,自动化测试经常运行,可以及时发现并修复错误,从而产生高质量的健壮系统。CI 不会自动消除系统中的错误;开发人员必须努力编写经过充分测试的清洁代码。然而,CI 可以促进及时发现本来可能会进入生产环境的错误。
提高生产力
通过 CI,开发团队的整体生产力可以得到提高,因为开发人员可以摆脱单调或手动的任务,这些任务已经作为 CI 过程的一部分自动化了。开发人员可以专注于开发系统的功能。
降低风险
有时,由于固有的复杂性,软件项目往往会因为对需求的低估和其他问题而超出预算和时间表。CI 可以帮助减少与软件开发相关的风险。通过频繁的代码提交和集成,可以建立项目状态的更清晰的图像,并且可以轻松地隔离和处理任何潜在问题。
促进持续交付
对于使用 CI 的开发团队,持续或频繁的部署变得相对容易。这是因为新功能或需求可以快速交付和部署。这将允许用户对产品提供充分和有用的反馈,这可以用来进一步完善软件并提高质量。
CI 工具
有许多可用的 CI 工具,每个工具都具有不同的功能,可以促进简单的 CI 并为部署流水线提供良好的结构。选择 CI 工具取决于几个因素,包括:
-
开发环境、程序语言、框架和应用架构
-
开发团队的构成、经验水平、技能和能力
-
部署环境设置、操作系统和硬件要求
接下来将解释一些流行和最常用的 CI 工具。这些 CI 工具在有效使用时可以帮助开发团队在软件项目中达到质量标准。
微软 Team Foundation Server
微软Team Foundation Server(TFS)是一个集成的服务器套件,包含一组协作工具,以提高软件开发团队的生产力。TFS 提供可以与 IDE(如Visual Studio、Eclipse等)集成的工具和代码编辑器。
TFS 提供了一套工具和扩展,可以促进流畅的 CI 过程。使用 TFS,可以自动化构建、测试和部署应用程序。TFS 通过支持各种编程语言和源代码存储库,提供了很大的灵活性。
TeamCity
TeamCity是 JetBrains 的企业级 CI 工具。它支持捆绑的.NET CLI,并且与 TFS 类似,它提供了自动化部署和组合构建的支持。TeamCity 可以通过 IDE 的可用插件在服务器上验证和运行自动化测试,然后再提交代码。
Jenkins
Jenkins是一个开源的 CI 服务器,可以作为独立运行或在容器中运行,或通过本地系统包安装。它是自包含的,能够自动化测试、构建相关任务和应用部署。通过一组链式工具和插件,Jenkins 可以与 IDE 和源代码存储库集成。
持续交付
持续交付是 CI 的续篇或延伸。它是一组软件开发实践,确保项目的代码可以部署到与生产环境相同的测试环境。持续交付确保所有更改都是最新的,并且一旦更改通过自动化测试,就可以立即发货和部署到生产环境。
众所周知,实践 CI 将促进团队成员之间的良好沟通,并消除潜在风险。开发团队需要进一步实践持续交付,以确保他们的开发活动对客户有益。这可以通过确保应用程序在开发周期的任何阶段都可以部署和准备好生产来实现。
通过开发团队成员的有效沟通和协作,可以实现持续交付。这要求应用程序交付过程的主要部分通过开发和完善的部署管道进行自动化。在任何时候,正在开发的应用程序都应该可以部署。产品所有者或客户将确定应用程序何时部署。
持续交付的好处
通过持续交付,可以提高软件开发团队的生产率,同时降低将软件应用程序发布到生产环境的成本和周转时间。以下是您的团队应该实践持续交付的原因。
降低风险
类似于 CI,持续交付有助于降低通常与软件发布和部署相关的风险。这可以确保零停机和应用程序的高可用性,因为经常进行的更改会定期集成并准备投入生产。
质量软件产品
由于测试、构建和部署过程的自动化,软件产品可以很快地提供给最终用户。用户将能够提供有用和宝贵的反馈意见,这些意见可以用来进一步完善和提高应用程序的质量。
降低成本
由于开发和部署过程的不同部分自动化,软件项目开发和发布成本可以大大降低。这是因为与增量和持续变更相关的成本被消除。
GitHub 在线项目托管
GitHub 是一个源代码托管平台,用于版本控制,允许开发团队成员协作和开发软件项目,无论他们的地理位置在哪里。GitHub 目前托管了多个不同编程语言的开源和专有项目。
GitHub 提供了基本和高级功能,使协作变得更加容易。它本质上是一个基于 Web 的源代码存储库或托管服务,使用 Git 作为版本控制系统,基于 Git 的分布式版本控制行为。
有趣的是,像Microsoft、Google、Facebook和Twitter这样的顶级公司在 GitHub 上托管他们的开源项目。基本上,任何 CI 工具都可以与 GitHub 一起使用。这使得开发团队可以根据预算选择 CI 工具。
除了 GitHub 提供的源代码托管服务外,还可以通过 GitHub 免费托管公共网页。这个功能允许 GitHub 用户创建与托管的开源项目相关的个人网站。
GitHub 支持公共和私人项目存储库托管。任何人都可以查看公共存储库的文件和提交历史,而私人存储库的访问仅限于添加的成员。GitHub 上的私人存储库托管是需要付费的。
项目托管
要创建项目存储库并使用 GitHub 的功能,您需要首先创建一个 GitHub 帐户。这可以通过访问github.com
来完成。成功创建帐户后,您可以继续创建项目存储库。
GitHub 存储库用于组织项目文件夹、文件和资产。文件可以是图像、视频和源文件。在 GitHub 中,存储库通常会有一个包含项目简要描述的README
文件。还可以向项目添加软件许可文件。
以下步骤描述了如何在 GitHub 中创建一个新存储库:
-
使用创建的帐户登录 GitHub。
-
转到
github.com/
的新页面,或者在屏幕右上角,账户的头像或个人资料图片旁边,单击+图标。 -
会显示一个下拉菜单,您可以在其中选择新存储库:
-
将存储库命名为
LoanApplication
并提供项目描述。 -
选择公共,使存储库可以公开访问。
-
选择使用 README 初始化此存储库,以在项目中包括
README
文件。 -
最后,单击创建存储库以创建和初始化存储库:
使用 GitHub Flow 进行分支
GitHub 有一个基于分支的工作流程,称为GitHub Flow,为开发团队提供了很好的支持和工具,以便频繁地协作和部署项目。
GitHub Flow 便于以下操作:
-
从新的或现有存储库创建分支
-
创建、编辑、重命名、移动或删除文件
-
根据约定的更改从分支发送拉取请求
-
根据需要在分支上进行更改
-
当分支准备好合并时合并拉取请求
-
通过在拉取请求或分支页面上使用删除按钮进行清理和清理分支
从项目创建分支是 Git 的核心,并且是 GitHub 流程的扩展,这是 GitHub Flow 的核心概念。分支用于尝试新概念和想法,或用于修复功能。分支是存储库的不同版本。
创建新分支时,通常的做法是从主分支创建分支。这将在那个时间点创建主分支中包含的所有文件和配置的副本。分支在技术上独立于主分支,因为在分支上进行的更改不会影响主分支。但是,可以从主分支拉取新的更新到分支,并且可以将在分支上进行的更更合并回主分支。
GitHub 上的以下图表进一步解释了项目分支的 GitHub 流程,其中对分支进行的提交更改通过拉取请求合并到主分支:
主分支必须始终可以随时部署。创建的分支上的更改应该只在拉取请求打开后合并到主分支。更改将在通过必要的验证和自动化测试后进行仔细审查和接受。
要从之前创建的 LoanApplication
存储库创建新分支,请执行以下步骤:
-
导航到存储库。
-
单击位于文件列表顶部的下拉菜单,标题为分支:主。
-
在新分支文本框中键入提供有关分支的有意义信息的描述性分支名称。
-
单击带有分支名称的突出显示的链接以创建分支:
目前,新创建的分支和主分支完全相同。您可以开始对创建的分支进行更改,添加和修改源文件。更改直接提交到分支而不是主分支。
提交更改有助于正确跟踪随时间对分支所做的更改。每次要提交更改时都会提供提交消息。提交消息提供了对更改内容的详细描述。始终提供提交消息很重要,因为 Git 使用提交跟踪更改。这可以便于在项目上进行轻松的协作,提交消息提供了更改历史记录。
在存储库中,每个提交都是一个独立的更改单元。如果由于提交而导致工作代码库中断,或者提交引入错误,可以回滚提交。
拉取请求
无论您对代码库所做的更改是小还是大,您都可以在项目开发过程中的任何时候发起拉取请求。拉取请求对于 GitHub 中的协作至关重要,因为它们促进了提交的讨论和审查。
要打开拉取请求,请单击“新拉取请求”选项卡。您将被带到拉取请求页面,在那里您可以为请求提供评论或描述,并单击“新拉取请求”按钮:
当您发起拉取请求时,项目的所有者或维护者将收到有关待定更改和您意图进行合并的通知。在对分支所做的更改进行适当审查后,可以提供必要的反馈以进一步完善代码。拉取请求显示了文件的差异以及您的分支和主分支的内容。如果所做的贡献被认为是可以接受的,它们将被接受并合并到主分支中:
审查更改和合并
拉取请求发起后,参与的团队成员对更改进行审查,并根据存储库的当前位置提供评论。您可以在拉取请求保持打开状态时继续进行更改,并且与审查相关的任何评论都将显示在统一的拉取请求视图上。评论以 markdown 编写,包含预格式化的文本块、图像和表情符号。
一旦拉取请求经过审查并被接受,它们将被合并到主分支中。可以按以下步骤在 GitHub 中合并请求。单击“合并拉取请求”按钮将更改合并到主分支中。然后单击“确认合并”,这将将分支上的提交合并到主分支中:
GitHub 中保存了拉取请求的历史记录,可以在以后进行搜索,以确定为什么发起了拉取请求,同时提供对已进行的审查和添加的评论的访问。
基本的 Git 命令
Git 是一种分布式版本控制系统(DVCS)。Git 的分支系统非常强大,使其在其他版本控制系统中脱颖而出。使用 Git,可以创建项目的多个独立分支。分支的创建、合并和删除过程是无缝且非常快速的。
Git 极大地支持无摩擦的上下文切换概念,您可以轻松地创建一个分支来探索您的想法,创建和应用补丁,进行提交,合并分支,然后稍后切换回您正在工作的早期分支。使用的分支工作流程将决定是否为每个功能或一组功能创建一个分支,同时在分支之间轻松切换以测试功能。
通过为生产、测试和开发设置不同的分支,您的开发可以得到组织并且高效,从而控制进入每个分支的文件和提交的流程。通过拥有良好的存储库结构,您可以轻松快速地尝试新的想法,并在完成后删除分支。
Git 具有丰富的有用命令集,掌握后可以完全访问其内部,并允许基本和高级源代码版本控制操作。Git 为 Windows、Macintosh 和 Linux 操作系统提供命令行界面和图形用户界面客户端。命令可以从 Mac 和 Linux 上的终端运行,而在 Windows 上有 Git Bash,用于从命令行运行 Git 的仿真器。
Git 上的可用命令用于执行源代码存储库的初始设置和配置,共享和更新项目,分支和合并,以及各种与源代码版本控制相关的操作。
配置命令
有一组命令可用于配置用户信息,这些命令跨越安装了 Git 的计算机上的所有本地存储库。git config
命令用于获取和设置全局存储库选项。它接受--global
选项,后跟要从全局.gitconfig
文件中获取或设置的特定配置。
要设置将附加到所有提交事务的全局用户名,请运行以下命令:
git config --global user.name "[name]"
也可以设置全局用户电子邮件地址。这将将设置的电子邮件地址附加到所有提交事务。运行以下命令来实现这一点:
git config --global user.email "[email address]"
为了美观,可以使用以下命令启用命令行输出的颜色:
git config --global color.ui auto
初始化存储库命令
git init
命令用于创建一个空的 Git 存储库,以及重新初始化现有存储库。运行git init
命令时,会创建一个.git
目录,以及用于保存对象、refs/heads
、refs/tags
、模板文件和初始 HEAD 文件的子目录,该文件引用主分支的 HEAD。在其最简单的形式中,git init
命令传递存储库名称,这将创建一个具有指定名称的存储库:
git init [repository-name]
要更新并选择新添加的模板或将存储库重新定位到另一个位置,可以在现有存储库中重新运行git init
。该命令不会覆盖存储库中已有的配置。完整的git init
命令概要如下:
git init [-q | --quiet] [--bare] [--template=<template_directory>]
[--separate-git-dir <git dir>] [--shared[=<permissions>]] [directory]
让我们详细讨论前面的命令:
-
当使用
-q
或--quiet
选项时,将打印错误和警告消息,而其他输出消息将被抑制。 -
--bare
选项用于创建一个裸存储库。 -
--template=<template_directory>
用于指定要使用模板的文件夹。 -
--separate-git-dir=<git dir>
用于指示存储库的目录或路径,或者在重新初始化的情况下,移动存储库的路径。 -
--shared[=(false|true|umask|group|all|world|everybody|0xxx)]
选项用于通知 Git 存储库将被多个用户共享。属于同一组的用户可以推送到存储库中。
使用git clone
命令,可以将现有存储库克隆到新目录中。该命令为克隆存储库中的所有分支创建远程跟踪分支。它将下载项目及其整个版本历史。git clone
命令可以通过传递存储库的 URL 作为选项来简单使用:
git clone [url]
传递给命令的 URL 将包含传输协议的信息、远程服务器的地址和存储库路径。Git 支持的协议有 SSH、Git、HTTP 和 HTTPS。该命令还有其他选项可以传递给它,以配置要克隆的存储库。
更改命令
Git 有一组有用的命令,用于检查存储库中文件的状态,审查对文件所做的更新,并提交对项目文件所做的更改。
git status
命令用于显示存储库的工作状态。该命令基本上提供了已更改并准备提交的文件的摘要。它显示了当前 HEAD 提交和索引文件之间存在差异的文件路径。它还显示了索引文件和工作树之间存在差异的文件路径,以及当前未被 Git 跟踪但未在.gitignore
文件中添加的文件路径:
git status
git add
命令使用工作树中找到的内容来更新索引。它基本上是将文件内容添加到索引中。它用于添加现有路径的当前内容。它可以用于删除树中不再存在的路径,或者添加工作树中所做更改的部分内容。
通常的做法是在执行提交之前多次运行该命令。它会添加文件的内容,就像在运行命令时的那样。它接受用于调整其行为的选项:
git add [file]
git commit
命令用于将索引的内容与用户提供的提交消息一起记录或存储到提交中,以描述对项目文件所做的更改。在运行该命令之前,必须使用git add
添加更改。
该命令灵活,使用允许不同的选项来记录更改。一种方法是将具有更改的文件列为提交命令的参数,这会告诉 Git 忽略在索引中暂存的更改,并存储列出的文件的当前内容。
此外,可以使用-a
开关与该命令一起使用,以添加索引中列出但不在工作树中的所有文件的更改。开关-m
用于指定提交消息:
git commit -m "[commit message]"
有时,希望显示索引和工作树之间的差异或更改,两个文件或 blob 对象之间可用的更改。git diff
命令用于此目的。当传递--staged
选项给命令时,Git 显示暂存和最后一个文件版本之间的差异:
git diff
git rm
命令从工作树和索引中删除文件。要删除的文件作为命令的选项传递。作为参数传递给命令的文件将从工作目录中删除并标记为删除。当传递--cached
选项给命令时,Git 不会从工作目录中删除文件,而是从版本控制中删除它:
git rm [files]
git reset
命令可用于取消暂存并保留已在存储库中暂存的文件的内容。该命令用于将当前HEAD
重置为指定状态。此外,它还可以根据指定的选项修改索引和工作树。
该命令有三种形式。第一和第二种形式用于从树复制条目到索引,而最后一种形式用于将当前分支HEAD
设置为特定提交:
git reset [-q] [<tree-ish>] [--] <paths>…
git reset (--patch | -p) [<tree-ish>] [--] [<paths>…]
git reset [--soft | --mixed [-N] | --hard | --merge | --keep] [-q] [<commit>]
分支和合并命令
git branch
命令是 Git 版本控制系统的核心。它用于在存储库中创建、移动、重命名、删除和列出可用的分支。该命令有几种形式,并接受用于设置和配置存储库分支的不同选项。在 Bash 上运行git branch
命令,不指定选项时,将列出存储库中可用的分支。这类似于使用--list
选项。
要创建一个新分支,使用git branch
命令并将分支名称作为参数运行:
git branch [branch name]
--delete
选项用于删除指定的分支,--copy
选项用于创建指定分支的副本以及其reflog
。
要将工作树或分支中的文件更新为另一个工作树中可用的内容,使用git checkout
命令。该命令用于切换分支或恢复工作树文件。与git branch
类似,它有几种形式并接受不同的选项。
当使用分支名称作为参数运行该命令时,Git 切换到指定的分支,更新工作目录,并将 HEAD 指向该分支:
git checkout [branch name]
如前一节所述,分支概念允许开发团队尝试新想法,并从现有项目创建新版本。分支的美妙之处在于能够将一个分支的更改合并到另一个分支中,实质上是将分支或开发线连接或合并在一起。
在 Git 中,git merge
命令用于将从一个分支创建的开发分支集成到单个分支中。例如,如果有一个从主分支创建的开发分支来测试某个功能,当运行git merge [分支名称]
命令时,Git 将追溯对该分支所做的更改。这是因为它是从主分支分出的,直到最新的分支,并将这些更改存储在主分支上的新提交中:
git merge [branch name]
git merge --abort
git merge -- continue
经常,合并过程可能会导致不同分支的文件之间发生冲突。运行git merge --abort
命令将中止合并过程并将分支恢复到合并前的状态。解决了遇到的冲突后,可以运行git merge --continue
重新运行合并过程。
配置 GitHub WebHooks
WebHook是通过 HTTP POST 传递的事件通知。WebHook 通常被称为 Web 回调或 HTTP 推送 API。WebHook 提供了一种机制,应用程序可以实时将数据传递给其他应用程序。
WebHook 与常规 API 不同之处在于,它没有通过轮询数据来获取最新数据的持续资源利用。当数据可用时,订阅者或消费应用程序将通过已在 WebHook 提供程序注册的 URL 接收数据。WebHook 对数据提供程序和消费者都是有效且高效的。
消费 WebHooks
要从 WebHook 接收通知或数据,消费应用程序需要向提供程序注册一个 URL。提供程序将通过 POST 将数据传递到 URL。URL 必须从网络公开访问并可达。
WebHook 提供程序通常通过 HTTP POST 以 JSON、XML 或作为多部分或 URL 编码的表单数据的形式传递数据。订阅者 URL 上的 API 的实现将受到 WebHook 提供程序使用的数据传递模式的影响。
经常会出现需要调试 WebHooks 的情况。这可能是为了解决错误。由于 WebHooks 的异步性质,有时可能会有挑战。首先,必须理解来自 WebHook 的数据。可以使用能够获取和解析 WebHook 请求的工具来实现这一点。根据对 WebHook 数据结构和内容的了解,可以模拟请求以测试 URL API 代码以解决问题。
在从 WebHook 消费数据时,重要的是要注意安全性,并将其纳入消费应用程序的设计中。因为 WebHook 提供程序将 POST 数据到的回调 URL 是公开可用的,所以可能会受到恶意攻击。
一种常见且简单的方法是在 URL 中附加一个强制身份验证令牌,每次请求都将对其进行验证。还可以围绕 URL 构建基本身份验证,以在接受和处理数据之前验证发起 POST 的一方。或者,如果请求签名已经在提供程序端实现,提供程序可以对每个 WebHook 请求进行签名。每个发布的请求的签名将由消费者进行验证。
根据订阅者生成事件的频率,WebHooks 可能会引发大量请求。如果订阅者未能正确设计以处理这样大量的请求,这可能会导致资源利用率高,无论是带宽还是服务器资源。当资源被充分利用并用完时,消费者可能无法处理更多请求,导致消费应用程序的拒绝服务。
GitHub WebHook
在 GitHub 中,WebHooks 用作在事件发生时向外部 Web 服务器发送通知的手段。GitHub WebHooks 允许您设置托管在 GitHub 上的项目以订阅www.github.com平台上可用的所需事件。当事件发生时,GitHub 将向配置的端点发送有效负载。
WebHooks 可以在任何存储库或组织级别进行配置。成功配置后,每当触发订阅的事件或操作时,WebHook 都将被触发。GitHub 允许为存储库或组织的每个事件创建多达 20 个 WebHooks。安装后,WebHooks 可以在存储库或组织上触发。
事件和负载
在 GitHub 的 WebHook 配置点,您可以指定要从 GitHub 接收请求的事件。GitHub 中的 WebHook 请求数据称为有效负载。最好只订阅所需数据的事件,以限制从 GitHub 发送到应用程序服务器的 HTTP 请求。默认情况下,即使在 GitHub 上创建的 WebHook 也订阅了push
事件。事件订阅可以通过 GitHub Web 或 API 进行修改。
以下表格中解释了 GitHub 上可订阅的一些可用事件:
事件 | 描述 |
---|---|
push |
这是默认事件,当对存储库进行 Git 推送时引发。这还包括通过更新引用的 API 操作进行的编辑标签或分支和提交 |
create |
每当创建分支或标签时引发。 |
delete |
每当删除分支或标签时引发。 |
issues |
每当分配问题,取消分配,加标签,取消标签,打开,编辑,里程碑,取消里程碑,关闭或重新打开时引发。 |
repository |
每当创建,删除(仅限组织挂钩),存档,取消存档,公开或私有化存储库时引发。 |
* |
这是通配符事件,表示应通知 URL 以获取任何事件。 |
GitHub 上所有可用事件的完整列表可在developer.github.com/webhooks/
上找到。
push
事件具有包含更详细信息的有效负载。GitHub 中的每个事件都具有特定的有效负载格式,用于描述该事件所需的信息。除了特定于事件的特定字段外,每个事件在有效负载中都包括触发事件的用户或发送者。
有效负载还包括发生事件的存储库或组织以及与事件相关的应用程序。有效负载大小不能超过 5 MB。产生有效负载大小超过 5 MB 的事件将不会触发。传递到 URL 的有效负载通常包含几个标头,其中一些在以下表格中进行了解释。创建新 WebHook 时,GitHub 会向配置的 URL 发送 ping,作为 WebHook 配置成功的指示:
标题 | 描述 |
---|---|
User-Agent |
发起请求的用户代理。这将始终具有前缀Github-Hookshot 。 |
X-GitHub-Event |
包含触发交付的事件名称。 |
X-GitHub-Delivery |
用于标识交付的 GUID。 |
X-Hub-Signature |
此标头包含响应正文的 HMAC 十六进制摘要。如果 WebHook 配置了密钥,则将发送此标头。标头的内容使用sha1 hash 函数和密钥作为 HMAC 密钥生成。 |
设置您的第一个 WebHook
要配置 WebHook,我们将使用之前创建的LoanApplication
存储库。单击存储库的设置页面,单击 Webhooks,然后单击添加 Webhook:
GitHub 将要求您对操作进行身份验证。提供您的 GitHub 帐户密码以继续。将加载 WebHook 配置页面,在那里您可以配置 WebHook 的选项:
-
在有效负载 URL 字段中,提供 Web 应用程序服务器的端点。由于我们将从 Visual Studio 运行
LoanApplication
,我们将使用以下 URL:http://localhost:54113/API/webhook
。 -
将内容类型下拉菜单更改为 application/json,以允许 GitHub 通过 POST 以 JSON 发送有效负载。
-
接下来,选择“让我选择单个事件”选项。这将显示所有可用 WebHook 事件的完整列表。
-
选择您希望 WebHook 订阅的事件。
-
最后,单击添加 Webhook按钮,完成配置:
创建 WebHook 后,GitHub 将尝试向 WebHook 中配置的 URL 发送 ping。指定的 URL http://localhost:54113/api/webhook
是本地开发,不是公开可用的。因此,GitHub 无法访问,导致 WebHook 请求失败:
为了将开发环境暴露给 GitHub 以使其可访问互联网,我们可以使用Ngrok,这是一个用于暴露本地 Web 服务器的公共 URL 的工具。转到ngrok.com/download
下载适用于您操作系统的 Ngrok。
运行以下命令告诉 Ngrok 将端口54113
暴露到互联网上:
ngrok http -host-header="localhost:54113" 54113
Ngrok 将创建一个公共 URL,可访问并转发到开发 PC 上指定的端口。在这种情况下,Ngrok 生成了http://d73c1ef5.ngrok.io
作为将转发到端口54113
的 URL:
接下来,更新之前创建的 WebHook 的有效负载 URL 为http://d73c1ef5.ngrok.io/api/webhook
。单击“更新 WebHook”按钮以保存更改。在“最近的交付”选项卡下,单击未能交付的有效负载的 GUID。这将打开一个屏幕,显示 JSON 有效负载,包括请求和响应。
单击“重新交付”按钮。这将显示一个对话框,询问您是否要重新交付有效负载。单击“是,重新交付此有效负载”按钮。这将尝试将 JSON 有效负载 POST 到有效负载 URL 字段中指定的新端点。这次,有效负载交付将成功,HTTP 响应代码为200
,表示端点已成功联系:
您可以编写消费者 Web 应用程序以按照您的意愿处理有效负载数据。成功配置后,GitHub 将在 WebHook 订阅的任何事件引发时将有效负载 POST 到端点。
TeamCity CI 平台
TeamCity 是 JetBrains 推出的一个独立于平台的 CI 工具。它是一个用户友好的 CI 工具,专门为软件开发人员和工程师设计。TeamCity 是一个强大而功能强大的 CI 工具,因为它能够充分优化集成周期。
TeamCity 还可以在不同平台和环境上同时并行运行构建。使用 TeamCity,您可以获得有关代码质量、构建持续时间甚至创建自定义指标的定制统计信息。它具有运行代码覆盖率和查找重复项的功能。
TeamCity 概念
在本节中,将解释 TeamCity 中经常使用的一些基本术语。这是为了理解成功配置构建步骤以及质量连续过程所需的一些概念。让我们来看看一些基本术语:
-
项目:这是正在开发的软件项目。它可以是一个发布或特定版本。此外,它包括构建配置的集合。
-
构建代理:这是执行构建过程的软件。它独立安装在 TeamCity 服务器之外。它们可以都驻留在同一台机器上,也可以在运行相似或不同操作系统的不同机器上。对于生产目的,通常建议它们都安装在不同的机器上以获得最佳性能。
-
TeamCity 服务器:TeamCity 服务器监视构建代理,同时使用兼容性要求将构建分发到连接的代理,并报告进度和结果。结果中的信息包括构建历史记录、日志和构建数据。
-
构建:这是创建软件项目的特定版本的过程。触发构建过程会将其放入构建队列,并在有可用代理运行时启动。构建代理在构建完成后将构建产物发送到 TeamCity 服务器。
-
构建队列:这是一个包含已触发但尚未启动的构建的列表。TeamCity 服务器读取待处理构建的队列,并在代理空闲时将构建分发给兼容的构建代理。
-
构建产物:这些是构建生成的文件。这些可以包括
dll
文件、可执行文件、安装程序、报告、日志文件等。 -
构建配置:这是描述构建过程的一组设置。这包括 VCS 根、构建步骤和构建触发器。
-
构建步骤:构建步骤由与构建工具集成的构建运行器表示,例如 MSBuild,代码分析引擎和测试框架,例如 xUnit.net。构建步骤本质上是要执行的任务,可以包含顺序执行的许多步骤。
-
构建触发器:这是一组规则,触发某些事件的新构建,例如当 VCS 触发新构建时,当 TeamCity 检测到配置的 VCS 根中的更改时。
-
VCS 根:这是一组版本控制设置,包括源路径、凭据和其他定义 TeamCity 与版本控制系统通信方式的设置。
-
更改:这是对项目源代码的修改。当更改已提交到版本控制系统但尚未包含在构建中时,对于某个构建配置,更改被称为待处理更改。
安装 TeamCity 服务器
TeamCity 可以在开发团队的服务器基础设施上本地托管,也可以通过与云解决方案集成来托管 TeamCity。这允许虚拟机被配置以运行 TeamCity。TeamCity 安装将包括服务器安装和默认的构建代理。
要安装 TeamCity 服务器,请转到 JetBrains 下载站点,获取 TeamCity 服务器的免费专业版,该版本附带免费许可密钥,可解锁 3 个构建代理和 100 个构建配置。如果您使用 Windows 操作系统,请运行捆绑了 Tomcat Java JRE 1.8 的下载.exe
。按照对话框提示提取和安装 TeamCity 核心文件。
在安装过程中,您可以设置 TeamCity 将监听的端口,也可以将其保留为默认的8080
。如果安装成功,TeamCity 将在浏览器中打开,并提示您通过在服务器上指定数据目录位置来完成安装过程。指定路径并单击“继续”:
在数据目录位置路径初始化后,您将进入数据库选择页面,在该页面上,您将有选择任何受支持的数据库的选项。选择内部(HSQLDB)并单击“继续”按钮:
数据库配置将需要几秒钟,然后您将看到许可协议页面。接受许可协议并单击“继续”按钮。下一页是管理员帐户创建页面。使用所需的凭据创建帐户以完成安装。安装完成后,您将被引导到概述页面:
TeamCity CI 工作流
TeamCity 构建生命周期描述了服务器和代理之间的数据流。这基本上是传递给代理的信息以及 TeamCity 检索结果的过程。工作流描述了为项目配置的构建步骤是如何端到端执行的:
-
TeamCity 服务器检测 VCS 根中的更改,并将其持久化到数据库中。
-
构建触发器注意到数据库中的更改并将构建添加到队列中。
-
TeamCity 服务器将队列中的构建分配给兼容的空闲构建代理。
-
构建代理执行构建步骤。在执行构建步骤期间,代理将构建进度报告发送到服务器。构建代理将构建进度报告发送到 TeamCity 服务器,以允许实时监控构建过程。
-
构建代理在构建完成后将构建产物发送到 TeamCity 服务器。
配置和运行构建
基本上,项目应包含运行成功构建所需的配置和项目属性。使用 TeamCity CI 服务器,可以自动化运行测试、执行环境检查、编译、构建,并提供可部署版本的项目。
安装的 TeamCity 服务器可以在安装期间指定的端口上本地访问。在这种情况下,我们将使用http://localhost:8060
。要创建一个 TeamCity 项目,请转到服务器 URL 并使用之前创建的凭据登录。点击“项目”菜单,然后点击“创建项目”按钮。
您将看到创建项目的几个选项,可以从存储库、手动创建,或连接到 GitHub、Bitbucket 或 Visual Studio Team Services 中的任何一个。点击“来自 GitHub.com”按钮,将 TeamCity 连接到我们之前在 GitHub 上创建的LoanApplication
存储库:
添加连接对话框显示了 TeamCity 将连接到 GitHub。需要创建一个新的 GitHub OAuth 应用程序才能成功将 TeamCity 连接到 GitHub。要在 GitHub 中创建新的 OAuth 应用程序,请执行以下步骤:
-
在主页 URL 字段中,提供 TeamCity 服务器的 URL:
http://localhost:8060
。 -
在授权回调 URL 中提供
http://localhost:8060/oauth/github/accessToken.html
。 -
点击“注册应用程序”按钮完成注册。将为您创建新的客户端密钥和客户端 ID:
- 创建的新客户端 ID 和客户端密钥将用于填写 TeamCity 上添加连接对话框中的字段,以创建从 TeamCity 到 GitHub 的连接。点击“保存”按钮保存设置:
-
下一步是授权 TeamCity 访问 VCS。点击“登录 GitHub”按钮即可完成。将显示一个页面,您必须授权 TeamCity 访问 GitHub 帐户中的公共和私有存储库。点击“授权”完成流程。
-
TeamCity 将启动到 GitHub 的连接,以检索可以选择的可用存储库列表。您可以筛选列表以选择所需的存储库:
- TeamCity 将验证与所选存储库的连接。如果成功,将显示“创建项目”。在此页面上,将显示项目和构建配置名称。如果需要,可以进行修改。点击“继续”按钮继续进行项目设置:
- 在下一个屏幕上,TeamCity 将扫描连接的存储库以查找可用的配置构建步骤。您可以点击“创建构建步骤”按钮添加构建步骤:
-
在新的构建步骤屏幕上,您必须从下拉菜单中选择构建运行程序。
-
为构建步骤指定一个描述性名称。
-
然后选择要构建运行程序执行的命令。填写所有其他必填字段
-
点击保存按钮保存构建步骤:
-
保存构建步骤后,将显示可用构建步骤的列表,您可以按照相同的步骤添加更多构建步骤。此外,您可以重新排序构建步骤,并通过单击“自动检测构建步骤”按钮来检测构建步骤。
-
配置构建步骤后,您可以通过单击 TeamCity 网页顶部菜单上的运行链接来运行构建。这将重定向到构建结果页面,您可以在那里查看构建的进度,随后审查或编辑构建配置:
总结
在本章中,我们广泛探讨了 CI 的概念,这是一种软件开发实践,可以帮助开发团队频繁地集成其代码。开发人员预计每天多次检查代码,然后由 CI 工具通过自动化构建过程进行验证。
还讨论了 CI 的常见术语,用于持续交付。我们解释了如何在 GitHub 和在线托管平台上托管软件项目的步骤,然后讨论了基本的 Git 命令。
探讨了创建 GitHub WebHooks 以配置与构建管理系统集成的过程。最后,给出了安装和配置 TeamCity CI 平台的逐步说明。
在下一章中,我们将探讨 Cake Bootstrapper 并配置 TeamCity 以使用名为 Cake 的跨平台构建自动化系统来清理、构建和恢复软件包依赖项并测试我们的LoanApplication
项目。
第八章:创建持续集成构建流程
持续反馈、频繁集成和及时部署,这些都是持续集成实践带来的结果,可以极大地减少与软件开发过程相关的风险。开发团队可以提高生产率,减少部署所需的时间,并从持续集成中获得巨大的好处。
在第七章中,持续集成和项目托管,我们设置了 TeamCity,一个强大的持续集成工具,简化和自动化了管理源代码检入和更改、测试、构建和部署软件项目的过程。我们演示了在 TeamCity 中创建构建步骤,并将其连接到我们在 GitHub 上的LoanApplication
项目。TeamCity 具有内置功能,可以连接到托管在 GitHub 或 Bitbucket 上的软件项目。
CI 流程将许多不同的步骤整合成一个易于重复的过程。这些步骤根据软件项目类型而有所不同,但有一些步骤是常见的,并适用于大多数项目。可以使用构建自动化系统自动化这些步骤。
在本章中,我们将配置 TeamCity 使用名为Cake的跨平台构建自动化系统,来清理、构建、恢复软件包依赖,并测试LoanApplication
解决方案。本章后面,我们将探讨在Visual Studio Team Services中使用 Cake 任务创建构建步骤。我们将涵盖以下主题:
-
安装 Cake 引导程序
-
使用 C#编写构建脚本
-
Visual Studio 的 Cake 扩展
-
使用 Cake 任务创建构建步骤
-
使用 Visual Studio Team Services 进行 CI
安装 Cake 引导程序
Cake是一个跨平台的构建自动化框架。它是一个用于编译代码、运行测试、复制文件和文件夹,以及运行与构建相关任务的构建自动化框架。Cake 是开源的,源代码托管在 GitHub 上。
Cake 具有使文件系统路径操作变得简单的功能,并具有操作 XML、启动进程、I/O 操作和解析 Visual Studio 解决方案的功能。可以使用 C#领域特定语言自动化 Cake 构建相关活动。
它采用基于依赖的编程模型进行构建自动化,通过该模型,在任务之间声明依赖关系。基于依赖的模型非常适合构建自动化,因为大多数自动化构建步骤都是幂等的。
Cake 真正实现了跨平台;其 NuGet 包 Cake.CoreCLR 允许它在 Windows、Linux 和 Mac 上使用.NET Core 运行。它有一个 NuGet 包,可以在 Windows 上依赖.NET Framework 4.6.1 运行。此外,它可以使用 Mono 框架在 Linux 和 Max 上运行,建议使用 Mono 版本 4.4.2。
无论使用哪种 CI 工具,Cake 在所有支持的工具中都具有一致的行为。它广泛支持大多数构建过程中使用的工具,包括MSBuild、ILMerge、Wix和Signtool。
安装
为了使用 Cake 引导程序,需要安装 Cake。安装 Cake 并测试运行的简单方法是克隆或下载一个.zip
文件,即位于github.com/cake-build/example
的 Cake 构建示例存储库。示例存储库包含一个简单的项目和运行 Cake 脚本所需的所有文件。
在示例存储库中,有一些感兴趣的文件——build.ps1
和build.sh
。它们是引导程序脚本,确保 Cake 所需的依赖项与 Cake 和必要的文件一起安装。这些脚本使调用 Cake 变得更容易。build.cake
文件是构建脚本;构建脚本可以重命名,但引导程序将默认定位build.cake
文件。tools.config
/packages.config
文件是包配置,指示引导程序脚本在tools
文件夹中安装哪些 NuGet 包。
解压下载的示例存储库存档文件。在 Windows 上,打开 PowerShell 提示符并通过运行.\build.ps1
执行引导程序脚本。在 Linux 和 Mac 上,打开终端并运行.\build.sh
。引导程序脚本将检测到计算机上未安装 Cake,并自动从 NuGet 下载它。
根据引导程序脚本的执行,在 Cake 下载完成后,将运行下载的示例build.cake
脚本,该脚本将清理输出目录,并在构建项目之前恢复引用的 NuGet 包。运行build.cake
文件时,它应该清理测试项目,恢复 NuGet 包,并运行项目中的单元测试。运行设置
和测试运行摘要
将如下截图所示呈现:
蛋糕引导程序可以通过从托管在 GitHub 上的Cake 资源存储库(github.com/cake-build/resources
)下载并安装,其中包含配置文件和引导程序。引导程序将下载 Cake 和构建脚本所需的必要工具,从而避免在源代码存储库中存储二进制文件。
PowerShell 安全
通常,PowerShell 可能会阻止运行build.ps1
文件。您可能会在 PowerShell 屏幕上收到错误消息,指出由于系统上禁用了运行脚本,无法加载build.ps1
。由于 PowerShell 中默认的安全设置,对文件的运行限制。
打开 PowerShell 窗口,将目录更改为之前下载的 Cake 构建示例存储库的文件夹,并运行.\build.ps1
命令。如果系统上的执行策略未从默认值更改,这应该会给您以下错误:
要查看系统上当前的执行策略配置,请在 PowerShell 屏幕上运行Get-ExecutionPolicy -List
命令;此命令将呈现一个包含可用范围和执行策略的表格,就像以下屏幕上显示的那样。根据您运行 PowerShell 的方式,您的实例可能具有不同的设置:
要更改执行策略以允许随后运行脚本,运行Set-ExecutionPolicy RemoteSigned -Scope Process
命令,该命令旨在将进程范围从未定义更改为RemoteSigned
。运行该命令将在 PowerShell 屏幕上显示一个警告并提示您的 PC 可能会面临安全风险。输入Y以确认并按Enter。运行命令时,PowerShell 屏幕上显示的内容如下截图所示:
这将更改 PC 的执行策略并允许运行 PowerShell 脚本。
蛋糕引导程序安装步骤
安装 Cake 引导程序的步骤对于不同平台是相似的,只有一些小的差异。执行以下步骤设置引导程序。
步骤 1
导航到 Cake 资源存储库以下载引导程序。对于 Windows,下载 PowerShell build.ps1
文件,对于 Mac 和 Linux,下载build.sh
bash 文件。
在 Windows 上,打开一个新的 PowerShell 窗口并运行以下命令:
Invoke-WebRequest https://cakebuild.net/download/bootstrapper/windows -OutFile build.ps1
在 Mac 上,从新的 shell 窗口运行以下命令:
curl -Lsfo build.sh https://cakebuild.net/download/bootstrapper/osx
在 Linux 上,打开一个新的 shell 来运行以下命令:
curl -Lsfo build.sh https://cakebuild.net/download/bootstrapper/linux
步骤 2
创建一个 Cake 脚本来测试安装。创建一个build.cake
文件;应该放在与build.sh
文件相同的位置:
var target = Argument("target", "Default");
Task("Default")
.Does(() =>
{
Information("Installation Successful");
});
RunTarget(target);
步骤 3
现在可以通过调用 Cake 引导程序来运行步骤 2中创建的 Cake 脚本。
在 Windows 上,您需要指示 PowerShell 允许运行脚本,方法是更改 Windows PowerShell 脚本执行策略。由于执行策略,PowerShell 脚本执行可能会失败。
要执行 Cake 脚本,请运行以下命令:
./build.ps1
在 Linux 或 Mac 上,您应该运行以下命令,以授予当前所有者执行脚本的权限:
chmod +x build.sh
运行命令后,可以调用引导程序来运行步骤 2中创建的 Cake 脚本:
./build.sh
使用 C#编写构建脚本
使用 Cake 自动化构建和部署任务可以避免与项目部署相关的问题和头痛。构建脚本通常包含构建和部署源代码以及配置文件和项目的其他工件所需的步骤和逻辑。
使用 Cake 资源库上可用的示例build.cake
文件可以作为编写项目的构建脚本的起点。但是,为了实现更多功能,我们将介绍一些基本的 Cake 概念,以便编写用于自动化构建和部署任务的健壮脚本。
任务
在 Cake 的构建自动化的核心是任务。Cake 中的任务是用于按照所需的顺序执行特定操作或活动的简单工作单元。Cake 中的任务可以具有指定的条件、相关依赖项和错误处理。
可以使用Task
方法来定义任务,将任务名称或标题作为参数传递给它:
Task("Action")
.Does(() =>
{
// Task code goes here
});
例如,以下代码片段中的build
任务会清理debugFolder
文件夹以删除其中的内容。运行任务时,将调用CleanDirectory
方法:
var debugFolder = Directory("./bin/Debug");
Task("CleanFolder")
.Does(() =>
{
CleanDirectory(debugFolder);
});
Cake 允许您使用 C#在任务中使用异步和等待功能来创建异步任务。实质上,任务本身将以单个线程同步运行,但任务中包含的代码可以受益于异步编程功能并利用异步 API。
Cake 具有DoesForEach
方法,可用于将一系列项目或产生一系列项目的委托作为任务的操作添加。当将委托添加到任务时,委托将在任务执行后执行:
Task("LongRunningTask")
.Does(async () =>
{
// use await keyword to multi thread code
});
通过将DoesForEach
链接到Task
方法来定义DoesForEach
,如以下代码片段所示:
Task("ProcessCsv")
.Does(() =>
{
})
.DoesForEach(GetFiles("**/*.csv"), (file) =>
{
// Process each csv file.
});
TaskSetup 和 TaskTeardown
TaskSetup
和TaskTeardown
用于包装要在执行每个任务之前和之后执行的构建操作。当执行诸如配置初始化和自定义日志记录等操作时,这些方法尤其有用:
TaskSetup(setupContext =>
{
var taskName =setupContext.Task.Name;
// perform action
});
TaskTeardown(teardownContext =>
{
var taskName =teardownContext.Task.Name;
// perform action
});
与任务的TaskSetup
和TaskTeardown
类似,Cake 具有Setup
和Teardown
方法,可用于在第一个任务之前和最后一个任务之后执行操作。这些方法在构建自动化中非常有用,例如,当您打算在运行任务之前启动一些服务器和服务以及在运行任务后进行清理活动时。应在RunTarget
之前调用Setup
或Teardown
方法以确保它们正常工作:
Setup(context =>
{
// This will be executed BEFORE the first task.
});
Teardown(context =>
{
// This will be executed AFTER the last task.
});
配置和预处理指令
Cake 操作可以通过使用环境变量、配置文件和将参数传递到 Cake 可执行文件来进行控制。这是基于指定的优先级,配置文件会覆盖环境变量和传递给 Cake 的参数,然后覆盖在环境变量和配置文件中定义的条目。
例如,如果您打算指定工具路径,即 cake 在恢复工具时检查的目录,您可以创建CAKE_PATHS_TOOLS
环境变量名称,并将值设置为 Cake 工具文件夹路径。
在使用配置文件时,文件应放置在与build.cake
文件相同的目录中。可以在配置文件中指定 Cake 工具路径,就像在以下代码片段中一样,它会覆盖环境变量中设置的任何内容:
[Paths]
Tools=./tools
Cake 工具路径可以直接传递给 Cake,这将覆盖环境变量和配置文件中设置的内容:
cake.exe --paths_tools=./tools
Cake 具有默认用于配置条目的值,如果它们没有使用任何配置 Cake 的方法进行覆盖。这里显示了可用的配置条目及其默认值,以及如何使用配置方法进行配置:
预处理器指令用于在 Cake 中引用程序集、命名空间和脚本。预处理器行指令在脚本执行之前运行。
依赖关系
通常,您将创建依赖于其他任务完成的任务;为了实现这一点,您可以使用IsDependentOn
和IsDependeeOf
方法。要创建依赖于另一个任务的任务,请使用IsDependentOn
方法。在以下构建脚本中,Cake 将在执行Task2
之前执行Task1
:
Task("Task1")
.Does(() =>
{
});
Task("Task2")
.IsDependentOn("Task1")
.Does(() =>
{
});
RunTarget("Task2");
使用IsDependeeOf
方法,您可以定义具有相反关系的任务依赖关系。这意味着依赖于任务的任务在该任务中定义。前面的构建脚本可以重构为使用反向关系:
Task("Task1")
.IsDependeeOf("Task2")
.Does(() =>
{
});
Task("Task2")
.Does(() =>
{
});
RunTarget("Task2");
标准
在 Cake 脚本中使用标准允许您控制构建脚本的执行流程。标准是必须满足才能执行任务的谓词。标准不会影响后续任务的执行。标准用于根据指定的配置、环境状态、存储库分支和任何其他所需选项来控制任务执行。
最简单的形式是使用WithCriteria
方法来指定特定任务的执行标准。例如,如果您只想在下午清理debugFolder
文件夹,可以在以下脚本中指定标准:
var debugFolder = Directory("./bin/Debug");
Task("CleanFolder")
.WithCriteria(DateTime.Now.Hour >= 12)
.Does(() =>
{
CleanDirectory(debugFolder);
});
RunTarget("CleanFolder");
您可以有一个任务的执行取决于另一个任务;在以下脚本中,CleanFolder
任务的标准将在创建任务时设置,而ProcessCsv
任务评估的标准将在任务执行期间进行:
var debugFolder = Directory("./bin/Debug");
Task("CleanFolder")
.WithCriteria(DateTime.Now.Hour >= 12)
.Does(() =>
{
CleanDirectory(debugFolder);
});
Task("ProcessCsv")
.WithCriteria(DateTime.Now.Hour >= 12)
.IsDependentOn("CleanFolder")
.Does(() =>
{
})
.DoesForEach(GetFiles("**/*.csv"), (file) =>
{
// Process each csv file.
});
RunTarget("ProcessCsv");
一个更有用的用例是编写一个带有标准的 Cake 脚本,检查本地构建并执行一些操作,以清理、构建和部署项目。将定义四个任务,每个任务执行一个要执行的操作,第四个任务将链接这些操作在一起:
var isLocalBuild = BuildSystem.IsLocalBuild
Task("Clean")
.WithCriteria(isLocalBuild)
.Does(() =>
{
// clean all projects in the soution
});
Task("Build")
.WithCriteria(isLocalBuild)
.Does(() =>
{
// build all projects in the soution
});
Task("Deploy")
.WithCriteria(isLocalBuild)
.Does(() =>
{
// Deploy to test server
});
Task("Main")
.IsDependentOn("Clean")
.IsDependentOn("Build")
.IsDependentOn("Deploy")
.Does(() =>
{
});
RunTarget("Main");
Cake 的错误处理和最终块
Cake 具有错误处理技术,您可以使用这些技术从错误中恢复,或者在构建过程中发生异常时优雅地处理异常。有时,构建步骤调用外部服务或进程;调用这些外部依赖项可能会导致错误,从而导致整个构建失败。强大的构建应该在不停止整个构建过程的情况下处理这些异常。
OnError
方法是一个任务扩展,用于在构建中生成异常时执行操作。您可以在OnError
方法中编写代码来处理错误,而不是强制终止脚本:
Task("Task1")
.Does(() =>
{
})
.OnError(exception =>
{
// Code to handle exception.
});
有时,您可能希望忽略抛出的错误并继续执行生成异常的任务;您可以使用ContinueOnError
任务扩展来实现这一点。使用ContinueOnError
方法时,您不能与之一起使用OnError
方法:
Task("Task1")
.ContinueOnError()
.Does(() =>
{
});
如果您希望报告任务中生成的异常,并仍然允许异常传播并采取其课程,请使用ReportError
方法。如果由于任何原因,在ReportError
方法内引发异常,则会被吞噬:
Task("Task1")
.Does(() =>
{
})
.ReportError(exception =>
{
// Report generated exception.
});
此外,您可以使用DeferOnError
方法将任何抛出的异常推迟到执行的任务完成。这将确保任务在抛出异常并使脚本失败之前执行其指定的所有操作:
Task("Task1")
.Does(() =>
{
})
.DeferOnError();
最后,您可以使用Finally
方法执行任何操作,而不管任务执行的结果如何:
Task("Task1")
.Does(() =>
{
})
.Finally(() =>
{
// Perform action.
});
LoanApplication 构建脚本
为了展示 Cake 的强大功能,让我们编写一个 Cake 脚本来构建LoanApplication
项目。Cake 脚本将清理项目文件夹,还原所有包引用,构建整个解决方案,并运行解决方案中的单元测试项目。
以下脚本设置要在整个脚本中使用的参数,定义目录和任务以清理LoanApplication.Core
项目的bin
文件夹,并使用DotNetCoreRestore
方法恢复包。可以使用DotNetCoreRestore
方法来还原 NuGet 包,该方法又使用dotnet restore
命令:
//Arguments
var target = Argument("target", "Default");
var configuration = Argument("configuration", "Release");
var solution = "./LoanApplication.sln";
// Define directories.
var buildDir = Directory("./LoanApplication.Core/bin") + Directory(configuration);
//Tasks
Task("Clean")
.Does(() =>
{
CleanDirectory(buildDir);
});
Task("Restore-NuGet-Packages")
.IsDependentOn("Clean")
.Does(() =>
{
Information("Restoring NuGet Packages");
DotNetCoreRestore();
});
脚本的后部分包含使用DotNetCoreBuild
方法构建整个解决方案的任务,该方法使用DotNetCoreBuildSettings
对象中提供的设置使用dotnet build
命令构建解决方案。使用DotNetCoreTest
方法执行测试项目,该方法使用DotNetCoreTestSettings
对象中提供的设置在解决方案中的所有测试项目中运行测试使用dotnet test
:
Task("Build")
.IsDependentOn("Restore-NuGet-Packages")
.Does(() =>
{
Information("Build Solution");
DotNetCoreBuild(solution,
new DotNetCoreBuildSettings()
{
Configuration = configuration
});
});
Task("Run-Tests")
.IsDependentOn("Build")
.Does(() =>
{
var testProjects = GetFiles("./LoanApplication.Tests.Units/*.csproj");
foreach(var project in testProjects)
{
DotNetCoreTool(
projectPath: project.FullPath,
command: "xunit",
arguments: $"-configuration {configuration} -diagnostics -stoponfail"
);
}
});
Task("Default")
.IsDependentOn("Run-Tests");
RunTarget(target);
Cake Bootstrapper 可用于通过从 PowerShell 窗口调用引导程序来运行 Cake build
文件。当调用引导程序时,Cake 将使用build
文件中可用的任务定义来开始执行定义的构建任务。执行开始时,执行的进度和状态将显示在 PowerShell 窗口中:
每个任务的执行进度将显示在 PowerShell 窗口中,显示 Cake 当前正在进行的所有活动。当构建执行完成时,将显示脚本中每个任务的执行持续时间,以及所有任务的总执行时间:
Visual Studio 的 Cake 扩展
Visual Studio 的 Cake 扩展为 Visual Studio 带来了对 Cake 构建脚本的语言支持。该扩展支持新模板、任务运行器资源管理器以及引导 Cake 文件的功能。可以在Visual Studio Market Place下载Visual Studio 的 Cake 扩展(marketplace.visualstudio.com/items?itemName=vs-publisher-1392591.CakeforVisualStudio
)。
从市场下载的.vsix
文件本质上是一个.zip
文件。该文件包含要安装在 Visual Studio 中的 Cake 扩展的内容。运行下载的.vsix
文件时,它将为 Visual Studio 2015 和 2017 安装 Cake 支持:
Cake 模板
安装扩展后,在创建新项目时,Visual Studio 的可用选项中将添加一个Cake 模板。该扩展将添加四种不同的 Cake 项目模板类型:
-
Cake Addin:用于创建 Cake Addin 的项目模板
-
Cake Addin Unit Test Project:用于为 Cake Addin 创建单元测试的项目模板,其中包括作为指南的示例
-
Cake Addin Unit Test Project (empty):用于为 Cake Addin 创建单元测试的项目模板,但不包括示例
-
Cake Module:此模板用于创建 Cake 模块,并附带示例
以下图片显示了不同的 Cake 项目模板:
任务运行器资源管理器
在使用 Cake 脚本进行构建自动化的 Visual Studio 解决方案中,当发现build.cake
文件时,Cake 任务运行器将被触发。Cake 扩展激活了任务运行器资源管理器集成,允许您在 Visual Studio 中直接运行包含的绑定的 Cake 任务。
要打开任务运行器资源管理器,请右键单击 Cake 脚本(build.cake
文件)并从显示的上下文菜单中选择任务运行器资源管理器;它应该打开任务运行器资源管理器,并在窗口中列出 Cake 脚本中的所有任务:
有时,当右键单击 Cake 脚本时,任务运行器资源管理器可能不会显示在上下文菜单中。如果是这样,请单击“查看”菜单,选择“其他窗口”,然后选择“任务运行器资源管理器”以打开它:
通过安装 Cake 扩展,Visual Studio 的构建菜单现在将包含一个 Cake 构建的条目,可以用来安装 Cake 配置文件、PowerShell 引导程序和 Bash 引导程序,如果它们在解决方案中尚未配置的话:
现在,您可以通过双击或右键单击并选择运行,直接从任务运行器资源管理器中执行每个任务。任务执行的进度将显示在任务运行器资源管理器上:
语法高亮显示
Cake 扩展为 Visual Studio 添加了语法高亮显示功能。这是 IDE 的常见功能,其中源代码以不同的格式、颜色和字体呈现。源代码高亮显示是基于定义的组、类别和部分进行的。
安装扩展后,任何带有.cake
扩展名的文件都可以在 Visual Studio 中打开,并具有完整的任务和语法高亮显示。目前,Visual Studio 中的.cake
脚本文件没有 IntelliSense 支持;预计这个功能将在以后推出。
以下截图显示了在 Visual Studio 中对build.cake
文件进行的语法高亮显示:
使用 Cake 任务来构建步骤
使用任务运行器资源管理器来运行用 Cake 脚本编写的构建任务更加简单和方便。这通常是通过 Visual Studio 的 Cake 扩展或直接调用 Cake 引导文件来完成的。然而,还有一种更有效的替代方法,那就是使用 TeamCity CI 工具来运行 Cake 构建脚本。
TeamCity 构建步骤可用于执行 Cake 脚本作为构建步骤执行过程的一部分。让我们按照以下步骤为LoanApplication
项目创建执行 Cake 脚本的构建步骤:
-
单击添加构建步骤以打开新的构建步骤窗口。
-
在运行器类型中,选择 PowerShell,因为 Cake 引导文件将由 PowerShell 调用。
-
在文本字段中为构建步骤命名。
-
在脚本选项中,选择文件。这是因为它是一个
.ps1
文件,将被调用,而不是一个直接的 PowerShell 脚本。 -
要选择脚本文件,请单击树图标;这将加载 GitHub 上托管的项目中可用的文件和文件夹。在显示的文件列表中选择
build.ps1
文件。 -
单击保存按钮以保存更改并创建构建步骤:
新的构建步骤应该出现在 TeamCity 项目中配置的可用构建步骤列表中。在参数描述选项卡中,将显示有关构建步骤的信息,显示运行器类型和要执行的 PowerShell 文件,如下截图所示:
使用 Visual Studio Team Services 进行 CI
Microsoft Visual Studio Team Services(VSTS)是Team Foundation Server(TFS)的云版本。它提供了让开发人员协作进行软件项目开发的出色功能。与 TFS 类似,它提供了易于理解的服务器管理体验,并增强了与远程站点的连接。
VSTS 为实践 CI 和持续交付(CD)的开发团队提供了出色的体验。它支持 Git 存储库进行源代码控制,易于理解的报告以及可定制的仪表板,用于监视软件项目的整体进展。
此外,它还具有内置的构建和发布管理功能,规划和跟踪项目,使用Kanban和Scrum方法管理代码缺陷和问题。它同样还有一个内置的维基用于与开发团队进行信息传播。
您可以通过互联网连接到 VSTS,使用开发人员需要已创建的 Microsoft 帐户。但是,组织中的开发团队可以配置 VSTS 身份验证以与Azure Active Directory(Azure AD)一起使用,或者设置 Azure AD 以具有 IP 地址限制和多因素身份验证等安全功能。
在 VSTS 中设置项目
要开始使用 VSTS,请转到www.visualstudio.com/team-services/
创建免费帐户。如果您已经创建了 Microsoft 帐户,可以使用该帐户登录,或者使用您组织的 Active Directory 身份验证。您应该被重定向到以下屏幕:
在 VSTS 中,每个帐户都有自己定制的 URL,其中包含一个团队项目集合,例如,packt.visualstudio.com
。您应该在字段中指定 URL,并选择要与项目一起使用的版本控制。VSTS 目前支持 Git 和 Team Foundation 版本控制。单击“继续”以继续进行帐户创建。
创建帐户后,单击“项目”菜单导航到项目页面,然后单击“新建项目”创建新项目。这将加载项目创建屏幕,在那里您将指定项目名称,描述,要使用的版本控制以及工作项过程。单击“创建”按钮完成项目创建:
项目创建完成后,您将看到“入门”屏幕。该屏幕提供了克隆现有项目或将现有项目推送到其中的选项。让我们导入我们之前在 GitHub 上创建的LoanApplication
项目。单击“导入”按钮开始导入过程:
在导入屏幕上,指定源类型和 GitHub 存储库的 URL,并提供 GitHub 登录凭据。单击“导入”按钮开始导入过程:
您将看到一个显示导入进度的屏幕。根据要导入的项目的大小,导入过程可能需要一些时间。当过程完成时,屏幕上将显示“导入成功”消息:
单击“单击此处导航到代码视图”以查看 VSTS 导入的文件和文件夹。文件屏幕将显示项目中可用的文件和文件夹以及提交和日期详细信息:
在 VSTS 中安装 Cake
Cake 在 VSTS 中有一个扩展,允许您相对容易地直接从 VSTS 构建任务运行 Cake 脚本。安装了扩展后,Cake 脚本就不必像在 TeamCity 中运行 Cake 脚本时那样使用 PowerShell 来运行。
在 Visual Studio Marketplace 上导航到 Cake Build 的 URL:marketplace.visualstudio.com/items/cake-build.cake
。点击“获取免费”按钮开始将 Cake 扩展安装到 VSTS 中:
单击“获取免费”按钮将重定向到 VSTS Visual Studio | Marketplace 集成页面。在此页面上,选择要安装 Cake 的帐户,然后单击“安装”按钮:
安装成功后,将显示一条消息,说明一切都已设置,类似于以下截图中的内容。点击“转到帐户”按钮,将您重定向到 VSTS 帐户页面:
添加构建任务
成功将 Cake 安装到 VSTS 后,您可以继续配置代码的构建方式以及软件的部署方式。VSTS 提供了简单的方法来构建您的源代码并发布您的软件。
要创建由 Cake 提供支持的 VSTS 构建,请单击“生成和发布”,然后选择“生成”子菜单。这将加载构建定义页面;单击此页面上的+新建按钮,开始构建创建过程。
将显示一个屏幕,选择存储库,如下截图所示。屏幕提供了从不同来源选择存储库的选项:
选择存储库来源后,单击“继续”按钮以加载模板屏幕。在此屏幕上,您可以选择用于配置构建的构建模板。VSTS 为各种支持的项目类型提供了特色模板。每个模板都配置了与模板项目相关的构建步骤:
向下滚动到模板列表的底部,或者在搜索框中简单地输入Empty
以选择空模板。将鼠标悬停在模板上以激活“应用”按钮,然后单击该按钮,以继续到任务创建页面:
当任务屏幕加载时,单击+按钮以向构建添加任务。滚动浏览显示的任务模板列表,选择 Cake,或使用搜索框过滤到 Cake。单击“添加”按钮,将 Cake 任务添加到构建阶段可用任务列表中:
添加 Cake 任务后,单击任务以加载属性屏幕。单击“浏览”按钮以选择包含LoanApplication
项目的构建脚本的build.cake
文件,以与构建任务关联。您可以修改显示名称并更改目标和详细程度属性。此外,如果有要传递给 Cake 脚本的参数,可以在提供的字段中提供它们:
单击“保存并排队”菜单,然后选择“保存并排队”,以确保创建的构建将在托管代理上排队。这将加载构建定义和排队屏幕,您可以在其中指定注释和代理队列:
托管代理是运行构建作业的软件。使用托管代理是执行构建的最简单和最简单的方法。托管代理由 VSTS 团队管理。
如果构建成功排队,您应该会收到屏幕上显示构建编号的通知,指出构建已排队:
单击构建编号以导航到构建执行页面。托管代理将处理队列并执行队列中构建的配置任务。构建代理将显示构建执行的进度。执行完成后,将报告构建的成功或失败。
VSTS 提供了巨大的好处,并简化了 CI 和 CD 过程。它提供了工具和功能,允许不同的 IDE 轻松集成,并使端到端的开发和项目测试相对容易。
摘要
在本章中,我们详细探讨了 Cake 构建自动化。我们介绍了安装 Cake 和 Cake Bootstrapper 的步骤。之后,我们探讨了编写 Cake 构建脚本和任务创建的过程,并提供了可用于各种构建活动的示例任务。
此外,我们为LoanApplication
项目创建了一个构建脚本,其中包含了清理、恢复和构建解决方案中所有项目以及构建解决方案中包含的单元测试项目的任务。
后来,我们在 TeamCity 中创建了一个构建步骤,通过使用 PowerShell 作为运行器类型来执行 Cake 脚本。在本章的后面,我们介绍了如何设置 Microsoft Visual Studio Team Services,安装 Cake 到 VSTS,并配置了一个包含 Cake 任务的构建步骤。
在最后一章中,我们将探讨如何使用 Cake 脚本执行 xUnit.net 测试。在本章的后面,我们将探讨.NET Core 版本控制、.NET Core 打包和元包。我们将为 NuGet 分发打包LoanApplication
项目。
第九章:测试和打包应用程序
在第八章中,创建持续集成构建流程,我们介绍了 Cake 自动化构建工具的安装和设置过程。此外,我们广泛演示了使用 Cake 编写构建脚本的过程,以及其丰富的 C#领域特定语言。我们还介绍了在 Visual Studio 中安装 Cake 扩展,并使用任务资源管理器窗口运行 Cake 脚本。
CI 流程为软件开发带来的好处不言而喻;它通过早期和快速检测,促进了项目代码库中错误的轻松修复。使用 CI,可以自动化运行和报告单元测试项目的测试覆盖率,以及项目构建和部署。
为了有效地利用 CI 流程的功能,代码库中的单元测试项目应该运行,并且应该由 CI 工具生成测试覆盖报告。在本章中,我们将修改 Cake 构建脚本,以运行我们的一系列 xUnit.net 测试。
在本章后面,我们将探讨.NET Core 版本控制以及它对应用程序开发的影响。最后,我们将为在.NET Core 支持的各种平台上分发的LoanApplication
项目进行打包。之后,我们将探讨如何将.NET Core 应用程序打包以在 NuGet 上共享。
本章将涵盖以下主题:
-
使用 Cake 执行 xUnit.net 测试
-
.NET Core 版本控制
-
.NET Core 包和元包
-
用于 NuGet 分发的打包
使用 Cake 执行 xUnit.net 测试
在第八章中,创建持续集成构建流程,在LoanApplication 构建脚本部分,我们介绍了使用 Cake 自动化构建脚本创建和运行构建步骤的过程。使用 xUnit 控制台运行程序和 xUnit 适配器,可以更轻松地从 Visual Studio IDE、Visual Studio Code 或任何其他适合构建.NET 和.NET Core 应用程序的 IDE 中获取单元测试的测试结果和覆盖率。然而,为了使 CI 流程和构建流程完整和有效,单元测试项目应该作为构建步骤的一部分进行编译和执行。
在.NET 项目中执行 xUnit.net 测试
Cake 对运行 xUnit.net 测试有很好的支持。Cake 有两个别名,用于运行不同版本的 xUnit.net 测试——xUnit 用于运行早期版本的 xUnit.net,xUnit2 用于 xUnit.net 的版本 2。要使用别名的命令,必须在XUnit2Settings
类中指定到 xUnit.net 的ToolPath,或者在build.cake
文件中包含工具指令,以指示 Cake 从 NuGet 获取运行 xUnit.net 测试所需的二进制文件。
以下是包含 xUnit.net 工具指令的语法:
#tool "nuget:?package=xunit.runner.console"
Cake 的XUnit2Alias
有不同形式的重载,用于运行指定程序集中的 xUnit.net 版本测试。该别名位于 Cake 的Cake.Common.Tools.XUnit
命名空间中。第一种形式是XUnit2(ICakeContext, IEnumerable<FilePath>)
,用于在IEnumerable
参数中运行指定程序集中的所有 xUnit.net 测试。以下脚本显示了如何使用GetFiles
方法将要执行的测试程序集获取到IEnumerable
对象,并将其传递给XUnit2
方法:
#tool "nuget:?package=xunit.runner.console"
Task("Execute-Test")
.Does(() =>
{
var assemblies = GetFiles("./LoanApplication.Tests.Unit/bin/Release/LoanApplication.Tests.Unit.dll");
XUnit2(assemblies);
});
XUnit2(ICakeContext, IEnumerable<FilePath>, XUnit2Settings)
别名类似于第一种形式,还增加了XUnit2Settings
类,用于指定 Cake 应该如何执行 xUnit.net 测试的选项。以下代码片段描述了用法:
#tool "nuget:?package=xunit.runner.console"
Task("Execute-Test")
.Does(() =>
{
var assemblies = GetFiles("./LoanApplication.Tests.Unit/bin/Release/LoanApplication.Tests.Unit.dll");
XUnit2(assemblies,
new XUnit2Settings {
Parallelism = ParallelismOption.All,
HtmlReport = true,
NoAppDomain = true,
OutputDirectory = "./build"
});
});
另外,XUnit2
别名允许传递字符串的IEnumerable
,该字符串应包含要执行的 xUnit.net 版本 2 测试项目的程序集路径。形式为XUnit2(ICakeContext, IEnumerable<string>)
,以下代码片段描述了用法:
#tool "nuget:?package=xunit.runner.console"
Task("Execute-Test")
.Does(() =>
{
XUnit2(new []{
"./LoanApplication.Tests.Unit/bin/Release/LoanApplication.Tests.Unit.dll",
"./LoanApplication.Tests/bin/Release/LoanApplication.Tests.dll"
});
});
在.NET Core 项目中执行 xUnit.net 测试
为了成功完成构建过程,重要的是在解决方案中运行测试项目,以验证代码是否正常工作。通过使用DotNetCoreTest
别名,相对容易地在.NET Core 项目中运行 xUnit.net 测试,使用dotnet test
命令。为了访问dotnet-xunit工具的其他功能,最好使用DotNetCoreTool
运行测试。
在.NET Core 项目中,通过运行dotnet test
命令来执行单元测试。该命令支持编写.NET Core 测试的所有主要单元测试框架,前提是该框架具有测试适配器,dotnet test
命令可以集成以公开可用的单元测试功能。
使用 dotnet-xunit 框架工具运行.NET Core 测试可以访问 xUnit.net 中的功能和设置,并且是执行.NET Core 测试的首选方式。要开始,应该通过编辑.csproj
文件并在ItemGroup
部分包含DotNetCliToolReference
条目,将 dotnet-xunit 工具安装到.NET Core 测试项目中。还应该添加xunit.runner.visualstudio
和Microsoft.NET.Test.Sdk
包,以便能够使用dotnet test
或dotnet xunit
命令执行测试:
<ItemGroup>
<DotNetCliToolReference Include="dotnet-xunit" Version="2.3.1" />
<PackageReference Include="xunit" Version="2.3.1" />
PackageReference Include="xunit.runner.visualstudio" Version="2.3.1" />
</ItemGroup>
此外,还有其他参数可用于在使用dotnet xunit
命令执行.NET Core 单元测试时自定义 xUnit.net 框架的行为。可以通过在终端上运行dotnet xunit --help
命令来显示这些参数及其用法。
Cake 具有别名,可用于调用 dotnet SDK 命令来执行 xUnit.net 测试。DotNetCoreRestore
别名使用dotnet restore
命令还原解决方案中使用的 NuGet 包。此外,DotNetCoreBuild
别名负责使用dotnet build
命令构建.NET Core 解决方案。使用DotNetCoreTest
别名执行测试项目中的单元测试,该别名使用dotnet test
命令。请参见以下 Cake 片段,了解别名的用法。
var configuration = Argument("Configuration", "Release");
Task("Execute-Restore")
.Does(() =>
{
DotNetCoreRestore();
});
Task("Execute-Build")
.IsDependentOn("Execute-Restore")
.Does(() =>
{
DotNetCoreBuild("./LoanApplication.sln"
new DotNetCoreBuildSettings()
{
Configuration = configuration
}
);
});
Task("Execute-Test")
.IsDependentOn("Execute-Build")
.Does(() =>
{
var testProjects = GetFiles("./LoanApplication.Tests.Unit/*.csproj");
foreach(var project in testProjects)
{
DotNetCoreTest(
project.FullPath,
new DotNetCoreTestSettings()
{
Configuration = configuration,
NoBuild = true
}
);
}
});
另外,可以使用DotNetCoreTool
别名来执行.NET Core 项目的 xUnit.net 测试。DotNetCoreTool
是 Cake 中的通用别名,可用于执行任何 dotnet 工具。这是通过提供工具名称和必要的参数(如果有)来完成的。DotNetCoreTool
公开了dotnet xunit
命令中可用的其他功能,从而灵活地调整单元测试的执行方式。使用DotNetCoreTool
别名时,需要手动将命令行参数传递给别名。请参见以下片段中别名的用法:
var configuration = Argument("Configuration", "Release");
Task("Execute-Test")
.Does(() =>
{
var testProjects = GetFiles("./LoanApplication.Tests.Unit/*.csproj");
foreach(var testProject in testProjects)
{
DotNetCoreTool(
projectPath: testProject.FullPath,
command: "xunit",
arguments: $"-configuration {configuration} -diagnostics -stoponfail"
);
}
});
.NET Core 版本
对.NET Core SDK 和运行时进行版本控制使得平台易于理解,并且具有更好的灵活性。.NET Core 平台本质上是作为一个单元分发的,其中包括不同发行版的框架、工具、安装程序和 NuGet 包。此外,对.NET Core 平台进行版本控制可以在不同的.NET Core 平台上实现并行应用程序开发,具有很大的灵活性。
从.NET Core 2.0 开始,使用了易于理解的顶级版本号来对.NET Core 进行版本控制。一些.NET Core 版本组件一起进行版本控制,而另一些则不是。然而,从 2.0 版本开始,对.NET Core 发行版和组件采用了一致的版本控制策略,其中包括网页、安装程序和 NuGet 包。
.NET Core 使用的版本模型基于框架的运行时组件[major].[minor]
版本号。与运行时版本号类似,SDK 版本使用带有额外独立[patch]
的[major].[minor]
版本号,该版本号结合了 SDK 的功能和补丁语义。
版本原则
截至.NET Core 2.0 版本,采用了以下原则:
-
将所有.NET Core 发行版版本化为x.0.0,例如第一个版本为 2.0.0,然后一起向前发展
-
文件和软件包名称应清楚地表示组件或集合及其版本,将版本分歧调和留给次要和主要版本边界
-
高阶版本和链接多个组件的安装程序之间应存在清晰的沟通。
此外,从.NET Core 2.0 开始,共享框架和相关运行时、.NET Core SDK 和相关.NET Core CLI 以及Microsoft.NETCore.App
元包的版本号被统一了。使用单个版本号可以更容易地确定在开发机器上安装的 SDK 版本以及在将应用程序移动到生产环境时应该使用的共享框架版本。
安装程序
每日构建和发布的下载符合新的命名方案。从.NET Core 2.0 开始,下载中提供的安装程序 UI 也已修改,以显示正在安装的组件的名称和版本。命名方案格式如下:
[product]-[component]-[major].[minor].[patch]-[previewN]-[optional build #]-[rid].[file ext]
此外,格式详细显示了正在下载的内容,其版本,可以在哪种操作系统上使用,以及它是否可读。请参见下面显示的格式示例:
dotnet-runtime-2.0.7-osx-x64.pkg # macOS runtime installer
dotnet-runtime-2.0.7-win-x64.exe # Windows SDK installer
安装程序中包含的网站和 UI 字符串的描述保持简单、准确和一致。有时,SDK 版本可能包含多个运行时版本。在这种情况下,当安装过程完成时,安装程序 UX 仅在摘要页面上显示 SDK 版本和已安装的运行时版本。这适用于 Windows 和 macOS 的安装程序。
此外,可能需要更新.NET Core 工具,而不一定需要更新运行时。在这种情况下,SDK 版本会增加,例如到 2.1.2。下次更新时,运行时版本将增加,例如,下次更新时,运行时和 SDK 都将作为 2.1.3 进行发布。
软件包管理器
.NET Core 平台的灵活性使得分发不仅仅由微软完成;其他实体也可以分发该平台。该平台的灵活性使得为 Linux 发行版所有者分发安装程序和软件包变得容易。同时,也使得软件包维护者可以轻松地将.NET Core 软件包添加到其软件包管理器中。
最小软件包集的详细信息包括dotnet-runtime-[major].[minor]
,这是具有特定 major+minor 版本组合的.NET 运行时,并且在软件包管理器中可用。dotnet-sdk
包括前向 major、minor、patch 版本以及更新卷。软件包集中还包括dotnet-sdk-[major].[minor]
,这是具有最高指定版本的共享框架和最新主机的 SDK,即dotnet-host
。
Docker
与安装程序和软件包管理器类似,docker 标签采用命名约定,其中版本号放在组件名称之前。可用的 docker 标签包括以下运行时版本:
-
1.0.8-runtime
-
1.0.8-sdk
-
2.0.4-runtime
-
2.0.4-sdk
-
2.1.1-runtime
-
2.1.1-sdk
当包含在 SDK 中的.NET Core CLI 工具被修复并重新发布时,SDK 版本会增加,例如,当版本从 2.1.1 增加到版本 2.1.2。此外,重要的是要注意,SDK 标签已更新以表示 SDK 版本而不是运行时。基于此,运行时将在下次发布时赶上 SDK 版本编号,例如,下次发布时,SDK 和运行时将都采用版本号 2.1.3。
语义版本控制
.NET Core 使用语义版本控制来描述.NET Core 版本中发生的更改的类型和程度。语义版本控制(SemVer)使用MAJOR.MINOR.PATCH
版本模式:
MAJOR.MINOR.PATCH[-PRERELEASE-BUILDNUMBER]
SemVer 的PRERELEASE
和BUILDNUMBER
部分是可选的,不是受支持的版本的一部分。它们专门用于夜间构建、从源目标进行本地构建和不受支持的预览版本。
当旧版本不再受支持时,采用现有依赖项的较新MAJOR
版本,或者切换兼容性怪癖的设置时,将递增版本的MAJOR
部分。每当现有依赖项有较新的MINOR
版本,或者有新的依赖项、公共 API 表面积或新行为添加时,将递增MINOR
。每当现有依赖项有较新的PATCH
版本、对较新平台的支持或有错误修复时,将递增PATCH
。
当MAJOR
被递增时,MINOR
和PATCH
被重置为零。同样,当MINOR
被递增时,PATCH
被重置为零,而MAJOR
不受影响。这意味着每当有多个更改时,受影响的最高元素会被递增,而其他部分会被重置为零。
通常,预览版本的版本会附加-preview[number]-([build]|"final")
,例如,2.1.1-preview1-final。开发人员可以根据.NET Core 的两种可用发布类型长期支持(LTS)和当前,选择所需的功能和稳定级别。
LTS 版本是一个相对更稳定的平台,支持时间更长,而新功能添加得更少。当前版本更频繁地添加新功能和 API,但允许安装更新的时间较短,提供更频繁的更新,并且支持时间比 LTS 更短。
.NET Core 软件包和 metapackages
.NET Core 平台是作为一组通常称为 metapackages 的软件包进行发布的。该平台基本上由 NuGet 软件包组成,这有助于使其轻量级且易于分发。.NET Core 中的软件包提供了平台上可用的原语和更高级别的数据类型和常用实用程序。此外,每个软件包直接映射到一个具有相同名称的程序集;System.IO.FileSystem.dll
程序集是System.IO.FileSystem
软件包。
.NET Core 中的软件包被定义为细粒度。这带来了巨大的好处,因为在该平台上开发的应用程序的结果是印刷小,只包含在项目中引用和使用的软件包。未引用的软件包不会作为应用程序分发的一部分进行发布。此外,细粒度软件包可以提供不同的操作系统和 CPU 支持,以及仅适用于一个库的特定依赖关系。.NET Core 软件包通常与平台支持一起发布。这允许修复作为轻量级软件包更新进行分发和安装。
以下是.NET Core 可用的一些 NuGet 软件包:
-
System.Runtime
:这是.NET Core 软件包,包括Object
、String
、Array
、Action
和IList<T>
。 -
System.Reflection
:此软件包包含用于加载、检查和激活类型的类型,包括Assembly
、TypeInfo
和MethodInfo
。 -
System.Linq
:用于查询对象的一组类型,包括Enumerable
和ILookup<TKey,TElement>
。 -
System.Collections
:用于通用集合的类型,包括List<T>
和Dictionary<TKey,TValue>
。 -
System.Net.Http
:用于 HTTP 网络通信的类型,包括HttpClient
和HttpResponseMessage
。 -
System.IO.FileSystem
:用于读取和写入本地或网络磁盘存储的类型,包括文件和目录。
在您的.Net Core 项目中引用软件包相对容易。例如,如果您在项目中包含System.Reflection
,则可以在项目中引用它,如下所示:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Reflection" Version="4.3.0" />
</ItemGroup>
</Project>
Metapackage
元包是除了项目中已引用的目标框架之外,添加到.NET Core 项目中的引用或依赖关系。例如,您可以将Microsoft.NETCore.App
或NetStandard.Library
添加到.NET Core 项目中。
有时,需要在项目中使用一组包。这是通过使用元包来完成的。元包是经常一起使用的一组包。此外,元包是描述一组或一套包的 NuGet 包。当指定框架时,元包可以为这些包创建一个框架。
当您引用一个元包时,实质上是引用了元包中包含的所有包。实质上,这使得这些包中的库在使用 Visual Studio 进行项目开发时可以进行智能感知。此外,这些库在项目发布时也将可用。
在.NET Core 项目中,元包是由项目中的目标框架引用的,这意味着元包与特定框架强烈关联或绑定在一起。元包可以访问已经确认和测试过可以一起工作的一组包。
.NET Standard 元包是NETStandard.Library
,它构成了.NET 标准中的一组库。这适用于.NET 平台的不同变体:.NET Core、.NET Framework 和 Mono 框架。
Microsoft.NETCore.App
和Microsoft.NETCore.Portable.Compatibility
是主要的.NET Core 元包。Microsoft.NETCore.App
描述了构成.NET Core 分发的库集,并依赖于NETStandard.Library
。
Microsoft.NETCore.Portable.Compatibility
描述了一组 facade,使得基于 mscorlib 的可移植类库(PCLs)可以在.NET Core 上工作。
Microsoft.AspNetCore.All 元包
Microsoft.AspNetCore.All
是 ASP.NET Core 的元包。该元包包括由 ASP.NET Core 团队支持和维护的包,Entity Framework Core 支持的包,以及 ASP.NET Core 和 Entity Framework Core 都使用的内部和第三方依赖项。
针对 ASP.NET Core 2.0 的可用默认项目模板使用Microsoft.AspNetCore.All
包。ASP.NET Core 版本号和 Entity Framework Core 版本号与Microsoft.AspNetCore.All
元包的版本号相似。ASP.NET Core 2.x 和 Entity Framework Core 2.x 中的所有可用功能都包含在Microsoft.AspNetCore.All
包中。
当您创建一个引用Microsoft.AspNetCore.All
元包的 ASP.NET Core 应用程序时,.NET Core Runtime Store 将可供您使用。.NET Core Runtime Store 公开了运行 ASP.NET Core 2.x 应用程序所需的运行时资源。
在部署过程中,引用的 ASP.NET Core NuGet 包中的资源不会与应用程序一起部署,这些资源位于.NET Core Runtime Store 中。这些资源经过预编译以提高性能,加快应用程序启动时间。此外,排除未使用的包是可取的。这是通过使用包修剪过程来完成的。
要使用Microsoft.AspNetCore.All
包,应将其添加为.NET Core 的.csproj
项目文件的引用,就像以下 XML 配置中所示:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>netcoreapp2.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.All" Version="2.0.0" />
</ItemGroup>
</Project>
NuGet 分发的打包
.NET Core 的灵活性不仅限于应用程序的开发,还延伸到部署过程。部署.NET Core 应用程序可以采用两种形式——基于框架的部署(FDD)和独立部署(SCD)。
使用 FDD 方法需要在开发应用程序的计算机上安装系统范围的.NET Core。安装的.NET Core 运行时将被应用程序和在该计算机上部署的其他应用程序共享。
这使得应用程序可以在不同版本或安装的 .NET Core 框架之间轻松移植。此外,使用此方法时,部署将是轻量级的,只包含应用程序的代码和使用的第三方库。使用此方法时,为应用程序创建了 .dll
文件,以便可以从命令行启动。
SCD 允许您将应用程序与运行所需的 .NET Core 库和 .NET Core 运行时一起打包。实质上,您的应用程序不依赖于部署计算机上已安装的 .NET Core 的存在。
使用此方法时,可执行文件(本质上是平台特定的 .NET Core 主机的重命名版本)将作为应用程序的一部分打包。在 Windows 上,此可执行文件为 app.exe
,在 Linux 和 macOS 上为 app
。与使用 依赖于框架的方法 部署应用程序时一样,为应用程序创建了 .dll
文件,以便启动应用程序。
dotnet publish 命令
dotnet publish
命令用于编译应用程序,并在将应用程序和依赖项复制到准备部署的文件夹之前检查应用程序的依赖项。执行该命令是准备 .NET Core 应用程序进行部署的唯一官方支持的方式。概要在此处:
dotnet publish [<PROJECT>] [-c|--configuration] [-f|--framework] [--force] [--manifest] [--no-dependencies] [--no-restore] [-o|--output] [-r|--runtime] [--self-contained] [-v|--verbosity] [--version-suffix]
dotnet publish [-h|--help]
运行命令时,输出将包含 .dll
程序集中包含的中间语言(IL)代码,包含项目依赖项的 .deps.json
文件,指定预期共享运行时的 .runtime.config.json
文件,以及从 NuGet 缓存中复制到输出文件夹中的应用程序依赖项。
命令的参数和选项在此处解释:
-
PROJECT
:用于指定要编译和发布的项目,默认为当前文件夹。 -
-c|--configuration:用于指定构建配置的选项,可取
Debug
和Release
值,默认值为Debug
。 -
-f|--framework
:目标框架选项,与命令一起指定时,将为目标框架发布应用程序。 -
--force:用于强制解析依赖项,类似于删除
project.assets.json
文件。 -
-h|--help:显示命令的帮助信息。
-
--manifest <PATH_TO_MANIFEST_FILE>:用于指定要在修剪应用程序发布的软件包时使用的一个或多个目标清单。
-
--no-dependencies:此选项用于忽略项目对项目的引用,但会还原根项目。
-
--no-restore:指示命令不执行隐式还原。
-
-o|--output <OUTPUT_DIRECTORY>:用于指定输出目录的路径。如果未指定该选项,则默认为 FDD 的
./bin/[configuration]/[framework]/
或 SCD 的./bin/[configuration]/[framework]/[runtime]
。 -
-r|--runtime <RUNTIME_IDENTIFIER>:用于为特定运行时发布应用程序,仅在创建 SCD 时使用。
-
--self-contained:用于指定 SCD。当指定运行时标识符时,默认值为 true。
-
-v|--verbosity
:用于指定 dotnet publish
命令的详细程度。允许的值为q[uiet]
、n[ormal]
、m[inimal]
、diag[nostic]
和d[etailed]
。 -
--version-suffix <VERSION_SUFFIX>:用于指定在项目文件的版本字段中替换星号 (
*
) 时要使用的版本后缀。
命令的使用示例是在命令行上运行 dotnet publish
。这将发布当前文件夹中的项目。要发布本书中使用的 LoanApplication
项目,可以运行 dotnet publish
命令。这将使用项目中指定的框架发布应用程序。ASP.NET Core 应用程序依赖的解决方案中的项目将与之一起构建。请参阅以下屏幕截图:
在netcoreapp2.0
文件夹中创建了一个publish
文件夹,其中将复制所有编译文件和依赖项:
创建一个 NuGet 软件包
NuGet是.NET 的软件包管理器,它是一个开源的软件包管理器,为构建在.NET Framework 和.NET Core 平台上的应用程序提供了更简单的版本控制和分发库的方式。NuGet 库是.NET 的中央软件包存储库,用于托管包作者和消费者使用的所有软件包。
使用.NET Core 的dotnet pack
命令可以轻松创建 NuGet 软件包。运行此命令时,它会构建.NET Core 项目,并从中创建一个 NuGet 软件包。打包的.NET Core 项目的 NuGet 依赖项将被添加到.nuspec
文件中,以确保在安装软件包时它们被解析。显示以下命令概要:
dotnet pack [<PROJECT>] [-c|--configuration] [--force] [--include-source] [--include-symbols] [--no-build] [--no-dependencies]
[--no-restore] [-o|--output] [--runtime] [-s|--serviceable] [-v|--verbosity] [--version-suffix]
dotnet pack [-h|--help]
这里解释了命令的参数和选项:
-
PROJECT
用于指定要打包的项目,可以是目录的路径或.csproj
文件。默认为当前文件夹。 -
c|--configuration
:此选项用于定义构建配置。它接受Debug
和Release
值。默认值为Debug
。 -
--force
:用于强制解析依赖项,类似于删除project.assets.json
文件。 -
-h|--help
:显示命令的帮助信息。 -
-include-source
:用于指定源文件包含在 NuGet 软件包的src
文件夹中。 -
--include-symbols
:生成nupkg
符号。 -
--no-build
:这是为了指示命令在打包之前不要构建项目。 -
--no-dependencies
:此选项用于忽略项目对项目的引用,但恢复根项目。 -
--no-restore
:这是为了指示命令不执行隐式还原。 -
-o|--output <OUTPUT_DIRECTORY>
:用于指定输出目录的路径,以放置构建的软件包。 -
-r|--runtime <RUNTIME_IDENTIFIER>
:此选项用于指定要为其还原软件包的目标运行时。 -
-s|--serviceable
:用于在软件包中设置可服务标志。 -
-v|--verbosity <LEVEL>
:用于指定命令的详细程度。允许的值为q[uiet]
、m[inimal]
、n[ormal]
、d[etailed]
和diag[nostic]
。 -
--version-suffix <VERSION_SUFFIX>
:用于指定在项目文件的版本字段中替换星号(*
)时要使用的版本后缀。
运行dotnet pack
命令将打包当前目录中的项目。要打包LoanApplication.Core
项目,可以运行以下命令:
dotnet pack C:\LoanApplication\LoanApplication.Core\LoanApplication.Core.csproj --output nupkgs
运行该命令时,LoanApplication.Core
项目将被构建并打包到项目文件夹中的nupkgs
文件中。将创建LoanApplication.Core.1.0.0.nupkg
文件,其中包含打包项目的库:
应用程序打包后,可以使用dotnet nuget push
命令将其发布到 NuGet 库。为了能够将软件包推送到 NuGet,您需要注册 NuGet API 密钥。在上传软件包到 NuGet 时,这些密钥需要作为dotnet nuget push
命令的选项进行指定。
运行dotnet nuget push LoanApplication.Core.1.0.0.nupkg -k <api-key> -s https://www.nuget.org/
命令将创建的 NuGet 软件包推送到库中,从而使其他开发人员可以使用。运行该命令时,将建立到 NuGet 服务器的连接,以在您的帐户下推送软件包:
将软件包推送到 NuGet 库后,登录您的帐户,您可以在已发布软件包的列表中找到推送的软件包:
当您将软件包上传到 NuGet 库时,其他程序员可以直接从 Visual Studio 使用 NuGet 软件包管理器搜索您的软件包,并在其项目中添加对库的引用。
总结
在本章中,我们首先使用 Cake 执行了 xUnit.net 测试。此外,我们广泛讨论了.NET Core 的版本控制、概念以及它对.NET Core 平台应用开发的影响。之后,我们为 NuGet 分发打包了本书中使用的LoanApplication
项目。
在本书中,您已经经历了一次激动人心的 TDD 之旅。使用 xUnit.net 单元测试框架,TDD 的概念被介绍并进行了广泛讨论。还涵盖了数据驱动的单元测试,这使您能够使用不同数据源的数据来测试您的代码。
Moq 框架被用来介绍和解释如何对具有依赖关系的代码进行单元测试。TeamCity CI 服务器被用来解释 CI 的概念。Cake,一个跨平台构建系统被探讨并用于创建在 TeamCity 中执行的构建步骤。此外,另一个 CI 工具 Microsoft VSTS 被用来执行 Cake 脚本。
最后,有效地使用 TDD 在代码质量和最终应用方面是非常有益的。通过持续的实践,本书中解释的所有概念都可以成为您日常编程例行的一部分。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· .NET10 - 预览版1新功能体验(一)
2020-05-17 HowToDoInJava Spring 教程·翻译完成