端到端(E2E)系统测试是系统整体的自动化测试。作为一名程序员,对代码的单元测试只是整个系统大局中的一个小因素。因此,在本章中,我们将关注以下主题:
本章结束时,您将获得以下技能:
那么,您已经完成了项目,并且所有的单元测试都通过了。但是,您的项目是更大系统的一部分。这个更大的系统需要进行测试,以确保您的代码以及与之交互的其他代码能够按照预期协同工作。单独测试的代码在集成到更大的系统中时可能会中断,而现有系统在添加新代码时可能会中断,因此执行 E2E 测试非常重要,也称为集成测试。
集成测试负责从头到尾测试整个程序流程。集成测试通常在需求收集阶段开始。首先,收集和记录系统的各种需求。然后设计所有组件并为每个子系统设计测试,然后为整个系统设计 E2E 测试。然后,根据需求编写代码并实现自己的单元测试。一旦代码完成且测试全部通过,那么代码将集成到测试环境中的整个系统中,并执行 E2E 测试。通常,E2E 测试是手动执行的,尽管在可能的情况下,它们也可以自动化。下图显示了一个系统,该系统由两个子系统(带模块和数据库)组成。在 E2E 测试中,所有这些模块将通过手动、自动化或两种方法进行测试:
每个系统的输入和输出是测试的主要焦点。你必须扪心自问,每个系统传入和传出的信息是否正确?
另外,在构建 E2E 测试时要考虑三件事:
每个子系统将有一个或多个它将提供的功能,每个功能将有一系列将按特定顺序执行的操作。这些行动将收到投入并提供产出。特性和功能之间也会有关系,您必须识别,之后您需要确定该功能是可重用还是独立。
考虑在线测试产品的场景。教师和学生将登录该系统。如果教师登录,他们将被带到管理控制台,如果学生登录,他们将被带到测试菜单执行一个或多个测试。在这种情况下,我们实际上有三个子系统:
上述系统中有两个执行流。我们有管理流程和测试流程。必须为每个流程建立条件和测试用例。我们将在 E2E 示例中使用这个非常简单的评估系统登录场景。在现实世界中,E2E 将比本章更多地参与其中。本章的主要目的是让您思考 E2E 测试以及如何最好地实现它,因此我们将尽可能简单,以使复杂性不会妨碍我们尝试完成的工作,即手动测试必须相互交互的三个模块。
本节的目的是构建构成整个系统的三个控制台应用:登录模块、管理模块和测试模块。然后,一旦它们被构建,我们将进行手动测试。下图显示了系统之间的交互。我们将从登录模块开始:
系统的第一部分要求教师和学生使用用户名和密码登录系统。任务列表如下:
输入用户名。
输入密码。
按 Cancel(这将重置用户名和密码)。
按 OK。
如果用户名无效,则在登录页面上显示错误消息。
如果用户有效,请执行以下操作:
让我们先创建一个控制台应用。叫它CH07_Logon
。在Program.cs
类中,用以下代码替换现有代码:
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
namespace CH07_Logon
{
internal static class Program
{
private static void Main(string[] args)
{
DoLogin("Welcome to the test platform");
}
}
}
DoLogin()
方法将获取传入的字符串并将其用作标题。由于我们尚未登录,标题将设置为"Welcome to the test platform"
。我们需要添加DoLogin()
方法。此方法的代码如下所示:
private static void DoLogin(string message)
{
Console.WriteLine("----------------------------");
Console.WriteLine(message);
Console.WriteLine("----------------------------");
Console.Write("Enter your username: ");
var usr = Console.ReadLine();
Console.Write("Enter your password: ");
var pwd = ReadPassword();
ValidateUser(usr, pwd);
}
前面的代码接受一条消息。该消息用作控制台窗口中的标题。然后提示用户输入用户名和密码。ReadPassword()
方法读取所有输入,并用星号替换过滤后的字母,以隐藏用户的输入。然后通过调用ValidateUser()
方法验证用户名和密码。
接下来我们必须做的是添加ReadPassword()
方法,如下代码所示:
public static string ReadPassword()
{
return ReadPassword('*');
}
这个方法真的很简单。它调用同名的重载方法并传入密码掩码字符。让我们实现重载的ReadPassword()
方法:
public static string ReadPassword(char mask)
{
const int enter = 13, backspace = 8, controlBackspace = 127;
int[] filtered = { 0, 27, 9, 10, 32 };
var pass = new Stack<char>();
char chr = (char)0;
while ((chr = Console.ReadKey(true).KeyChar) != enter)
{
if (chr == backspace)
{
if (pass.Count > 0)
{
Console.Write("\b \b");
pass.Pop();
}
}
else if (chr == controlBackspace)
{
while (pass.Count > 0)
{
Console.Write("\b \b");
pass.Pop();
}
}
else if (filtered.Count(x => chr == x) <= 0)
{
pass.Push((char)chr);
Console.Write(mask);
}
}
Console.WriteLine();
return new string(pass.Reverse().ToArray());
}
重载的ReadPassword()
方法接受密码掩码。此方法将每个字符添加到堆栈中。除非所按下的键是*进入*键,否则会检查所按下的键,以查看用户是否正在执行删除按键。如果用户正在执行删除按键,则最后输入的字符将从堆栈中删除。如果输入的字符不在筛选列表中,则将其推送到堆栈上。然后将密码掩码写入屏幕。按下Enter键后,控制台窗口中会写入一个空行,堆栈内容会反转,并以字符串形式返回。
**我们需要为这个子系统编写的最后一个方法是ValidateUser()
方法:
private static void ValidateUser(string usr, string pwd)
{
if (usr.Equals("admin") && pwd.Equals("letmein"))
{
var process = new Process();
process.StartInfo.FileName = @"..\..\..\CH07_Admin\bin\Debug\CH07_Admin.exe";
process.StartInfo.Arguments = "admin";
process.Start();
}
else if (usr.Equals("student") && pwd.Equals("letmein"))
{
var process = new Process();
process.StartInfo.FileName = @"..\..\..\CH07_Test\bin\Debug\CH07_Test.exe";
process.StartInfo.Arguments = "test";
process.Start();
}
else
{
Console.Clear();
DoLogin("Invalid username or password");
}
}
ValidateUser()
方法检查用户名和密码。如果他们以管理员身份验证,则会加载管理员页面。如果他们作为学生验证,则加载学生页面。否则,将清除控制台,通知用户凭据错误,并提示用户重新输入凭据。
成功执行登录操作后,将加载相关子系统,然后登录子系统终止。现在我们已经编写了登录模块,我们将编写管理模块。
管理子系统是执行所有系统管理的地方。这包括:
在本练习中,我们将不实现任何这些功能。我会让你做一个有趣的练习。我们感兴趣的是,成功登录后将加载管理模块。如果在未登录的情况下加载管理模块,则会显示错误消息。然后,当用户按键时,他们被带到登录模块。当用户以管理员身份成功登录时,将完成成功登录,并使用admin 参数调用管理员可执行文件。
在 Visual Studio 中创建一个控制台应用,并将其命名为CH07_Admin
。更新Main()
方法如下:
private static void Main(string[] args)
{
if ((args.Count() > 0) && (args[0].Equals("admin")))
{
DisplayMainScreen();
}
else
{
DisplayMainScreenError();
}
}
Main()
方法检查参数计数是否大于0
,以及数组中的第一个参数是否为 admin。如果是,则通过调用DisplayMainScreen()
方法显示主屏幕。否则,将调用DisplayMainScreenError()
方法,警告用户必须登录才能访问系统。是时候写下DisplayMainScreen()
方法了:
private static void DisplayMainScreen()
{
Console.WriteLine("------------------------------------");
Console.WriteLine("Test Platform Administrator Console");
Console.WriteLine("------------------------------------");
Console.WriteLine("Press any key to exit");
Console.ReadKey();
Process.Start(@"..\..\..\CH07_Logon\bin\Debug\CH07_Logon.exe");
}
如你所见,DisplayMainScreen()
方法非常简单。它显示一个标题,其中包含一条消息,可按任意键退出,然后等待按键。按键后,程序将弹出到登录模块并退出。现在,对于DisplayMainScreenError()
方法:
private static void DisplayMainScreenError()
{
Console.WriteLine("------------------------------------");
Console.WriteLine("Test Platform Administrator Console");
Console.WriteLine("------------------------------------");
Console.WriteLine("You must login to use the admin module.");
Console.WriteLine("Press any key to exit");
Console.ReadKey();
Process.Start(@"..\..\..\CH07_Logon\bin\Debug\CH07_Logon.exe");
}
从这个方法中,您可以看到模块是在没有登录的情况下启动的。这是不允许的。因此,当用户按下任意键时,用户将被重定向到登录模块,在那里他们可以登录使用管理模块。我们的最后一个模块是测试模块。让我们开始写吧。
测试系统由一个菜单组成。此菜单显示学生必须执行的测试列表,并提供退出测试系统的选项。该系统的功能包括:
与前面的模块一样,我将让您玩一玩并添加上述功能。这里我们感兴趣的主要事情是确保测试模块只能在用户登录时运行。退出模块时,将加载登录模块。
测试模块或多或少是管理模块的翻版,因此我们将快速浏览本节,以了解我们需要的地方。更新Main()
方法如下:
private static void Main(string[] args)
{
if ((args.Count() > 0) && (args[0].Equals("test")))
{
DisplayMainScreen();
}
else
{
DisplayMainScreenError();
}
}
现在添加DisplayMainScreen()
方法:
private static void DisplayMainScreen()
{
Console.WriteLine("------------------------------------");
Console.WriteLine("Test Platform Student Console");
Console.WriteLine("------------------------------------");
Console.WriteLine("Press any key to exit");
Console.ReadKey();
Process.Start(@"..\..\..\CH07_Logon\bin\Debug\CH07_Logon.exe");
}
最后,写出DisplayMainScreenError()
方法:
private static void DisplayMainScreenError()
{
Console.WriteLine("------------------------------------");
Console.WriteLine("Test Platform Student Console");
Console.WriteLine("------------------------------------");
Console.WriteLine("You must login to use the student module.");
Console.WriteLine("Press any key to exit");
Console.ReadKey();
Process.Start(@"..\..\..\CH07_Logon\bin\Debug\CH07_Logon.exe");
}
现在我们已经编写了所有三个模块,我们将在下一节中测试它们。
在本节中,我们将对三模块系统进行手动 E2E 测试。我们将测试登录模块,以确保它只允许有效登录访问管理模块或测试模块。当一个有效的管理员登录到系统时,他们应该看到管理模块,并且应该卸载登录模块。当一个有效的学生登录到系统时,他们应该看到测试模块,并且应该卸载登录模块。
如果我们随后尝试在没有首先登录的情况下加载管理模块,我们应该被警告必须登录。按任意键都应卸载管理模块并加载登录模块。尝试在不登录的情况下使用测试模块的行为应与管理模块的行为相同。我们应该得到警告,除非登录,否则不能使用测试模块,按任意键都会加载登录模块并卸载测试模块。
现在让我们来看一下手动测试过程:
admin
作为用户名,输入letmein
作为密码,然后按输入。您应该看到管理模块屏幕以获得成功登录:student
作为您的用户名,letmein
作为您的密码。按键进入键,您将看到学生模块:我们现在已经成功地对我们的系统进行了 E2E 测试,该系统由三个模块组成。这是 E2E 测试时通过系统运行的最佳方式。单元测试将非常有助于使此阶段变得相当简单。当您到达这个阶段时,您的 bug 应该已经被捕获并处理好了。但与往常一样,总是有可能遇到问题,这就是为什么手动运行整个系统作为一个整体是好的。这样,您可以通过交互直观地看到系统的行为符合预期。
较大的系统使用工厂和依赖注入。在本章的以下章节中,我们将从工厂开始,对这两个方面进行介绍。
工厂使用工厂方法模式实现。此模式的目的是允许在不指定对象类的情况下创建对象。这是通过调用工厂方法来实现的。工厂方法的主要目标是创建类的实例。
您可以将 factory 方法模式用于以下场景:
考虑下面的图表:
如上图所示,您有以下项目:
Factory
,为返回类型的FactoryMethod()
提供接口ConcreteFactory
,重写或实现FactoryMethod()
返回具体类型ConcreteObject
,继承或实现基类或接口现在是演示的好时机。假设您有三个不同的客户。每个客户都需要使用不同的关系数据库作为后端数据源。您的客户使用的数据库将是 Oracle 数据库、SQL Server 和 MySQL。
作为 E2E 测试的一部分,您需要针对每个数据源进行测试。但是,如何编写一次程序并使其在这些数据库中工作呢?这就是Factory
方法模式的用武之地。
在安装过程中或通过应用的初始配置,您可以让用户指定希望用作数据源的数据库。此信息可以作为加密的数据库连接字符串存储在配置文件中。当应用启动时,它将读取数据库连接字符串并对其解密。然后,数据库连接字符串将被传递到工厂方法中。最后,将选择、实例化并返回适当的数据库连接对象,以供应用使用。
现在您已经有了一些背景知识,让我们在 Visual Studio 中创建一个.NET Framework 控制台应用,并将其命名为CH07_Factories
。将App.cong
文件中的代码替换为以下代码:
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.8" />
</startup>
<connectionStrings>
<clear />
<add name="SqlServer"
connectionString="Data Source=SqlInstanceName;Initial Catalog=DbName;Integrated Security=True"
providerName="System.Data.SqlClient"
/>
<add name="Oracle"
connectionString="Data Source=OracleInstance;User Id=usr;Password=pwd;Integrated Security=no;"
providerName="System.Data.OracleClient"
/>
<add name="MySQL"
connectionString="Server=MySqlInstance;Database=MySqlDb;Uid=usr;Pwd=pwd;"
providerName="System.Data.MySqlClient"
/>
</connectionStrings>
</configuration>
如您所见,前面的代码已将connectionStrings
元素添加到配置文件中。在该元素中,我们清除所有现有的连接字符串,然后添加将用于应用的三个数据库连接字符串。为了简化本节的内容,我们有未加密的连接字符串,但在生产环境中,请确保您的连接字符串已加密!
在本项目中,我们不会在Program
类中使用Main()
方法。我们将开始Factory
课程,如下所示:
namespace CH07_Factories
{
public abstract class Factory
{
public abstract IDatabaseConnection FactoryMethod();
}
}
前面的代码是我们的抽象工厂,它有一个返回类型为IDatabaseConnection
的抽象FactoryMethod()
。由于它不存在,我们接下来将添加:
namespace CH07_Factories
{
public interface IDatabaseConnection
{
string ConnectionString { get; }
void OpenConnection();
void CloseConnection();
}
}
在这个接口中,我们有一个只读连接字符串,一个名为OpenConnection()
的方法打开数据库连接,一个名为CloseConnection()
的方法关闭打开的数据库连接。到目前为止,我们已经有了抽象的Factory
和IDatababaseConnection
接口。接下来,我们将创建具体的数据库连接类。让我们从 SQL Server 数据库连接类开始:
public class SqlServerDbConnection : IDatabaseConnection
{
public string ConnectionString { get; }
public SqlServerDbConnection(string connectionString)
{
ConnectionString = connectionString;
}
public void CloseConnection()
{
Console.WriteLine("SQL Server Database Connection Closed.");
}
public void OpenConnection()
{
Console.WriteLine("SQL Server Database Connection Opened.");
}
}
如您所见,SqlServerDbConnection
类完全实现了IDatabaseConnection
接口。构造函数将connectionString
作为单个参数。然后将只读的ConnectionString
属性分配给connectionString
。OpenConnection()
方法仅打印到控制台。
但是,在实际实现中,连接字符串将用于连接到字符串中指定的有效数据源。数据库连接一旦打开,就必须关闭。数据库连接的关闭将通过CloseConnection()
方法进行。接下来,我们对 Oracle 数据库连接和 MySQL 数据库连接重复前面的过程:
public class OracleDbConnection : IDatabaseConnection
{
public string ConnectionString { get; }
public OracleDbConnection(string connectionString)
{
ConnectionString = connectionString;
}
public void CloseConnection()
{
Console.WriteLine("Oracle Database Connection Closed.");
}
public void OpenConnection()
{
Console.WriteLine("Oracle Database Connection Closed.");
}
}
我们现在有了OracleDbConnection
类。因此,我们需要实现的最后一个类是MySqlDbConnection
类:
public class MySqlDbConnection : IDatabaseConnection
{
public string ConnectionString { get; }
public MySqlDbConnection(string connectionString)
{
ConnectionString = connectionString;
}
public void CloseConnection()
{
Console.WriteLine("MySQL Database Connection Closed.");
}
public void OpenConnection()
{
Console.WriteLine("MySQL Database Connection Closed.");
}
}
有了这些,我们添加了具体的类。剩下要做的唯一一件事就是创建继承抽象的Factory
类的ConcreteFactory
类。您需要参考System.Configuration.ConfigurationManager
NuGet 数据包:
using System.Configuration;
namespace CH07_Factories
{
public class ConcreteFactory : Factory
{
private static ConnectionStringSettings _connectionStringSettings;
public ConcreteFactory(string connectionStringName)
{
GetDbConnectionSettings(connectionStringName);
}
private static ConnectionStringSettings GetDbConnectionSettings(string connectionStringName)
{
return ConfigurationManager.ConnectionStrings[connectionStringName];
}
}
}
如我们所见,该类使用了System.Configuration
名称空间。ConnectionStringSettings
值存储在_connectionStringSettings
成员变量中。这是在接受connectionStringName
的构造函数中设置的。名称被传递到GetDbConnectionSettings()
方法中。你们中的快将在构造函数中看到一个明显的错误。
正在调用该方法,但未设置成员变量。然而,当我们开始运行我们还没有编写的测试时,我们会发现这个疏忽并修复它。GetDbConnectionSettings()
方法使用ConfigurationManager
从ConnectionStrings[]
数组中读取所需的连接字符串。
现在,是时候通过添加FactoryMethod()
来完成我们的ConcreteClass
:
public override IDatabaseConnection FactoryMethod()
{
var providerName = _connectionStringSettings.ProviderName;
var connectionString = _connectionStringSettings.ConnectionString;
switch (providerName)
{
case "System.Data.SqlClient":
return new SqlServerDbConnection(connectionString);
case "System.Data.OracleClient":
return new OracleDbConnection(connectionString);
case "System.Data.MySqlClient":
return new MySqlDbConnection(connectionString);
default:
return null;
}
}
我们的FactoryMethod()
返回一个具体的类型为IDatabaseConnection
的类。在类的开头,读取成员变量,并本地存储providerName
和connectionString
的值。然后使用一个开关来确定要构建和传回的数据库连接的类型。
我们现在可以测试我们的工厂,看看它是否能与客户使用的不同类型的数据库一起工作。这个测试可以手动完成,但是为了本练习的目的,我们将编写自动化测试。
创建一个新的 NUnit 测试项目。添加对CH07_Factories
项目的引用。然后,添加System.Configuration.ConfigurationManager
NuGet 包。将该类重命名为UnitTests.cs
。现在,添加第一个测试,如图所示:
[Test]
public void IsSqlServerDbConnection()
{
var factory = new ConcreteFactory("SqlServer");
var connection = factory.FactoryMethod();
Assert.IsInstanceOf<SqlServerDbConnection>(connection);
}
此测试用于 SQL Server 数据库连接。创建一个新的ConcreteFactory()
实例并传入"SqlServer"
的connectionStringName
值。然后工厂通过FactoryMethod()
实例化并返回正确的数据库连接对象。最后,断言连接对象以测试它是否确实是类型为SqlServerDbConnection
的实例。我们需要为其他数据库连接再编写两次前面的测试,因此现在我们添加 Oracle 数据库连接测试:
[Test]
public void IsOracleDbConnection()
{
var factory = new ConcreteFactory("Oracle");
var connection = factory.FactoryMethod();
Assert.IsInstanceOf<OracleDbConnection>(connection);
}
测试通过"Oracle"
的connectionStringName
值。进行断言以测试返回的连接对象是否为类型OracleDbConnection
。最后,我们进行了 MySQL 数据库连接测试:
[Test]
public void IsMySqlDbConnection()
{
var factory = new ConcreteFactory("MySQL");
var connection = factory.FactoryMethod();
Assert.IsInstanceOf<MySqlDbConnection>(connection);
}
测试通过"MySQL"
的connectionStringName
值。进行断言以测试返回的连接对象是否为类型MySqlDbConnection
。如果我们现在运行测试,它们都会失败,因为没有设置_connectionStringSettings
变量,所以让我们解决这个问题。修改您的ConcreteFactory
构造函数如下:
public ConcreteFactory(string connectionStringName)
{
_connectionStringSettings = GetDbConnectionSettings(connectionStringName);
}
如果您现在运行所有测试,它们应该可以工作。如果 NUnit 没有接收到您的连接字符串,那么它将在与您期望的不同的App.config
文件中查找。在读取连接字符串的行之前添加以下行:
var filepath = ConfigurationManager.OpenExeConfiguration(ConfigurationUserLevel.None).FilePath;
这将通知您 NUnit 在哪里查找您的连接字符串设置。如果该文件不存在,您可以手动创建该文件,并从主App.config
文件复制内容。但问题是,该文件很可能在下一次构建时被删除。因此,为了使更改永久化,您可以向测试项目中添加生成后事件命令行。
为此,右键单击测试项目并选择 Properties。然后在“属性”选项卡上,选择“生成事件”。在生成后事件命令行中,添加以下命令:
xcopy "$(ProjectDir)App.config" "$(ProjectDir)bin\Debug\netcoreapp3.1\" /Y /I /R
以下屏幕截图显示了“项目属性”对话框的“生成事件”页面,其中包含生成后事件命令行:
这将在测试项目输出文件夹中创建丢失的文件。您系统上的文件可能名为testhost.x86.dll.config
,因为它在我的系统上。现在,您的构建应该可以工作了。
如果您在FactoryMethod()
中更改其中一个案例的返回类型,您将看到您的测试失败,如下图所示:
将代码更改回正确的类型,以便代码现在可以通过。
我们已经了解了如何手动 E2E 测试系统,以及如何使用软件工厂,以及如何自动测试工厂是否按预期运行。现在我们来看看依赖注入以及如何对其进行 E2E 测试。
依赖项注入(DI)通过将代码的行为与其依赖项分离,帮助您生成松散耦合的代码,从而生成更易于测试、扩展和维护的可读代码。代码更具可读性,因为您遵循单一责任原则。这也会导致更小的代码。更小的代码更容易维护和测试,因为我们依赖于抽象而不是实现,所以我们可以根据需要更容易地扩展代码。
以下是您可以实现的 DI 类型:
穷人的 DI没有容器。但是,推荐的最佳实践是使用 DI 容器。简单来说,DI 容器是一个注册框架,它实例化依赖项并在请求时注入依赖项。
现在,我们将为 DI 示例编写自己的依赖项容器、接口、服务和客户机。然后,我们将为依赖项项目编写测试。请记住,即使测试应该首先编写,在我遇到的大多数业务情况下,它们都是在软件编写完成后编写的!所以在这个场景中,我们将在我们想要的软件编码完成后编写测试。当您雇用多个团队,其中一些团队使用 TDD,而另一些团队不使用 TDD,或者您使用不存在测试的第三方代码时,通常会发生这种情况。
我们前面提到过,E2E 最好是手动完成的,而且自动化很难,但是您可以自动化系统测试,以及执行手动测试。如果您以多个数据源为目标,这尤其有用。
您首先需要准备一个依赖项容器。依赖项容器保存类型和实例的寄存器。在使用类型之前先注册类型。当需要使用对象的实例时,可以将其解析为变量,并将其注入(传递)到构造函数、方法或属性中。
创建一个新类库并将其命名为CH07_DependencyInjection
。添加一个名为DependencyContainer
的新类,并添加以下代码:
public static readonly IDictionary<Type, Type> Types = new Dictionary<Type, Type>();
public static readonly IDictionary<Type, object> Instances = new Dictionary<Type, object>();
public static void Register<TContract, TImplementation>()
{
Types[typeof(TContract)] = typeof(TImplementation);
}
public static void Register<TContract, TImplementation>(TImplementation instance)
{
Instances[typeof(TContract)] = instance;
}
在这段代码中,我们有两个包含类型和实例的字典。我们还有两种方法。一个用于注册我们的类型,第二个用于注册我们的实例。现在我们已经有了注册和存储类型和实例的代码,我们需要一种在运行时解析它们的方法。将以下代码添加到DependencyContainer
类中:
public static T Resolve<T>()
{
return (T)Resolve(typeof(T));
}
此方法在类型中传递。它调用该方法来解析该类型,并返回该类型的实例。现在,让我们添加该方法:
public static object Resolve(Type contract)
{
if (Instances.ContainsKey(contract))
{
return Instances[contract];
}
else
{
Type implementation = Types[contract];
ConstructorInfo constructor = implementation.GetConstructors()[0];
ParameterInfo[] constructorParameters = constructor.GetParameters();
if (constructorParameters.Length == 0)
{
return Activator.CreateInstance(implementation);
}
List<object> parameters = new List<object>(constructorParameters.Length);
foreach (ParameterInfo parameterInfo in constructorParameters)
{
parameters.Add(Resolve(parameterInfo.ParameterType));
}
return constructor.Invoke(parameters.ToArray());
}
}
Resolve()
方法检查Instances
字典是否包含密钥与契约匹配的实例。如果是,则返回该实例。否则,将创建并返回一个新实例。
现在,我们需要一个接口,我们要注入的服务将实现该接口。我们称之为IService
。它将有一个返回字符串的方法,该方法将被称为WhoAreYou()
:
public interface IService
{
string WhoAreYou();
}
我们要注入的服务将实现上述接口。我们的第一个类将命名为ServiceOne
,方法将返回字符串"CH07_DependencyInjection.ServiceOne()"
:
public class ServiceOne : IService
{
public string WhoAreYou()
{
return "CH07_DependencyInjection.ServiceOne()";
}
}
第二个服务相同,只是调用了ServiceTwo
,方法返回字符串"CH07_DependencyInjection.ServiceTwo()"
:
public class ServiceTwo : IService
{
public string WhoAreYou()
{
return "CH07_DependencyInjection.ServiceTwo()";
}
}
依赖项容器、接口和服务类现已就位。最后,我们将添加一个客户端,该客户端将用作演示对象,它将通过 DI 使用我们的服务。我们的类将演示构造函数注入、属性注入和方法注入。将以下代码添加到类的顶部:
private IService _service;
public Client() { }
_service
成员变量将用于存储注入的服务。我们有一个默认构造函数,这样我们就可以测试我们的属性和方法注入。添加接受并设置IService
成员的构造函数:
public Client (IService service)
{
_service = service;
}
接下来,我们将把属性添加到测试属性注入和构造函数注入中:
public IService Service
{
get { return _service; }
set
{
_service = value;
}
}
然后,我们将添加一个对注入对象调用WhoAreYou()
的方法。Service
属性允许设置和检索_service
成员变量。最后,我们将添加我们的GetServiceName()
方法:
public string GetServiceName(IService service)
{
return service.WhoAreYou();
}
在注入的IService
类实例上调用GetServiceName()
方法。此方法返回传入的服务的完全限定名。现在我们将编写单元测试来测试功能。添加测试项目并引用依赖项项目。调用测试项目CH07_DependencyInjection.Tests
并将UnitTest1
重命名为UnitTests
。
我们将编写测试来检查实例的注册和解析是否有效,以及是否通过构造函数注入、setter 注入和方法注入了正确的类。我们的测试将测试ServiceOne
和ServiceTwo
的注射。让我们从写我们的Setup()
方法开始,如下所示:
[TestInitialize]
public void Setup()
{
DependencyContainer.Register<ServiceOne, ServiceOne>();
DependencyContainer.Register<ServiceTwo, ServiceTwo>();
}
在我们的Setup()
方法中,我们注册了IService
类的两个实现,即ServiceOne()
和ServiceTwo()
。现在,我们将编写两个测试方法来测试依赖项容器:
[TestMethod]
public void DependencyContainerTestServiceOne()
{
var serviceOne = DependencyContainer.Resolve<ServiceOne>();
Assert.IsInstanceOfType(serviceOne, typeof(ServiceOne));
}
[TestMethod]
public void DependencyContainerTestServiceTwo()
{
var serviceTwo = DependencyContainer.Resolve<ServiceTwo>();
Assert.IsInstanceOfType(serviceTwo, typeof(ServiceTwo));
}
这两种方法都称为Resolve()
方法。该方法检查类型的实例。如果实例存在,它将返回它。否则,将实例化并返回一个。是时候为serviceOne
和serviceTwo
编写构造函数注入测试了:
[TestMethod]
public void ConstructorInjectionTestServiceOne()
{
var serviceOne = DependencyContainer.Resolve<ServiceOne>();
var client = new Client(serviceOne);
Assert.IsInstanceOfType(client.Service, typeof(ServiceOne));
}
[TestMethod]
public void ConstructorInjectionTestServiceTwo()
{
var serviceTwo = DependencyContainer.Resolve<ServiceTwo>();
var client = new Client(serviceTwo);
Assert.IsInstanceOfType(client.Service, typeof(ServiceTwo));
}
在这两种构造函数测试方法中,我们从容器注册表解析相关服务。然后我们将服务传递给构造函数。最后,使用 getService
属性,我们断言通过构造函数传入的服务是预期服务的实例。让我们编写测试以显示属性设置器注入按预期工作:
[TestMethod]
public void PropertyInjectTestServiceOne()
{
var serviceOne = DependencyContainer.Resolve<ServiceOne>();
var client = new Client();
client.Service = serviceOne;
Assert.IsInstanceOfType(client.Service, typeof(ServiceOne));
}
[TestMethod]
public void PropertyInjectTestServiceTwo()
{
var serviceTwo = DependencyContainer.Resolve<ServiceTwo>();
var client = new Client();
client.Service = serviceTwo;
Assert.IsInstanceOfType(client.Service, typeof(ServiceOne));
}
要测试 setter 注入是否解析了我们所关注的类,请使用默认构造函数创建一个客户机,然后将解析的实例分配给Service
属性。接下来,我们断言服务是否是预期类型的实例。最后,对于我们的测试,我们只需要测试我们的方法注入:
[TestMethod]
public void MethodInjectionTestServiceOne()
{
var serviceOne = DependencyContainer.Resolve<ServiceOne>();
var client = new Client();
Assert.AreEqual(client.GetServiceName(serviceOne), "CH07_DependencyInjection.ServiceOne()");
}
[TestMethod]
public void MethodInjectionTestServiceTwo()
{
var serviceTwo = DependencyContainer.Resolve<ServiceTwo>();
var client = new Client();
Assert.AreEqual(client.GetServiceName(serviceTwo), "CH07_DependencyInjection.ServiceTwo()");
}
在这里,我们再次解析我们的实例。使用默认构造函数创建一个新的客户端,并断言传入已解析的实例,调用GetServiceName()
方法返回传入实例的正确标识。
系统由一个或多个模块组成。当一个系统包含两个或多个模块时,您需要测试它们之间的交互,以确保它们按预期协同工作。让我们考虑下面的图表中所示的 API 的系统:
从前面的图中可以看到,我们有一个客户机,它通过 API 访问云中的数据存储。客户端向 HTTP 服务器发送请求。请求已通过身份验证。一旦通过身份验证,请求就被授权访问 API。客户机发送的数据被反序列化,然后传递到业务层。然后,业务层对数据存储执行读取、插入、更新或删除操作。然后,数据通过业务层从数据库传回客户机,然后是序列化层,然后再传回客户机。
正如您所看到的,我们有许多相互交互的模块。我们有以下几点:
如果我们看一下前面这三点,我们可以看到可以编写许多测试来自动化 E2E 测试过程。许多测试本质上都是单元测试,它们被整合到我们的集成测试套件中。现在让我们考虑一下。我们能够测试以下各项:
从这些测试中可以看出,它们是单元测试而不是集成测试。那么,我们可以编写什么集成测试呢?我们可以编写以下测试:
这四个测试可以使用正确的用户名和密码以及格式良好的数据请求编写,也可以针对无效的用户名或密码以及格式错误的数据请求编写。
因此,您可以通过使用单元测试来测试每个模块中的代码,然后使用一次只测试两个模块之间的交互的测试来执行集成测试。您还可以编写执行完整 E2E 操作的测试。
但是,尽管能够用代码测试所有这些,但您必须做的一件事是手动运行系统,以验证一切是否按预期工作。
成功完成所有这些测试后,您就有信心将代码发布到生产环境中。
现在我们已经介绍了 E2E 测试(也称为集成测试,让我们花一些时间总结一下我们所学到的内容。
在本章中,我们了解了什么是 E2E 测试。我们看到我们可以编写自动化测试,但我们也开始理解从最终用户的角度手动测试整个应用的重要性。
当我们观察工厂时,我们看到了它们在数据库连接方面的使用示例。我们考虑了这样一个场景:我们的应用将允许用户使用他们选择的数据库。我们加载一个连接字符串,然后根据该连接字符串实例化并返回相关的数据库连接对象以供使用。我们看到了如何为每个不同数据库的每个用例测试我们的工厂。工厂可以在许多不同的场景中使用,现在您知道它们是什么,如何使用它们,最重要的是,您知道如何测试它们。
DI 使单个类能够与接口的多个不同实现一起工作。我们在编写自己的依赖项容器时看到了这一点。我们创建的接口由两个类实现,添加到依赖项寄存器,并在依赖项容器调用时解析。我们实现了单元测试来测试构造函数注入、属性注入和方法注入的不同实现。
然后,我们看了一下模块。一个简单的应用可能由单个模块组成,但应用的复杂性越高,组成该应用的模块就越多。随着模块数量的增加,出错的机会也随之增加。因此,测试模块之间的交互非常重要。模块本身可以使用单元测试进行测试。模块之间的交互可以通过更复杂的测试进行测试,这些测试从头到尾贯穿于整个场景。
在下一章中,我们将研究处理线程和并发时的最佳实践。但首先,让我们测试一下你对本章内容的了解。