单元测试Unit Testing [43]

8.6 如何测试日志功能

  日志是一个灰色地带,当涉及到测试时,如何处理它并不明显。这是一个复杂的话题,我将分成以下问题:

  • 你到底应不应该测试日志?
  • 如果是这样,你应该如何测试?
  • 多大程度的日志才是足够的?
  • 你如何传递记录器实例?

我们将使用我们的CRM项目样本作为例子。

8.6.1 你应该测试日志吗?

  日志是一个跨领域的功能,你可以在你代码库的任何部分要求它。下面是一个在用户类中进行日志记录的例子。

清单8.3 一个登录用户的例子

public class User {
    public void ChangeEmail(string newEmail, Company company) {
    	//方法的起点
        _logger.Info($"Changing email for user {UserId} to {newEmail}");
        Precondition.Requires(CanChangeEmail() == null);
        if (Email == newEmail) return;
        UserType newType = company.IsEmailCorporate(newEmail) ? UserType.Employee : UserType.Customer;
        if (Type != newType) {
            int delta = newType == UserType.Employee ? 1 : -1;
            company.ChangeNumberOfEmployees(delta);
            //改变用户类型
            _logger.Info($"User {UserId} changed type " + $ "from {Type} to {newType}");
        }
        Email = newEmail;
        Type = newType;
        EmailChangedEvents.Add(new EmailChangedEvent(UserId, newEmail));
        //该方法的结束
        _logger.Info($"Email is changed for user {UserId}");
    }
}

  用户类在日志文件中记录了ChangeEmail方法的每次开始和结束,以及用户类型的变化。你应该测试这个功能吗?
  一方面,日志产生了关于应用程序行为的重要信息。但另一方面,日志可能是如此无处不在,以至于不清楚这种功能是否值得额外的、相当重要的测试工作。对于是否应该测试日志的问题,答案可以归结为以下几点。日志是应用程序可观察行为的一部分,还是一个实现细节?
  在这个意义上,它与其他功能没有什么不同。 日志最终会在进程外的依赖中产生副作用,如文本文件或数据库。如果这些副作用是为了让你的客户、应用程序的客户或除开发人员自己之外的其他人观察到,那么日志就是一种可观察的行为,因此必须进行测试。如果唯一的听众是开发人员,那么它就是一个实现细节,可以在没有人注意的情况下*修改,在这种情况下,它不应该被测试。
  例如,如果你写了一个日志库,那么这个库产生的日志就是其可观察行为中最重要(也是唯一)的部分。另一个例子是,当业务人员坚持要对关键的应用工作流程进行记录。 在这种情况下,日志也成为一种业务需求,因此必须由测试来覆盖。然而,在后一个例子中,你可能也有单独的日志记录,只是为了开发人员。
  Steve Freeman和Nat Pryce在他们的《成长的面向对象的软件,以测试为指导》(Addison-Wesley Professional,2009)一书中,将这两种类型的日志称为支持性日志和诊断性日志。

  • 支持日志产生的信息是为了让支持人员或系统管理员跟踪的。
  • 诊断性日志帮助开发人员了解应用程序内部发生的情况

8.6.2 你应该如何测试日志?

  因为日志涉及到进程外的依赖关系,所以当涉及到测试时,与其他涉及进程外依赖关系的功能适用同样的规则。 你需要使用mocks来验证你的应用程序和日志存储之间的交互。

介绍在ILogger顶部的包装
  但不要只是模拟出ILogger的接口。因为支持性日志是一种业务需求,在你的代码库中明确反映这种需求。 创建一个特殊的DomainLogger类,明确列出业务所需的所有支持日志;用该类而不是原始ILogger来验证交互。
  例如,假设商业人士要求你记录用户类型的所有变化,但方法开始和结束时的日志记录只是为了调试的目的。 下一个列表显示了引入DomainLogger类后的User类。

清单8.4 提取支持日志到DomainLogger类中

public void ChangeEmail(string newEmail, Company company) {
	//诊断性记录
    _logger.Info($"Changing email for user {UserId} to {newEmail}");
    Precondition.Requires(CanChangeEmail() == null);
    if (Email == newEmail) return;
    UserType newType = company.IsEmailCorporate(newEmail) ? UserType.Employee : UserType.Customer;
    if (Type != newType) {
        int delta = newType == UserType.Employee ? 1 : -1;
        company.ChangeNumberOfEmployees(delta);
        //支持日志记录
        _domainLogger.UserTypeHasChanged(UserId, Type, newType);
    }
    Email = newEmail;
    Type = newType;
    EmailChangedEvents.Add(new EmailChangedEvent(UserId, newEmail));
    //诊断性记录
    _logger.Info($"Email is changed for user {UserId}");
}

  诊断日志仍然使用旧的日志器(属于ILogger类型),但支持日志现在使用新的IDomainLogger类型的domainLogger实例。下面的列表显示了IDomainLogger的实现。

清单8.5 DomainLogger作为ILogger上面的一个封装器

public class DomainLogger: IDomainLogger {
    private readonly ILogger _logger;
    public DomainLogger(ILogger logger) {
        _logger = logger;
    }
    public void UserTypeHasChanged(int userId, UserType oldType, UserType newType) {
        _logger.Info($"User {userId} changed type " + $ "from {oldType} to {newType}");
    }
}

  DomainLogger工作在ILogger之上:它使用领域语言来声明业务所需的特定日志条目,从而使支持日志更容易理解和维护。事实上,这种实现方式与结构化日志的概念非常相似,当涉及到日志文件的后期处理和分析时,它可以实现极大的灵活性。

了解结构化日志

  结构化日志是一种日志技术,在这种技术中,捕获日志数据与呈现该数据是解耦的。传统的日志记录是针对简单的文本。一个像

logger.Info("User Id is " + 12);

  首先形成一个字符串,然后把这个字符串写到日志存储中。这种方法的问题是,由于缺乏结构,产生的日志文件很难分析。例如,不容易看到有多少特定类型的信息,以及其中有多少与特定的用户ID有关。你需要使用(或甚至自己编写)特殊的工具来实现这一点。
  另一方面,结构化日志为你的日志存储引入了结构。结构化日志库的使用在表面上看起来很相似:

logger.Info("User Id is {UserId}", 12);

  但它的基本行为有很大不同。在幕后,该方法计算消息模板的哈希值(消息本身被存储在一个查找存储中以提高空间效率),并将该哈希值与输入参数相结合,形成一组捕获的数据。下一步是对这些数据进行渲染。你仍然可以有一个平面的日志文件,就像传统的日志记录一样,但这只是一种可能的呈现方式。你也可以配置日志库,将捕获的数据呈现为JSON或CSV文件,这样会更容易分析(图8.12)。

单元测试Unit Testing [43] 图8.12 结构化日志将日志数据与该数据的呈现解耦。你可以设置多种呈现方式,如平面日志文件、JSON或CSV文件。

 
  列表8.5中的DomainLogger本身并不是一个结构化的记录器,但它的操作精神是一样的。再看一下这个方法:

public void UserTypeHasChanged(int userId, UserType oldType, UserType newType) {
    _logger.Info(
    	$"User {userId} changed type " + 
    	$"from {oldType} to {newType}");
}

  你可以把UserTypeHasChanged()看作是消息模板的哈希值。 与userId、oldType和newType参数一起,这个哈希值形成了日志数据。 该方法的实现将日志数据渲染成一个平面日志文件。你可以通过将日志数据写入JSON或CSV文件来轻松创建额外的渲染。

为支持和诊断性记录编写测试
  正如我前面提到的,DomainLogger代表了一个进程外的依赖关系–日志存储。这带来了一个问题:User现在与该依赖关系进行交互,因此违反了业务逻辑与进程外依赖关系的通信之间的分离。 对DomainLogger的使用使User过渡到过度复杂的代码类别,使其更难测试和维护(关于代码类别的更多细节请参考第7章)。
  这个问题可以用我们实现通知外部系统更改用户电子邮件的方法来解决:借助于域事件(同样,详见第七章)。你可以引入一个单独的域事件来跟踪用户类型的变化。然后控制器将把这些变化转换成对DomainLogger的调用,如下面的列表所示。

清单8.6 用一个域事件取代用户中的DomainLogger

public void ChangeEmail(string newEmail, Company company) {
    _logger.Info($"Changing email for user {UserId} to {newEmail}");
    Precondition.Requires(CanChangeEmail() == null);
    if (Email == newEmail) return;
    UserType newType = company.IsEmailCorporate(newEmail) ? UserType.Employee : UserType.Customer;
    if (Type != newType) {
        int delta = newType == UserType.Employee ? 1 : -1;
        company.ChangeNumberOfEmployees(delta);
        //使用领域事件而不是DomainLogger
        AddDomainEvent(new UserTypeChangedEvent(UserId, Type, newType));
    }
    Email = newEmail;
    Type = newType;
    AddDomainEvent(new EmailChangedEvent(UserId, newEmail));
    _logger.Info($"Email is changed for user {UserId}");
}

  注意,现在有两个领域的事件。 UserTypeChangedEvent和EmailChangedEvent。它们都实现了相同的接口(IDomainEvent),因此可以被存储在同一个集合中。
  这里是控制器的样子。

清单8.7 UserController的最新版本

public string ChangeEmail(int userId, string newEmail) {
    object[] userData = _database.GetUserById(userId);
    User user = UserFactory.Create(userData);
    string error = user.CanChangeEmail();
    if (error != null) return error;
    object[] companyData = _database.GetCompany();
    Company company = CompanyFactory.Create(companyData);
    user.ChangeEmail(newEmail, company);
    _database.SaveCompany(company);
    _database.SaveUser(user);
    _eventDispatcher.Dispatch(user.DomainEvents);//派遣用户域事件
    return "OK";
}

  EventDispatcher是一个新的类,它将领域事件转换为对进程外依赖关系的调用:

  • EmailChangedEvent转换成_messageBus.SendEmailChangedMessage()
  • UserTypeChangedEvent转换成_domainLogger.UserTypeHasChanged()

  UserTypeChangedEvent的使用恢复了两个职责之间的分离:领域逻辑和与进程外依赖关系的通信。测试支持日志现在与测试其他非托管依赖关系,即消息总线没有任何区别。

  • 单元测试应该检查被测用户中UserTypeChangedEvent的实例。
  • 单个集成测试应该使用一个mock,以确保与DomainLogger的交互到位。

  注意,如果你需要在控制器中做支持日志,而不是在域类中做支持日志,那么就不需要使用域事件。 你可能还记得第七章,控制器协调了域模型和进程外依赖关系之间的协作。 DomainLogger就是这样的依赖关系之一,因此UserController可以直接使用该日志记录器。
  同时注意到我并没有改变User类做诊断性日志的方式。User仍然在其ChangeEmail方法的开头和结尾处直接使用记录器实例。 这是在设计上。 诊断性日志仅适用于开发人员;你不需要对这个功能进行单元测试,因此也不需要把它放在领域模型之外。
  尽管如此,在可能的情况下,还是要避免在User或其他领域类中使用诊断性日志。我将在下一节解释原因。

8.6.3 多大程度的日志是足够的?

  另一个重要的问题是关于日志的最佳数量。多少日志记录才够? 支持性日志在这里是不可能的,因为这是一个商业要求。不过,你可以控制诊断性日志记录。
  重要的是不要过度使用诊断记录,原因有以下两个:

  • 过多的日志会使代码变得混乱。 这对领域模型来说尤其如此。这就是为什么我不建议在User中使用诊断性日志,尽管从单元测试的角度来看,这样的使用是没有问题的:它掩盖了代码。
  • 日志的信噪比是关键。你的日志越多,就越难找到相关信息。尽量增加信号;尽量减少噪音。

  尽量不要在域模型中使用诊断性日志。在大多数情况下,你可以安全地将日志记录从域类转移到控制器。即使如此,也只能在你需要调试的时候暂时使用诊断性日志。 一旦你完成了调试,就删除它。理想情况下,你应该只对未处理的异常使用诊断性日志。

8.6.4 如何传递日志的实例?

  最后,最后一个问题是如何在代码中传递日志实例。 解决这些实例的一种方法是使用静态方法,如下面的列表所示。

清单8.8 将ILogger存储在一个静态字段中

public class User {
	//通过一个静态方法解析ILogger,并将其存储在一个私有静态字段中
    private static readonly ILogger _logger = 
    	LogManager.GetLogger(typeof (User));
    public void ChangeEmail(string newEmail, Company company) {
        _logger.Info($"Changing email for user {UserId} to {newEmail}"); 
        /* ... */
        _logger.Info($"Email is changed for user {UserId}");
    }
}

  Steven van Deursen和Mark Seeman在他们的《依赖注入原则、实践、模式》(Manning Publications,2018)一书中,将这种类型的依赖获取称为环境背景。这是一种反模式。他们的两个论点是

  • 依赖性是隐藏的,很难改变。
  • 测试变得更加困难

  我完全同意这种分析。不过,对我来说,环境上下文的主要缺点是它掩盖了代码中的潜在问题。如果在一个领域类中明确地注入一个记录器变得如此不方便,以至于你不得不求助于环境上下文,那肯定是有问题的迹象。你要么记录了太多的东西,要么使用了太多的暗示层。在任何情况下,环境上下文都不是一个解决方案。相反,要解决问题的根本原因。
  下面的列表显示了一种显式注入日志的方式:作为方法参数。另一种方式是通过类的构造函数。

清单8.9 明确地注入记录器

//方法注入
public void ChangeEmail(string newEmail, Company company, ILogger logger) {
    logger.Info($"Changing email for user {UserId} to {newEmail}"); 
    /* ... */
    logger.Info($"Email is changed for user {UserId}");
}
上一篇:vue3创建项目


下一篇:Defect分析报告