标题有点标题党,但相信各位看完这篇文章一定会所收获,如果之前没有接触过单元测试或了解不深通过本文都能对单元测试有个全新认识。本文的特点是不脱离实际,所测试的代码都是常见的模式。
写完这篇文章后,我看了一些关于单元测试理论的东西,发现文章中有些好像不太合主流测试理论,由于理论和使用个人难以完美结合,只能取实用为本。
另外本文编写的单元测试都是基于已有代码进行测试,而不是TDD倡导的现有测试后有可以工作的代码,不同思想指导下写出的测试代码可能不太一样。
最近的项目中写了一个巨长的函数,调试的时候总是发现各种潜在的问题,一遍一遍的F5启动调试,键盘都快按烂了,那个函数还没跑通。想了想,弄个单元测试很有必要,说干就干。找来xUnit等把单元测试搞了起来。说来这还是第一次正八经的搞单元测试,想想有必要把这个过程记录一下,于是这篇文章就诞生了。
进行单元测试代码编写的过程中收获还真不少,比如把那个巨长的函数重构为4个功能相对独立,大小适中的函数。另外写测试可以以用户的角度使用函数,从而发现了几个之前没有想到的应该进行逻辑判断的地方并在程序代码中加入了if段。其实这些都是单元测试的好处,当然单元测试的利可能不只这些,总之越早在项目中加入单元测试越是事半功倍。
这篇文章对单元测试做了一些总结,当然最重要的是记录了Mocks工具的使用。在这次单元测试之前我对单元测试的了解停留在几个测试框架的测试方法上。拿测试运行器干的最多的事不是“测试”而是“调试”。即一般都是在一个类及函数不方便启动程序来调试时,搞一个测试类,用测试运行器的调试功能专门去Debug这个方法。这其实也只是用了测试框架(和测试运行器)很小的一部分功能。
在开始正题之前说一说单元测试工具的选择。现在xUnit.net几乎成为了准官方的选择。xUnit.net配套工具完善,上手简单初次接触单元测试是很好的选择。测试运行器选择了Resharper的xUnit runner插件(Resharper也是vs必不可少的插件),个人始终感觉VS自带的测试运行工具远不如Resharper的好用。Mock框架选择了大名鼎鼎的RhinoMocks,神一样的开源Mock框架。
由于我是单元测试新手,这也是第一次比较仔细的写单元测试,最大的体会就是Mock工具要比Test Framework与编写单元测试代码的用户关系更密切。本文将从最简单的测试开始争取将所有可能遇到的测试情况都写出来,如有不完整也请帮忙指出,如有错误请不吝赐教。
插播一下,xUnit.net的安装很简单,打开Nuget包管理器找到xUnit.net并安装就可以了(写这篇文章是最新正式版是2.0,2.1到了RC),就是一些程序集。RhinoMocks也是同理。Resharper的xUnit Test Runner通过Resharper的Extension Manager(有这么一个菜单项)来安装,点击菜单弹出如下图的对话框:
图1
写这段内容时,xUnit.net Test Runner排在显眼的第一位,点击ToggleButton切换到Install,点击安装就可以了,完了需要重启vs。
ps.新版的Resharper Extension Manager基于Nuget实现,我这里的联通宽带连nuget常周期性抽风,有时不得不走代理,速度龟慢。
这里先展示一个最简单的方法及测试,目的是让没有接触过单元测试的同学有个直观印象:
被测方法是一个计算斐波那契数的纯计算方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
public int Fibonacci( int n)
{ if (n == 1 || n == 2)
{
return 1;
}
int first = 1;
int second = 1;
for ( int i = 2; i < n; i++)
{
var temp = second;
second += first;
first = temp;
}
return second;
} |
测试方法:
1
2
3
4
5
6
7
|
[Fact] public void Test_Fibonacci_N()
{ var act = Fibonacci(10);
var expect = 55;
Assert.True(act == expect);
} |
xUnit最简单的使用就是在测试方法上标记[Fact],如果使用Resharper Test Runner的话在vs的代码窗口中可以看到这样这样一个小圆圈,点击就可以“运行”或“调式”这个测试方法。(其它runner也类似)
图2
在测试方法所在的类声明那行前面也有一个这个的圆点,点击后可以执行类中所有测试方法。如果测试通过圆点是绿色小球标识,如果不通过会以红色标记显示。
另外也可以打开Resharper的UnitTest窗口,里面会列出项目中所有的单元测试,也可以通过这个执行单个或批量测试:
图3
我们执行上面的测试,可以看到下面的结果:
图4
嗯 ,我们的测试通过了。有时候我们还会编写一些测试,测试相反的情况,或边界情况。如:
1
2
3
4
5
6
7
|
[Fact] public void Test_Fibonacci_N_Wrong()
{ var act = Fibonacci(11);
var expect = 55;
Assert.False(act == expect);
} |
在团队人员配置比较齐全的情况下,设计测试用例应该是测试人员的工作,程序员按照设计好的测试用例编写测试方法,对被测试方法进行全方面的测试。
除了上面用到的Assert.True/False,xUnit还提供了如下几种断言方法(以2.0版为准,表格尽量给这些方法分类排的序,可能不太完整):
断言 | 说明 |
---|---|
Assert.Equal() | 验证两个参数是否相等,支持字符串等常见类型。同时有泛型方法可用,当比较泛型类型对象时使用默认的IEqualityComparer<T>实现,也有重载支持传入IEqualityComparer<T> |
Assert.NotEqual() | 与上面的相反 |
Assert.Same() | 验证两个对象是否同一实例,即判断引用类型对象是否同一引用 |
Assert.NotSame() | 与上面的相反 |
Assert.Contains() | 验证一个对象是否包含在序列中,验证一个字符串为另一个字符串的一部分 |
Assert.DoesNotContain() | 与上面的相反 |
Assert.Matches() | 验证字符串匹配给定的正则表达式 |
Assert.DoesNotMatch() | 与上面的相反 |
Assert.StartsWith() | 验证字符串以指定字符串开头。可以传入参数指定字符串比较方式 |
Assert.EndsWith() | 验证字符串以指定字符串结尾 |
Assert.Empty() | 验证集合为空 |
Assert.NotEmpty() | 与上面的相反 |
Assert.Single() | 验证集合只有一个元素 |
Assert.InRange() | 验证值在一个范围之内,泛型方法,泛型类型需要实现IComparable<T>,或传入IComparer<T> |
Assert.NotInRange() | 与上面的相反 |
Assert.Null() | 验证对象为空 |
Assert.NotNull() | 与上面的相反 |
Assert.StrictEqual() | 判断两个对象严格相等,使用默认的IEqualityComparer<T>对象 |
Assert.NotStrictEqual() | 与上面相反 |
Assert.IsType()/Assert.IsType<T>() | 验证对象是某个类型(不能是继承关系) |
Assert.IsNotType()/ Assert.IsNotType<T>() |
与上面的相反 |
Assert.IsAssignableFrom()/ Assert.IsAssignableFrom<T>() |
验证某个对象是指定类型或指定类型的子类 |
Assert.Subset() | 验证一个集合是另一个集合的子集 |
Assert.ProperSubset() | 验证一个集合是另一个集合的真子集 |
Assert.ProperSuperset() | 验证一个集合是另一个集合的真超集 |
Assert.Collection() | 验证第一个参数集合中所有项都可以在第二个参数传入的Action<T>序列中相应位置的Action<T>上执行而不抛出异常。 |
Assert.All() |
验证第一个参数集合中的所有项都可以传入第二个Action<T>类型的参数而不抛出异常 。与Collection()类似,区别在于这里Action<T>只有一个而不是序列。 |
Assert.PropertyChanged() | 验证执行第三个参数Action<T>使被测试INotifyPropertyChanged对象触发了PropertyChanged时间,且属性名为第二个参数传入的名称。 |
Assert.Throws()/Assert.Throws<T>() Assert.ThrowsAsync()/ Assert.ThrowsAsync<T>() |
验证测试代码抛出指定异常(不能是指定异常的子类) 如果测试代码返回Task,应该使用异步方法 |
Assert.ThrowsAny<T>() Assert.ThrowsAnyAsync<T>() |
验证测试代码抛出指定异常或指定异常的子类 如果测试代码返回Task,应该使用异步方法 |
编写单元测试的测试方法就是传说中的3个A,Arrange、Act和Assert。
-
Arrange用于初始化一些被测试方法需要的参数或依赖的对象。
-
Act方法用于调用被测方法获取返回值。
-
Assert用于验证测试方法是否按期望执行或者结果是否符合期望值
大部分的测试代码都应按照这3个部分来编写,上面的测试方法中只有Act和Assert2部分,对于逻辑内聚度很高的函数,这2部分就可以很好的工作。像是一些独立的算法等按上面编写测试就可以了。但是如果被测试的类或方法依赖其它对象我们就需要编写Arrange部分来进行初始化。下一节就介绍相关内容。
2.被测试类需要初始化的情况
在大部分和数据库打交道的项目中,尤其是使用EntityFramework等ORM的项目中,常常会有IRepository和Repository<T>这样的身影。我所比较赞同的一种对这种仓储类测试的方法是:使用真实的数据库(这个真实指的非Mock,一般来说使用不同于开发数据库的测试数据库即可,通过给测试方法传入测试数据库的链接字符串实现),并且相关的DbContext等都直接使用EntityFramework的真实实现而不是Mock。这样,在IRepository之上的所有代码我们都可以IRepository的Mock来作为实现而不用去访问数据库。
如果对于实体存储到数据库可能存在的问题感到担心,如类型是否匹配,属性是否有可空等等,我们也可以专门给实体写一些持久化测试。为了使这个测试的代码编写起来更简单,我们可以把上面测试好的IRepository封装成一个单独的方法供实体的持久化测试使用。
下面将给出一些示例代码:
首先是被测试的IRepository
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
public interface IRepository<T> where T : BaseEntity
{ T GetById( object id);
void Insert(T entity);
void Update(T entity);
void Delete(T entity);
IQueryable<T> Table { get ; }
IQueryable<T> TableNoTracking { get ; }
void Attach(T entity);
} |
这是一个项目中最常见的IRepository接口,也是最简单化的,没有异步支持,没有Unit of Work支持,但用来演示单元测试足够了。这个接口的实现代码EFRepository就不列出来的(用EntityFramework实现这个接口的代码大同小异)。下面给出针对这个接口进行的测试并分析测试中的一些细节。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
|
public class EFRepositoryTests:IDisposable
{ private const string TestDatabaseConnectionName = "DefaultConnectionTest" ;
private readonly IDbContext _context;
private readonly IRepository<User> _repository; //用具体的泛型类型进行测试,这个不影响对EFRepository测试的效果
public EFRepositoryTests()
{
_context = new MyObjectContext(TestDatabaseConnectionName);
_repository = new EfRepository<User>(_context);
}
[Fact]
public void Test_insert_getbyid_table_tablenotracking_delete_success()
{
var user = new User()
{
UserName = "zhangsan" ,
CreatedOn = DateTime.Now,
LastActivityDate = DateTime.Now
};
_repository.Insert(user);
var newUserId = user.Id;
Assert.True(newUserId > 0);
//声明新的Context,不然查询直接由DbContext返回而不经过数据库
using ( var newContext = new MyObjectContext(TestDatabaseConnectionName))
{
var repository = new EfRepository<User>(newContext);
var userInDb = repository.GetById(newUserId);
user.UserName.ShouldEqual(userInDb.UserName);
}
using ( var newContext = new MyObjectContext(TestDatabaseConnectionName))
{
var repository = new EfRepository<User>(newContext);
var userInDb = repository.Table.Single(r => r.Id == newUserId);
user.UserName.ShouldEqual(userInDb.UserName);
}
using ( var newContext = new MyObjectContext(TestDatabaseConnectionName))
{
var repository = new EfRepository<User>(newContext);
var userInDb = repository.TableNoTracking.Single(r => r.Id == newUserId);
user.UserName.ShouldEqual(userInDb.UserName);
}
_context.Entry(user).State.ShouldEqual(EntityState.Unchanged);
_repository.Delete(user);
using ( var newContext = new MyObjectContext(TestDatabaseConnectionName))
{
var repository = new EfRepository<User>(newContext);
var userInDb = repository.GetById(newUserId);
userInDb.ShouldBeNull();
}
}
[Fact]
public void Test_insert_update_attach_success()
{
var user = new User()
{
UserName = "zhangsan" ,
CreatedOn = DateTime.Now,
LastActivityDate = DateTime.Now
};
_repository.Insert(user);
var newUserId = user.Id;
Assert.True(newUserId > 0);
//update
using ( var newContext = new MyObjectContext(TestDatabaseConnectionName))
{
var repository = new EfRepository<User>(newContext);
var userInDb = repository.GetById(newUserId);
userInDb.UserName = "lisi" ;
repository.Update(userInDb);
}
//assert
using ( var newContext = new MyObjectContext(TestDatabaseConnectionName))
{
var repository = new EfRepository<User>(newContext);
var userInDb = repository.GetById(newUserId);
userInDb.UserName.ShouldEqual( "lisi" );
}
//update by attach&modifystate
using ( var newContext = new MyObjectContext(TestDatabaseConnectionName))
{
var repository = new EfRepository<User>(newContext);
var userForUpdate = new User()
{
Id = newUserId,
UserName = "wangwu" ,
CreatedOn = DateTime.Now,
LastActivityDate = DateTime.Now
};
repository.Attach(userForUpdate);
var entry = newContext.Entry(userForUpdate);
entry.State.ShouldEqual(EntityState.Unchanged); //assert
entry.State = EntityState.Modified;
repository.Update(userForUpdate);
}
//assert
using ( var newContext = new MyObjectContext(TestDatabaseConnectionName))
{
var repository = new EfRepository<User>(newContext);
var userInDb = repository.GetById(newUserId);
userInDb.UserName.ShouldEqual( "wangwu" );
}
_repository.Delete(user);
}
public void Dispose()
{
_context.Dispose();
}
} |
如代码所示,通过2个测试方法覆盖了对IRepository方法的测试。在测试类的成员中声明了被测试接口的对象以及这些接口所依赖的成员的对象。这个场景是测试数据仓储所以这些依赖对象使用真实类型而非Mock(后文会见到使用Mock的例子)。然后在构造函数中对这些成员进行初始化。这些部分都是测试的Arrange部分。即对于所有测试方法通用的初始化信息我们放在测试类构造函数完成,因测试方法而异的Arrange在每个测试方法中完成。
测试方法中用的到扩展方法可以见文章最后一小节。
对于需要清理分配资源的测试类,可以实现IDisposable接口并实现相应Dispose方法,xUnit.net将负责将构造函数中分配对象的释放。
xUnit.net每次执行测试方法时,都是实例化一个测试类的新对象,比如执行上面的测试类中的两个测试测试方法会执行测试类的构造函数两次(Dispose也会执行两次保证分配的对象被释放)。这种设置使每个测试方法都有一个干净的上下文来执行,不同测试方法使用同名的测试类成员不会产生冲突。
3.避免重复初始化
如果测试方法可以共用相同的测试类成员,或是出于提高测试执行速度考虑我们希望在执行类中测试方法时初始化代码只执行一次,可以使用下面介绍的方法来共享同一份测试上下文(测试类的对象):
首先实现一个Fixture类用来完成需要共享的对象的初始化和释放工作:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
public class DbContextFixture: IDisposable
{ private const string TestDatabaseConnectionName = "DefaultConnectionTest" ;
public readonly IDbContext Context;
public DbContextFixture()
{
Context = new MyObjectContext(TestDatabaseConnectionName);
}
public void Dispose()
{
Context.Dispose();
}
} |
下面是重点,请注意怎样在测试类中使用这个Fixture:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
public class EFRepositoryByFixtureTests : IClassFixture<DbContextFixture>
{ private readonly IDbContext _context;
private readonly IRepository<User> _repository;
public EFRepositoryByFixtureTests(DbContextFixture dbContextFixture)
{
_context = dbContextFixture.Context;
_repository = new EfRepository<User>(_context);
}
//测试方法略...
} |
测试类实现了IClassFixture<>接口,然后可以通过构造函数注入获得前面的Fixture类的对象(这个注入由xUnit.net来完成)。
这样所有测试方法将共享同一个Fixture对象,即DbContext只被初始化一次。
除了在同一个类的测试方法之间共享测试上下文,也可以在多个测试类之间共享测试上下文:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
public class DbContextFixture : IDisposable
{ private const string TestDatabaseConnectionName = "DefaultConnectionTest" ;
public readonly IDbContext Context;
public DbContextFixture()
{
Context = new GalObjectContext(TestDatabaseConnectionName);
}
public void Dispose()
{
Context.Dispose();
}
} [CollectionDefinition( "DbContext Collection" )]
public class DbContextCollection : ICollectionFixture<DbContextFixture>
{ } |
Fixture类和之前一模一样,这次多了一个Collection结尾的类来实现一个名为ICollectionFixture<>接口的类。这个类没有代码其最主要的作用的是承载这个CollectionDefinition Attribute,这个特性的名字非常重要。
来看一下在测试类中怎么使用:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
全部评论
专题导读
热门推荐
热门话题
阅读排行榜
|
请发表评论