在单元测试中模拟依赖项有哪些好处?

8

我正在为我的控制器和服务层(C#,MVC)进行单元测试,使用Moq dll来模拟单元测试中的真实/依赖对象。

但是我对于如何模拟依赖或真实对象有一些困惑。让我们以下面的单元测试方法为例:

[TestMethod]
public void ShouldReturnDtosWhenCustomersFound_GetCustomers ()
{
    // Arrrange 
    var name = "ricky";
    var description = "this is the test";

    // setup mocked dal to return list of customers
    // when name and description passed to GetCustomers method
    _customerDalMock.Setup(d => d.GetCustomers(name, description)).Returns(_customerList);

    // Act
    List<CustomerDto> actual = _CustomerService.GetCustomers(name, description);

    // Assert
    Assert.IsNotNull(actual);
    Assert.IsTrue(actual.Any());

    // verify all setups of mocked dal were called by service
    _customerDalMock.VerifyAll();
}

在上面的单元测试方法中,我模拟了GetCustomers方法并返回一个客户列表。该列表已经定义好了,看起来像下面这样:
List<Customer> _customerList = new List<Customer>
{
    new Customer { CustomerID = 1, Name="Mariya",Description="description"},
    new Customer { CustomerID = 2, Name="Soniya",Description="des"},
    new Customer { CustomerID = 3, Name="Bill",Description="my desc"},
    new Customer { CustomerID = 4, Name="jay",Description="test"},
};

让我们来看一下客户模拟对象和实际对象的断言:

Assert.AreEqual(_customer.CustomerID, actual.CustomerID);
Assert.AreEqual(_customer.Name, actual.Name);
Assert.AreEqual(_customer.Description, actual.Description);

然而,我并不理解这个(上面的单元测试)总是能够正常工作。这意味着我们只是在测试(在断言中)我们传递或返回的内容(在模拟对象中)。而我们知道真正/实际的对象将始终返回我们传递的列表或对象。

那么在这里进行单元测试或模拟的意义是什么?

5个回答

8
模拟的真正目的是实现真正的隔离。
假设您有一个依赖于CustomerRepository的CustomerService类。您编写了一些单元测试,涵盖了CustomerService提供的功能。它们都通过了。
一个月后,进行了一些更改,突然您的CustomerServices单元测试开始失败 - 您需要找到问题所在。
因此,您假设:
因为测试CustomerServices的单元测试失败了,问题肯定在那个类中!!
对吗?错了!问题可能在CustomerServices中,也可能在其任何依赖项中,即CustomerRepository。如果任何一个依赖项失败,被测试的类也很可能会失败。
现在想象一下一个巨大的依赖链:A依赖于B,B依赖于C,... Y依赖于Z。如果在Z中引入故障,则所有单元测试都将失败。
这就是为什么您需要将受测类与其依赖项(无论是域对象、数据库连接、文件资源等)隔离开来。您想要测试一个单元。

1
你的示例太简单,无法展现模拟的真正好处。因为你测试的逻辑除了返回一些数据之外并没有做任何事情。
但是,想象一下,如果你的逻辑基于实际时间(例如每小时定期执行某个过程),那么模拟时间源可以让你实际上对这种逻辑进行单元测试,这样你的测试不必等待数小时才能运行,等待时间流逝。

1
除了已经说过的内容之外:
我们可以有没有依赖的类。唯一的事情就是进行单元测试,没有模拟和存根。
当我们有依赖关系时,有几种不同类型的依赖关系:
- 我们的类大多以“fire and forget”的方式使用的服务,即不影响消费代码的控制流的服务。 - 我们可以模拟这些(以及所有其他类型)服务来测试它们是否被正确调用(集成测试),或者仅仅是为了注入它们可能被我们的代码所需的内容。
- 提供结果但没有内部状态且不影响系统状态的双向服务。它们可以被称为复杂的数据转换。 - 通过模拟这些服务,您可以测试对于服务实现的不同变体的代码行为的期望,而无需拥有它们全部。
- 影响系统状态或依赖于真实世界现象或您无法控制的东西的服务。 "@500 - Internal Server Error" 给出了时间服务的很好的例子。
使用mocking,可以让时间以任何所需的速度(和方向)流动。另一个例子是与数据库(DB)一起工作。在单元测试中,通常希望不改变DB状态,而这在功能测试中并不成立。对于此类服务,“隔离”是模拟的主要(但不是唯一)动机。
  • 依赖于您代码内部状态的服务。
考虑Entity Framework:
当调用SaveChanges()时,很多事情会在幕后发生。EF检测更改并修复导航属性。此外,EF不允许您添加具有相同键的多个实体。
显然,模拟此类依赖关系的行为和复杂性可能非常困难...但通常情况下,如果它们被设计良好,则不需要。如果您严重依赖某些组件提供的功能,则很难替换此依赖关系。可能需要的是隔离。您不希望在测试时留下痕迹,因此更好的方法是告诉EF不要使用真实的DB。是的,依赖关系意味着不仅仅是简单的接口。更常见的情况是期望行为的契约而不是方法签名。例如,IDbConnection具有Open()Close()方法,这意味着某些调用序列。
当然,这不是严格的分类。最好将其视为极端情况。
@dcastro写道:你想测试一个单元。然而,该语句并没有回答你是否应该测试它。
不要忽略集成测试。有时知道系统的某个复合部分存在故障是可以接受的。
至于@dcastro提供的依赖链示例,我们可以尝试找到可能出现问题的位置:
假设Z是最终依赖项。我们为其创建没有模拟的单元测试。所有边界条件都已知。这里必须达到100%的覆盖率。之后,我们说Z正常工作。如果Z失败,我们的单元测试必须指出它。
类比来自工程学。在建造飞机时,没有人会测试每个螺钉和螺栓。
统计方法用于证明生产零件的工厂运行良好。
另一方面,对于系统中非常关键的部分,花费时间模拟依赖项的复杂行为是合理的。是的,它越复杂,测试就越难以维护。在这里,我更愿意称它们为规范检查。
是的,您的api和测试都可能是错误的,但代码审查和其他形式的测试可以在一定程度上确保代码的正确性。一旦这些测试在进行了一些更改后失败,您就需要更改规范和相应的测试,或者找到错误并使用测试覆盖该情况。
我强烈建议您观看Roy的视频:http://youtube.com/watch?v=fAb_OnooCsQ

0
在这种情况下,模拟允许您伪造数据库连接,以便您可以在原地和内存中运行测试,而不依赖于任何其他资源,即数据库。这些测试断言当调用服务时,会调用相应的DAL方法。
然而,列表中后面的断言和列表中的值并不是必要的。正如您正确注意到的那样,您只需要断言返回了您“模拟”的值。这对于模拟框架本身很有用,以确保模拟方法的行为符合预期。但在您的代码中,这只是多余的。
一般情况下,模拟允许您:
  • 测试行为(当发生某事时,执行特定方法)
  • 伪造资源(例如,电子邮件服务器、Web 服务器、HTTP API 请求/响应、数据库)
相比之下,没有模拟的单元测试通常允许您测试状态。也就是说,当调用特定方法时,您可以检测对象状态的变化。

0

所有之前的答案都假定模拟具有某种价值,并继续解释该价值应该是什么。

为了满足那些可能寻求解决此问题的哲学上异议的后代,这里提出一个持不同意见的观点:

虽然模拟是一个很好的技巧,但(几乎)应该避免使用。

当您模拟代码测试中的依赖项时,您“根据定义”做出了两种假设:

  • 关于依赖项的行为的假设
  • 关于您的代码测试内部工作方式的假设

可以争辩说,对依赖项行为的假设是无害的,因为它们只是规定真实依赖项应如何根据某些要求或规范文档运行。我愿意接受这一点,但请注意,它们仍然是假设,每当您做出假设时,您就在危险中生活。

现在,无法争辩的是,你对待测试代码内部工作的假设本质上将你的测试变成了白盒测试:模拟对象期望被测试代码向其依赖项发出特定调用,带有特定参数,并且当模拟对象返回特定结果时,期望被测试代码以特定方式行为。

如果你正在构建高关键性(航空级)软件,并且目标是不留任何机会,成本不是问题,那么白盒测试可能是合适的。它比黑盒测试多个数量级的劳动强度,因此费用非常昂贵,对于商业软件来说,它完全是过度杀伤力,因为目标只是满足需求,而不是确保内存中的每一位都具有某个精确的预期值。

白盒测试劳动强度很大,因为它使测试极其脆弱:每次修改测试代码内部工作,即使修改不是为了响应需求变化,你也必须去修改编写用于测试该代码的每个模拟对象。这是一个极高的维护水平。

如何避免模拟和黑盒测试

  • 使用伪造对象而不是模拟对象
    • 如果你想了解它们之间的区别,可以阅读马丁·福勒(Martin Fowler)的这篇文章:https://martinfowler.com/bliki/TestDouble.html。但是,为了给你一个例子,内存数据库可以用作伪造对象来代替完整的关系型数据库管理系统(RDBMS)。 (请注意,伪造对象比模拟对象更真实。)
    • 伪造对象将为您提供与模拟对象相同的隔离程度,但没有所有冒险和昂贵的假设,最重要的是,没有所有脆弱性。
  • 进行集成测试而不是单元测试
    • 当然,在可能的情况下尽量使用伪造对象。

有关我对此主题的思考的更长文章,请参见https://blog.michael.gr/2021/12/white-box-vs-black-box-testing.html


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接