C# 端到端系统测试详解

端到端E2E)系统测试是系统整体的自动化测试。作为一名程序员,对代码的单元测试只是整个系统大局中的一个小因素。因此,在本章中,我们将关注以下主题:

本章结束时,您将获得以下技能:

  • 能够定义 E2E 测试
  • 能够执行 E2E 测试
  • 能够解释什么是工厂以及如何使用它们
  • 能够理解依赖注入是什么以及如何使用它
  • 能够理解什么是模块化以及如何利用模块化

那么,您已经完成了项目,并且所有的单元测试都通过了。但是,您的项目是更大系统的一部分。这个更大的系统需要进行测试,以确保您的代码以及与之交互的其他代码能够按照预期协同工作。单独测试的代码在集成到更大的系统中时可能会中断,而现有系统在添加新代码时可能会中断,因此执行 E2E 测试非常重要,也称为集成测试

集成测试负责从头到尾测试整个程序流程。集成测试通常在需求收集阶段开始。首先,收集和记录系统的各种需求。然后设计所有组件并为每个子系统设计测试,然后为整个系统设计 E2E 测试。然后,根据需求编写代码并实现自己的单元测试。一旦代码完成且测试全部通过,那么代码将集成到测试环境中的整个系统中,并执行 E2E 测试。通常,E2E 测试是手动执行的,尽管在可能的情况下,它们也可以自动化。下图显示了一个系统,该系统由两个子系统(带模块和数据库)组成。在 E2E 测试中,所有这些模块将通过手动、自动化或两种方法进行测试:

每个系统的输入和输出是测试的主要焦点。你必须扪心自问,每个系统传入和传出的信息是否正确?

另外,在构建 E2E 测试时要考虑三件事:

  • 将有哪些用户功能,每个功能将执行哪些步骤?
  • 每个功能及其每个步骤都有什么条件
  • 我们需要为哪些不同的场景构建测试用例?

每个子系统将有一个或多个它将提供的功能,每个功能将有一系列将按特定顺序执行的操作。这些行动将收到投入并提供产出。特性和功能之间也会有关系,您必须识别,之后您需要确定该功能是可重用还是独立

考虑在线测试产品的场景。教师和学生将登录该系统。如果教师登录,他们将被带到管理控制台,如果学生登录,他们将被带到测试菜单执行一个或多个测试。在这种情况下,我们实际上有三个子系统:

  • 登录系统
  • 管理系统
  • 测试系统

上述系统中有两个执行流。我们有管理流程和测试流程。必须为每个流程建立条件和测试用例。我们将在 E2E 示例中使用这个非常简单的评估系统登录场景。在现实世界中,E2E 将比本章更多地参与其中。本章的主要目的是让您思考 E2E 测试以及如何最好地实现它,因此我们将尽可能简单,以使复杂性不会妨碍我们尝试完成的工作,即手动测试必须相互交互的三个模块。

本节的目的是构建构成整个系统的三个控制台应用:登录模块、管理模块和测试模块。然后,一旦它们被构建,我们将进行手动测试。下图显示了系统之间的交互。我们将从登录模块开始:

登录模块(子系统)

系统的第一部分要求教师和学生使用用户名和密码登录系统。任务列表如下:

  1. 输入用户名。

  2. 输入密码。

  3. 按 Cancel(这将重置用户名和密码)。

  4. 按 OK。

  5. 如果用户名无效,则在登录页面上显示错误消息。

  6. 如果用户有效,请执行以下操作:

    • 如果用户是教师,请加载管理控制台。
    • 如果用户是学生,请加载测试控制台。

让我们先创建一个控制台应用。叫它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 测试我们的三模块系统

在本节中,我们将对三模块系统进行手动 E2E 测试。我们将测试登录模块,以确保它只允许有效登录访问管理模块或测试模块。当一个有效的管理员登录到系统时,他们应该看到管理模块,并且应该卸载登录模块。当一个有效的学生登录到系统时,他们应该看到测试模块,并且应该卸载登录模块。

如果我们随后尝试在没有首先登录的情况下加载管理模块,我们应该被警告必须登录。按任意键都应卸载管理模块并加载登录模块。尝试在不登录的情况下使用测试模块的行为应与管理模块的行为相同。我们应该得到警告,除非登录,否则不能使用测试模块,按任意键都会加载登录模块并卸载测试模块。

现在让我们来看一下手动测试过程:

  1. 确保所有项目都已生成,然后运行登录模块。您将看到以下屏幕:

  1. 输入错误的用户名和/或密码,然后按输入,您将看到以下屏幕:

  1. 现在,输入admin作为用户名,输入letmein作为密码,然后按输入。您应该看到管理模块屏幕以获得成功登录:

  1. 按任意键退出,您将再次看到登录模块:

  1. 输入student作为您的用户名,letmein作为您的密码。按键进入键,您将看到学生模块:

  1. 现在,在不登录的情况下加载管理模块,您将看到以下内容:

  1. 按任意键将返回登录模块。现在,在不登录的情况下加载测试模块,您将看到以下内容:

我们现在已经成功地对我们的系统进行了 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()的方法关闭打开的数据库连接。到目前为止,我们已经有了抽象的FactoryIDatababaseConnection接口。接下来,我们将创建具体的数据库连接类。让我们从 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属性分配给connectionStringOpenConnection()方法仅打印到控制台。

但是,在实际实现中,连接字符串将用于连接到字符串中指定的有效数据源。数据库连接一旦打开,就必须关闭。数据库连接的关闭将通过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.ConfigurationManagerNuGet 数据包:

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()方法使用ConfigurationManagerConnectionStrings[]数组中读取所需的连接字符串。

现在,是时候通过添加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的类。在类的开头,读取成员变量,并本地存储providerNameconnectionString的值。然后使用一个开关来确定要构建和传回的数据库连接的类型。

我们现在可以测试我们的工厂,看看它是否能与客户使用的不同类型的数据库一起工作。这个测试可以手动完成,但是为了本练习的目的,我们将编写自动化测试。

创建一个新的 NUnit 测试项目。添加对CH07_Factories项目的引用。然后,添加System.Configuration.ConfigurationManagerNuGet 包。将该类重命名为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 注入和方法注入了正确的类。我们的测试将测试ServiceOneServiceTwo的注射。让我们从写我们的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()方法。该方法检查类型的实例。如果实例存在,它将返回它。否则,将实例化并返回一个。是时候为serviceOneserviceTwo编写构造函数注入测试了:

[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 使单个类能够与接口的多个不同实现一起工作。我们在编写自己的依赖项容器时看到了这一点。我们创建的接口由两个类实现,添加到依赖项寄存器,并在依赖项容器调用时解析。我们实现了单元测试来测试构造函数注入、属性注入和方法注入的不同实现。

然后,我们看了一下模块。一个简单的应用可能由单个模块组成,但应用的复杂性越高,组成该应用的模块就越多。随着模块数量的增加,出错的机会也随之增加。因此,测试模块之间的交互非常重要。模块本身可以使用单元测试进行测试。模块之间的交互可以通过更复杂的测试进行测试,这些测试从头到尾贯穿于整个场景。

在下一章中,我们将研究处理线程和并发时的最佳实践。但首先,让我们测试一下你对本章内容的了解。

  1. 什么是 E2E 测试?
  2. E2E 测试的另一个术语是什么?
  3. 在 E2E 测试期间,我们应该采用什么方法?
  4. 什么是工厂,我们为什么使用它们?
  5. 什么是 DI?
  6. 为什么要使用依赖项容器?
  • 曼宁的书将向您介绍.NET 依赖注入,然后再指导您了解各种 DI 框架。**

教程来源于Github,感谢apachecn大佬的无私奉献,致敬!

技术教程推荐

趣谈网络协议 -〔刘超〕

如何做好一场技术演讲 -〔极客时间〕

MySQL实战45讲 -〔林晓斌〕

从0开发一款iOS App -〔朱德权〕

Flutter核心技术与实战 -〔陈航〕

JavaScript核心原理解析 -〔周爱民〕

小马哥讲Spring AOP编程思想 -〔小马哥〕

零基础实战机器学习 -〔黄佳〕

搞定音频技术 -〔冯建元 〕