Day 14-假物件 (Fake) - 模拟物件 (Mock)-3 (核心技术-6)

只针对一个关注点测试

昨天提到虚设常式与模拟物件的差异,两者之间之差在验证的时候如果是用该假物件验证,则为模拟物件;反之,则为虚设常式。此外,每一次的测试都应该只有一个关注点(换言之,应只有一个模拟物件),若一个测试含有多个验证(多个模拟物件),则会引发一些疑虑,举个简单的例子如下:

[Test]
public void CheckSumResult()
{
    // Arrange
    var sum0 = 0;
    var sum1 = 0;
    var sum2 = 0;
    
    // Act
    sum0 = 1001 + 1 + 2;
    sum1 = 1 + 1001 + 2;
    sum2 = 1 + 2 + 1001;
    
    // Assert
    Assert.AreEqual(1004, sum0);
    Assert.AreEqual(1004, sum1);
    Assert.AreEqual(1004, sum2);
}

OK,写到这边可以看出来我们在测试程序码 CheckSumResult 做了三次的总和,顺序互相调换,理论上总和应该都是1004;但假设今天这个互相调换的程序码没写好,变成以下的情况:

[Test]
public void CheckSumResult()
{
    // Arrange
    var sum0 = 0;
    var sum1 = 0;
    var sum2 = 0;
    
    // Act
    sum0 = 1001 + 1 + 2;
    sum1 = 1 + 1 + 2;
    sum2 = 1 + 2 + 1001;
    
    // Assert
    Assert.AreEqual(1004, sum0);
    Assert.AreEqual(1004, sum1);
    Assert.AreEqual(1004, sum2);
}

在 sum0 到 sum1 的时候,1001 与 1 应该要互相调换,但不知为何 1001 没有成功覆写第二个位置,导致 sum1 的值不符合预期,就会出错;但是,第一与三个总和应该还是要出现正确,却无法从这个测试得知。其原因在於 NUnit 的机制是在验证发生失败时,会抛出一个 AssertException 例外,NUnit 测试执行器会拦截这个例外,认为目前这个测试方法失败了,就不会继续执行下面的程序码。同理,假设有人把昨天两个的测试写在一起,改写成以下的例子:

using NUnit3;

[TestFixture]
public class EmailWithLogSystemUnitTests
{
    [Test]
    public void SendFunction_Fail()
    {
        // Arrange
        StubEmailSerivce stubEmailService = new StubEmailSerivce();
        FakeLogSerivce mockLogService = new FakeLogSerivce();
        
        EmailWithLogSystem EmailWithLogService = new EmailWithLogSystem(stubEmailService, mockLogService);
        
        // Act
        var result = EmailWithLogService.SendFunction("[email protected]", "Test Demo");
        
        // Assert
        Assert.AreEqual("Fail", result);
        Assert.AreEqual("[email protected] is not send yet!", mockLogService.logMessage);
    }
}

public class StubEmailSerivce : IEmailService
{
    public string SendEmail(mailAddress, mailMessage)
    {
        return "Fail";
    }
}

public class FakeLogSerivce : ILogService
{
    public string logMessage;
    
    public string Log(string LogMessage)
    {
        logMessage = LogMessage;
    }
}

这段测试码出现了两个问题点,第一个是出现多个验证,这样的坏处是若改动程序码,如下:

public class StubEmailSerivce : IEmailService
{
    public string SendEmail(mailAddress, mailMessage)
    {
        // Fail 改成 Success
        // return "Fail";
        return "Success";
    }
}

第一眼看到这段改动,很难在第一时间看出第二个验证是否有出错,出错的问题点在哪。此外,第二个问题点是有假物件同时担任虚设常式及模拟物件。因有多个验证,在第一个验证的时候,他是扮演虚设常式;但在第二个验证的时候,又担任了模拟物件,角色呈现暧昧不清的情况,会造成程序码阅读上的困难,形成维护上的成本。


过度指定

在单元测试的艺术中,过度指定是指

对一个测试单元该如何完成内部行为进行了假设,而不是只检查最终行为的正确性。

好,我相信看到这会觉得好抽象XDDD,先列出单元测试的艺术中提出的几种情况,再举个简单的例子。

  • 测试对一个被测试物件的纯内部状态进行验证
  • 测试中使用多个模拟物件
  • 测试在需要使用虚设常式物件时,使用模拟物件
  • 测试在不必要的情况下,指定顺序或使用了精准的参数匹配器

那我们以「测试在需要使用虚设常式物件时,使用模拟物件」的情境并搭配昨天的状况来撰写,如下:

using NUnit3;

[TestFixture]
public class EmailWithLogSystemUnitTests
{
    [Test]
    public void SendFunction_CatchSendResult_Success()
    {
        // Arrange
        MockEmailSuccessSerivce mockEmailService = new MockEmailSuccessSerivce();
        StubLogSerivce stubLogService = new StubLogSerivce();
        
        EmailWithLogSystem EmailWithLogService = new EmailWithLogSystem(mockEmailService, stubLogService);
        
        // Act
        EmailWithLogService.SendFunction("[email protected]", "Test Demo");
        
        // Assert
        Assert.AreEqual("Success", mockEmailService.SendResult);
    }
}

public class MockEmailSuccessSerivce : IEmailService
{
    public string SendResult

    public string SendEmail(mailAddress, mailMessage)
    {
        SendResult = "Success";
        
        return "Success";
    }
}

public class StubLogSerivce : ILogService
{
    public string logMessage;
    
    public string Log(string LogMessage)
    {
        logMessage = LogMessage;
    }
}

昨天提到当我们 SendEmail 的时候,我们去验证方法提供的回传值;然而,今天我们却以模拟物件的写法去验证是不是有做 SendEmail 这个动作,当之後若规格发生改变,改变了回传值,我们也需相对应改模拟物件的方法,这样使得程序码维护变困难却没有任何测试效益。


假物件链

好,那接下来又要提更抽象的东西 XD,假设我们今天新增了一个虚设常式,然後这个虚设常式又可以再新增虚设常式,甚至可以新增要验证的模拟物件,形成一个假物件链(这难道是传说中的假物件俄罗斯娃娃套餐!?XDD)。

举个例子:

public class EmailWithLogServiceFactory()
{
    public class StubEmailSuccessSerivce : IEmailService
    {
        public string SendEmail(mailAddress, mailMessage)
        {
            return "Success";
        }
    }
    
    public class StubEmailFailSerivce : IEmailService
    {
        public string SendEmail(mailAddress, mailMessage)
        {
            return "Fail";
        }
    }

    public class StubLogSerivce : ILogService
    {
        public string logMessage;

        public string Log(string LogMessage)
        {
            logMessage = LogMessage;
        }
    }
    
    public class MockLogSerivce : ILogService
    {
        public string logMessage;

        public string Log(string LogMessage)
        {
            logMessage = LogMessage;
        }
    }
}

可以看出来,我们把 Day-13 所用到的模拟物件都汇整在 EmailWithLogServiceFactory 里面。实务上,在工厂方法(Factory Method Pattern)的设计框架中,要撰写测试的话就很容易以这种形式撰写。因此,会随着不同的设计方式而决定假物件的设计模式,进而衍生不同的假物件链。


到这边算是把假物件做个简单的概述,撰写单元测试的核心很大一部分就是看假物件怎麽设计,而决定了後续假物件的难易程度。相信如果这几天都有在看的人会发现一件事情,我们花很大的篇幅,在撰写假物件的程序码;其实这会衍生很多问题点(撷取自单元测试的艺术):

  • 撰写模拟物件和虚设常式物件需要花很多时间
  • 如果类别和介面有很多方法、属性或事件,就很难为它手刻模拟物件和虚设常式物件
  • 要保留模拟物件多次被呼叫的状态,你需要在手刻的假物件中写许多样板程序
  • 如果要验证呼叫端对一个方法所传入的多个参数全都是正确的,需要写多个验证语法,非常笨拙
  • 难以在测试中重用模拟物件或虚设常式物件的程序码。一般的程序码还能用,但是一旦介面有两个或三个以上的方法 需要实作,程序码的维护就会显得异常麻烦。

因此,接下来明天终於要介绍另一个单元测试很大的核心概念——隔离框架(isolation framework),教你如何产制动态虚设常式物件(dynamic stub)和动态模拟物件(dynamic mock)。


<<:  [Day6] Git版本控制 - 基本操作篇 (MacOS)

>>:  D13 第七周 (回忆篇)

【Day 9】梯度下降法(Gradient Descent) --- Tip 2, 3

Tip 2:随机梯度下降法(Stochastic Gradient Descent) 提升训练速度 ...

【Day03】Verilog 资料型态(上)

资料型态 值 意义 0 低电位(逻辑0) 1 高电位(逻辑1) Z 高阻抗(High Impende...

Day27 MANO开源专案使用之OSM-建立篇

今天我来讲如何使用osm在kubernetes建立VNF。如果有想要在详细的了解OSM的话可以於他们...

鞋业的制作与知识的应用

任何一项产品和工程的完成,除非是企业够大,能够独立用母子公司运作完成,否则以一般规模的中小企业体,产...

D7. 学习基础C、C++语言

D7: if判断式 if的基本样子是: if(判断式){ 如果条件成立时要做甚麽 } else { ...