在实际的项目中,大部分待测试的类都会有很多的外部依赖,例如一个web服务、文件系统、数据库等,由于这些外部依赖我们无法人为的去控制它们,这样就导致在进行单元测试时,即使待测试的类逻辑非常正确也可能由于依赖出现异常导致单元测试失败。为了消除外部依赖对单元测试的影响,我们就需要使用桩对象(stud)和模拟对象(mock)来解除这些外部依赖。
在正式介绍桩对象和模拟对象之前,先来设计一个简单的并且拥有一个依赖的例子,现在我们有一个MessageService类,它有一个用来判断是否是有效消息的方法IsValidMessage,它会将有效的消息通过DB类保存到数据库中,并返回判断的结果:
1 | public class DB |
2 | { |
3 | private const string ConnectionString = "Data Source =.;Initial Catalog = Example;Integrated Security = True"; |
4 | public void Insert(string message) |
5 | { |
6 | using (SqlConnection conn = new SqlConnection(ConnectionString)) |
7 | { |
8 | string sql = "insert into MessageTable(message) values(@message)"; |
9 | using (SqlCommand cmd = new SqlCommand(sql, conn)) |
10 | { |
11 | cmd.Parameters.Add(new SqlParameter("@message", message)); |
12 | cmd.ExecuteNonQuery(); |
13 | } |
14 | } |
15 | } |
16 | } |
17 | |
18 | public class MessageService |
19 | { |
20 | public bool IsValidMessage(string message) |
21 | { |
22 | if (string.IsNullOrWhiteSpace(message)) |
23 | { |
24 | return false; |
25 | } |
26 | |
27 | var _db = new DB(); |
28 | _db.Insert(message); |
29 | return true; |
30 | } |
31 | } |
针对当前的MessageService我们可以编写这样一个单元测试用例:当输入不为空的消息时返回true
1 | [TestMethod] |
2 | public void IsValidMessage_ValidMessage_ReturnTrue() |
3 | { |
4 | var message = "valid message"; |
5 | MessageService service = new MessageService(); |
6 | var result = service.IsValidMessage(message); |
7 | Assert.IsTrue(result); |
8 | } |
如果DB类的数据库连接字符串是正常,并且数据库服务是正常,那么我们运行这个单元测试会发现其是通过的,可数据库服务是否正常我们是不可控的,DB类的行为是否会被修改也是不可控的,对于单元测试来说,它只关注IsValidMessage自身的逻辑,不应该让外部的依赖来影响我们对它的测试结果,接下来将正式介绍如何用桩对象和模拟对象来解除待测试类的外部依赖。
桩对象
桩对象是外部依赖的一个替代品,换句话就是我们要自己去实现这个依赖项,然后控制它的各种行为,整体的交互入下图所示:
在明白了桩对象的基本概念之后,我们使用桩对象来测试一开始的MessageService类。
使用桩对象首先需要将待测试的类的依赖提取成接口,让待测试类依赖这个接口,然后实现一个继承了该接口的桩类型,最后在待测试类中注入我们的桩对象,来看一下代码。
新建一个IDB接口,MessageService类的构造函数需要传递IDB接口的实例:
1 | public interface IDB |
2 | { |
3 | void Insert(string message); |
4 | } |
5 | |
6 | public class MessageService |
7 | { |
8 | IDB _db; |
9 | public MessageService(IDB db) |
10 | { |
11 | _db = db; |
12 | } |
13 | |
14 | public bool IsValidMessage(string message) |
15 | { |
16 | if (string.IsNullOrWhiteSpace(message)) |
17 | { |
18 | return false; |
19 | } |
20 | |
21 | _db.Insert(message); |
22 | return true; |
23 | } |
24 | } |
在测试项目中创建一个继承了IDB接口的StubDB类,它的Insert方法什么都不做,同时修改我们的单元测试,往MessageService的构造函数中注入StubDB的实例对象:
1 | public class StubDB : IDB |
2 | { |
3 | public void Insert(string message) |
4 | { |
5 | } |
6 | } |
7 | |
8 | [TestClass] |
9 | public class MessageServiceTests |
10 | { |
11 | [TestMethod] |
12 | public void IsValidMessage_ValidMessage_ReturnTrue() |
13 | { |
14 | var message = "valid message"; |
15 | var stub = new StubDB(); |
16 | MessageService service = new MessageService(stub); |
17 | var result = service.IsValidMessage(message); |
18 | Assert.IsTrue(result); |
19 | } |
20 | } |
通过StubDB我们就消除了MessageService的所有外部依赖,我们可以完全控制StubDB的行为,不让它对我们的单元测试产生任何的影响。
模拟对象
模拟对象跟桩对象一样也是外部依赖的一个替代品,但是它主要用来进行交互测试,被测试对象能否将消息传递给其它对象,或者是从其它对象那里成功接收到消息。它与桩对象的区别在于,在桩对象模式下,测试是对被测试对象进行断言的,桩对象不会使测试失败,而模拟对象有可能使单元测试失败,并且测试是对模拟对象进行断言的,我们来看一下模拟对象的交互图:
继续来看一下MessageService类,现在我们新增了一个测试案例:传递一个有效的消息,会正确的调用IDB接口的Insert方法。来看一下如何使用模拟对象实现它:
1 | public class MockDB : IDB |
2 | { |
3 | public string Message { get; private set; } |
4 | public void Insert(string message) |
5 | { |
6 | Message = message; |
7 | } |
8 | } |
9 | |
10 | [TestMethod] |
11 | public void IsValidMessage_ValidMessage_CallInsert() |
12 | { |
13 | var message = "valid message"; |
14 | var mock = new MockDB(); |
15 | MessageService service = new MessageService(mock); |
16 | var result = service.IsValidMessage(message); |
17 | Assert.AreEqual(message, mock.Message); |
18 | } |
我们新建了一个继承IDB接口的MockDB类型,在Insert方法中将message赋值给了它Message属性,在单元测试中对Message属性进行断言,这样就可以测试在传递给IsValidMessage一个有效的消息时能否正确调用IDB接口的Insert方法。
总结
本文介绍了桩对象和模拟对象的基本概念,并通过一个简单的MessageService类来展示如何使用它们,以及它们之间区别。桩对象和模拟对象非常相似,只是使用的场景不一样。我们需要了解这两者的概念和使用方式,因为这不仅能帮助我们更好的进行单元测试,还能帮助我们写出更好的可测试的代码。