NUnit是.net平台上使用得最为广泛的测试框架之一,本文将通过示例来描述NUnit的使用方法,并提供若干编写单元测试的建议和技巧,供单元测试的初学者参考。
继续下文之前,先来看看一个非常简单的测试用例(TestCase):
2 public void AdditionTest()
3 {
4 int expectedResult = 2 ;
5
6 Assert.AreEqual(exptectedResult, 1 + 1 );
7 }
你肯定会说这个TestCase也太白痴了吧!这也是许多NUnit文档被人诟病的一点,但是我的理解并不是这样,xUnit本来就是编写UT的简易框架,keep it simple and stupid,任何通过复杂的TestCase来介绍NUnit的用法都是一种误导,UT复杂之处在于如何在实际项目中应用和实施,而不是徘徊于该如何使用NUnit。
主要内容:
1、NUnit的基本用法
2、测试用例的组织
3、NUnit的断言(Assert)
4、常用单元测试工具介绍
一、NUnit的基本用法
和其他xNUnit框架不同的是,NUnit框架使用Attribute(如前面代码中的[Test])来描述测试用例的,也就是说我们只要掌握了 Attribute的用法,也就基本学会如何使用NUnit了。VSTS所集成的单元测试也支持类似NUnit的Attributes,下表对比了 NUnit和VSTS的标记:
usage | NUnit attributes | VSTS attributes |
标识测试类 | TestFixture | TestClass |
标识测试用例(TestCase) | Test | TestMethod |
标识测试类初始化函数 | TestFixtureSetup | ClassInitialize |
标识测试类资源释放函数 | TestFixtureTearDown | ClassCleanup |
标识测试用例初始化函数 | Setup | TestInitialize |
标识测试用例资源释放函数 | TearDown | TestCleanUp |
标识测试用例说明 | N/A | Description |
标识忽略该测试用例 | Ignore | Ignore |
标识该用例所期望抛出的异常 | ExpectedException | ExpectedException |
标识测试用例是否需要显式执行 | Explicit | ? |
标识测试用例的分类 | Category | ? |
现在,让我们找一个场景,通过示例来了解上述NUnit标记的用法。来看看一个存储在数据库中的数字类:
这是我们常见的DAL+Entity的设计,DigitDataProvider和Digit类的实现代码如下:
1)Digit.cs类:
2 using System.Data;
3
4 namespace Product
5 {
6 /// <summary>
7 /// Digit 的摘要说明
8 /// </summary>
9 /// 创 建 人: Aero
10 /// 创建日期: 2006-3-22
11 /// 修 改 人:
12 /// 修改日期:
13 /// 修改内容:
14 /// 版 本:
15 public class Digit
16 {
17 private Guid _digitId;
18 public Guid DigitID
19 {
20 get { return this ._digitId; }
21 set { this ._digitId = value; }
22 }
23
24 private int _value = 0 ;
25 public int Value
26 {
27 get { return this ._value; }
28 set { this ._value = value; }
29 }
30
31 #region 构造函数
32 /// <summary>
33 /// 默认无参构造函数
34 /// </summary>
35 /// 创 建 人: Aero
36 /// 创建日期: 2006-3-22
37 /// 修 改 人:
38 /// 修改日期:
39 /// 修改内容:
40 public Digit()
41 {
42 //
43 // TODO: 在此处添加构造函数逻辑
44 //
45 }
46
47 /// <summary>
48 /// construct the digit object from a datarow
49 /// </summary>
50 /// <param name="row"></param>
51 public Digit(DataRow row)
52 {
53 if (row == null )
54 {
55 throw new ArgumentNullException();
56 }
57
58 if (row[ " DigitID " ] != DBNull.Value)
59 {
60 this ._digitId = new Guid(row[ " DigitID " ].ToString());
61 }
62
63 if (row[ " Value " ] != DBNull.Value)
64 {
65 this ._value = Convert.ToInt32(row[ " Value " ]);
66 }
67 }
68
69 #endregion
70 }
71 }
72
2)DigitDataProvider类:
2 using System.Data;
3 using System.Data.SqlClient;
4 using System.Collections;
5
6 namespace Product
7 {
8 /// <summary>
9 /// DigitDataProvider 的摘要说明
10 /// </summary>
11 /// 创 建 人: Aero
12 /// 创建日期: 2006-3-22
13 /// 修 改 人:
14 /// 修改日期:
15 /// 修改内容:
16 /// 版 本:
17 public class DigitDataProvider
18 {
19 /// <summary>
20 /// 定义数据库连接
21 /// </summary>
22 private SqlConnection _dbConn;
23 public SqlConnection Connection
24 {
25 get { return this ._dbConn; }
26 set { this ._dbConn = value; }
27 }
28
29 #region 构造函数
30 /// <summary>
31 /// 默认无参构造函数
32 /// </summary>
33 /// 创 建 人: Aero
34 /// 创建日期: 2006-3-22
35 /// 修 改 人:
36 /// 修改日期:
37 /// 修改内容:
38 public DigitDataProvider()
39 {
40 //
41 // TODO: 在此处添加构造函数逻辑
42 //
43 }
44
45 public DigitDataProvider(SqlConnection conn)
46 {
47 this ._dbConn = conn;
48 }
49
50 #endregion
51
52 #region 成员函数定义
53
54 /// <summary>
55 /// retrieve all Digits in the database
56 /// </summary>
57 /// <returns></returns>
58 public ArrayList GetAllDigits()
59 {
60 // retrieve all digit record in database
61 SqlCommand command = this ._dbConn.CreateCommand();
62 command.CommandText = " SELECT * FROM digits " ;
63 SqlDataAdapter adapter = new SqlDataAdapter(command);
64 DataSet results = new DataSet();
65 adapter.Fill(results);
66
67 // convert rows to digits collection
68 ArrayList digits = null ;
69
70 if (results != null && results.Tables.Count > 0 )
71 {
72 DataTable table = results.Tables[ 0 ];
73 digits = new ArrayList(table.Rows.Count);
74
75 foreach (DataRow row in table.Rows)
76 {
77 digits.Add( new Digit(row));
78 }
79 }
80
81 return digits;
82 }
83
84 /// <summary>
85 /// remove all digits from the database
86 /// </summary>
87 /// <returns></returns>
88 public int RemoveAllDigits()
89 {
90 // retrieve all digit record in database
91 SqlCommand command = this ._dbConn.CreateCommand();
92 command.CommandText = " DELETE FROM digits " ;
93
94 return command.ExecuteNonQuery();
95 }
96
97 /// <summary>
98 /// retrieve and return the entity of given value
99 /// </summary>
100 /// <exception cref="System.NullReferenceException"> entity not exist in the database </exception>
101 /// <param name="value"></param>
102 /// <returns></returns>
103 public Digit GetDigit( int value)
104 {
105 // retrieve entity of given value
106 SqlCommand command = this ._dbConn.CreateCommand();
107 command.CommandText = " SELECT * FROM digits WHERE Value=' " + value + " ' " ;
108 SqlDataAdapter adapter = new SqlDataAdapter(command);
109 DataSet results = new DataSet();
110 adapter.Fill(results);
111
112 // convert rows to digits collection
113 Digit digit = null ;
114
115 if (results != null && results.Tables.Count > 0
116 && results.Tables[ 0 ].Rows.Count > 0 )
117 {
118 digit = new Digit(results.Tables[ 0 ].Rows[ 0 ]);
119 }
120 else
121 {
122 throw new NullReferenceException( " not exists entity of given value " );
123 }
124
125 return digit;
126 }
127
128 /// <summary>
129 /// remove prime digits from database
130 /// </summary>
131 /// <returns></returns>
132 public int RemovePrimeDigits()
133 {
134 throw new NotImplementedException();
135 }
136
137 #endregion
138 }
139 }
140
3)新建测试数据库:
[ DigitID ] [ uniqueidentifier ] NOT NULL ,
[ Value ] [ int ] NOT NULL
) ON [ PRIMARY ]
GO
下面,我们开始尝试为DigitDataProvider类编写UT,新建DigitDataProviderTest.cs类。
1、添加nunit.framework引用:
并在DigitDataProviderTest.cs中添加:
2、编写测试用例
1)标识测试类:NUnit要求每个测试类都必须添加TestFixture的Attribute,并且携带一个public无参构造函数。
2 public class DigitProviderTest
3 {
4 public DigitProviderTest()
5 {
6 }
7 }
2)编写DigitDataProvider.GetAllDigits()的测试函数
2 /// regular test of DigitDataProvider.GetAllDigits()
3 /// </summary>
4 [Test]
5 public void TestGetAllDigits()
6 {
7 // initialize connection to the database
8 // note: change connection string to ur env
9 IDbConnection conn = new SqlConnection(
10 " Data source=localhost;user id=sa;password=sa;database=utdemo " );
11 conn.Open();
12
13 // preparing test data
14 IDbCommand command = conn.CreateCommand();
15 string commadTextFormat = " INSERT INTO digits(DigitID, Value) VALUES('{0}', '{1}') " ;
16
17 for ( int i = 1 ; i <= 100 ; i ++ )
18 {
19 command.CommandText = string .Format(
20 commadTextFormat, Guid.NewGuid().ToString(), i.ToString());
21 command.ExecuteNonQuery();
22 }
23
24 // test DigitDataProvider.GetAllDigits()
25 int expectedCount = 100 ;
26 DigitDataProvider provider = new DigitDataProvider(conn as SqlConnection);
27 IList results = provider.GetAllDigits();
28
29 // that works?
30 Assert.IsNotNull(results);
31 Assert.AreEqual(expectedCount, results.Count);
32
33 // delete test data
34 command = conn.CreateCommand();
35 command.CommandText = " DELETE FROM digits " ;
36 command.ExecuteNonQuery();
37
38 // close connection to the database
39 conn.Close();
40 }
什么?很丑?很麻烦?这个问题稍后再讨论,先来看看一个完整的测试用例该如何定义:
2 public void TestCase()
3 {
4 // 1) initialize test environement, like database connection
5
6
7 // 2) prepare test data, if neccessary
8
9
10 // 3) test the production code by using assertion or Mocks.
11
12
13 // 4) clear test data
14
15
16 // 5) reset the environment
17
18 }
NUnit要求每一个测试函数都可以独立运行(往往有人会误解NUnit并按照Consoler中的排序来执行),这就要求我们在调用目标函数之前先要初始化目标函数执行所需要的环境,如打开数据库连接、添加测试数据等。为了不影响其他的测试函数,在调用完目标函数后,该测试函数还要负责还原初始环境,如删除测试数据和关闭数据库连接等。对于同一测试类里的测试函数来说,这些操作往往是相同的,让我们对上面的代码进行一次Refactoring, Extract Method:
2 /// connection to database
3 /// </summary>
4 private static IDbConnection _conn;
5
6 /// <summary>
7 /// 初始化测试类所需资源
8 /// </summary>
9 [TestFixtureSetUp]
10 public void ClassInitialize()
11 {
12 // note: change connection string to ur env
13 DigitProviderTest._conn = new SqlConnection(
14 " Data source=localhost;user id=sa;password=sa;database=utdemo " );
15 DigitProviderTest._conn.Open();
16 }
17
18 /// <summary>
19 /// 释放测试类所占用资源
20 /// </summary>
21 [TestFixtureTearDown]
22 public void ClassCleanUp()
23 {
24 DigitProviderTest._conn.Close();
25 }
26
27 /// <summary>
28 /// 初始化测试函数所需资源
29 /// </summary>
30 [SetUp]
31 public void TestInitialize()
32 {
33 // add some test data
34 IDbCommand command = DigitProviderTest._conn.CreateCommand();
35 string commadTextFormat = " INSERT INTO digits(DigitID, Value) VALUES('{0}', '{1}') " ;
36
37 for ( int i = 1 ; i <= 100 ; i ++ )
38 {
39 command.CommandText = string .Format(
40 commadTextFormat, Guid.NewGuid().ToString(), i.ToString());
41 command.ExecuteNonQuery();
42 }
43 }
44
45 /// <summary>
46 /// 释放测试函数所需资源
47 /// </summary>
48 [TearDown]
49 public void TestCleanUp()
50 {
51 // delete all test data
52 IDbCommand command = DigitProviderTest._conn.CreateCommand();
53 command.CommandText = " DELETE FROM digits " ;
54
55 command.ExecuteNonQuery();
56 }
57
58 /// <summary>
59 /// regular test of DigitDataProvider.GetAllDigits()
60 /// </summary>
61 [Test]
62 public void TestGetAllDigits()
63 {
64 int expectedCount = 100 ;
65 DigitDataProvider provider =
66 new DigitDataProvider(DigitProviderTest._conn as SqlConnection);
67
68 IList results = provider.GetAllDigits();
69 // that works?
70 Assert.IsNotNull(results);
71 Assert.AreEqual(expectedCount, results.Count);
72 }
NUnit提供了以下Attribute来支持测试函数的初始化:
TestFixtureSetup:在当前测试类中的所有测试函数运行前调用;
TestFixtureTearDown:在当前测试类的所有测试函数运行完毕后调用;
Setup:在当前测试类的每一个测试函数运行前调用;
TearDown:在当前测试类的每一个测试函数运行后调用。
3)编写DigitDataProvider.RemovePrimeDigits()的测试函数
唉,又忘了质数判断的算法,这个函数先不实现(throw new NotImplementedException()),对应的测试函数先忽略。
2 /// regular test of DigitDataProvider.RemovePrimeDigits
3 /// </summary>
4 [Test, Ignore( " Not Implemented " )]
5 public void TestRemovePrimeDigits()
6 {
7 DigitDataProvider provider =
8 new DigitDataProvider(DigitProviderTest._conn as SqlConnection);
9
10 provider.RemovePrimeDigits();
11 }
Ignore的用法:
4)编写DigitDataProvider.GetDigit()的测试函数
当查找一个不存在的Digit实体时,GetDigit()会不会像我们预期一样抛出NullReferenceExceptioin呢?
2 /// Exception test of DigitDataProvider.GetDigit()
3 /// </summary>
4 [Test, ExpectedException( typeof (NullReferenceException))]
5 public void TestGetDigit()
6 {
7 int expectedValue = 999 ;
8 DigitDataProvider provider =
9 new DigitDataProvider(DigitProviderTest._conn as SqlConnection);
10
11 Digit digit = provider.GetDigit(expectedValue);
12 }
ExpectedException的用法
ExpectedException(Type t, string expectedMessage)
在NUnitConsoler里执行一把,欣赏一下黄绿灯吧。本文相关代码可从UTDemo_Product.rar下载。
二、测试函数的组织
现在有一个性能测试的Testcase,执行一次要花上一个小时,我们并不需要(也无法忍受)每次自动化测试时都去执行这样的Testcase,使用NUnit的Explicit标记可以让这个TestCase只有在显示调用下才会执行:
2 public void OneHourTest()
3 {
4 //
5 }
不幸的是,这样耗时的TestCase在整个测试工程中可能有数十个,或许更多,我们能不能把这些TestCase都组织起来,要么一起运行,要么不运行呢?NUnit提供的Category标记可实现此功能:
2 public void OneHourTest()
3 {
4 ...
5 }
6
7 [Test, Explicit, Category( " LongTest " )]
8 public void TwoHoursTest()
9 {
10 ...
11 }
这样,只有当显示选中LongTest分类时,这些TestCase才会执行
三、NUnit的断言
NUnit提供了一个断言类NUnit.Framework.Assert,可用来进行简单的state base test(见idior的Enterprise Test Driven Develop),可别对这个断言类期望太高,在实际使用中,我们往往需要自己编写一些高级断言。
常用的NUnit断言有:
method | usage | example |
Assert.AreEqual(object expected, object actual[, string message]) | 验证两个对象是否相等 | Assert.AreEqual(2, 1+1) |
Assert.AreSame(object expected, object actual[, string message]) | 验证两个引用是否指向同意对象 | object expected = new object(); object actual = expected; Assert.AreSame(expected, actual) |
Assert.IsFalse(bool) | 验证bool值是否为false | Assert.IsFalse(false) |
Assert.IsTrue(bool) | 验证bool值是否为true | Assert.IsTrue(true) |
Assert.IsNotNull(object) | 验证对象是否不为null | Assert.IsNotNull(new object()) |
Assert.IsNull(object) | 验证对象是否为null | Assert.IsNull(null); |
这里要特殊指出的Assert.AreEqual只能处理基本数据类型和实现了Object.Equals接口的对象的比较,对于我们自定义对象的比较,通常需要自己编写高级断言,这个问题郁闷了我好一会,下面给出一个用于level=1的情况下的对象比较的高级断言的实现:
2 {
3 /// <summary>
4 /// 验证两个对象的属性值是否相等
5 /// </summary>
6 /// <remarks>
7 /// 目前只支持的属性深度为1层
8 /// </remarks>
9 public static void AreObjectsEqual( object expected, object actual)
10 {
11 // 若为相同引用,则通过验证
12 if (expected == actual)
13 {
14 return ;
15 }
16
17 // 判断类型是否相同
18 Assert.AreEqual(expected.GetType(), actual.GetType());
19
20 // 测试属性是否相等
21 Type t = expected.GetType();
22 PropertyInfo[] properties = t.GetProperties(BindingFlags.Instance | BindingFlags.Public);
23
24 foreach (PropertyInfo property in properties)
25 {
26 object obj1 = t.InvokeMember(property.Name,
27 BindingFlags.Instance | BindingFlags.Public | BindingFlags.GetProperty,
28 null , expected, null );
29 object obj2 = t.InvokeMember(property.Name,
30 BindingFlags.Instance | BindingFlags.Public | BindingFlags.GetProperty,
31 null , actual, null );
32
33 // 判断属性是否相等
34 AdvanceAssert.AreEqual(obj1, obj2, " assertion failed on " + property.Name);
35 }
36 }
37
38 /// <summary>
39 /// 验证对象是否相等
40 /// </summary>
41 private static void AreEqual( object expected, object actual, string message)
42 {
43 Type t = expected.GetType();
44
45 if (t.Equals( typeof (System.DateTime)))
46 {
47 Assert.AreEqual(expected.ToString(), actual.ToString(), message);
48 }
49 else
50 {
51 // 默认使用NUnit的断言
52 Assert.AreEqual(expected, actual, message);
53 }
54 }
55 }
56
四、常用单元测试工具介绍:
1、NUnit:目前最高版本为2.2.7(也是本文所使用的NUnit的版本)
下载地址:http://www.nunit.org
2、TestDriven.Net:一款把NUnit和VS IDE集成的插件
下载地址:http://www.testdriven.net/
3、NUnit2Report:和nant结合生成单元测试报告
下载地址:http://nunit2report.sourceforge.net
4、Rhino Mocks 2:个人认为时.net框架下最好的mocks库,而且支持.net 2.0, rocks~!
下载地址:http://www.ayende.com/projects/rhino-mocks.aspx
想不到一口气写了这么多,前段时间在公司的项目中进行了一次单元测试的尝试,感触很深,看了idior的文章后更加觉得单元测试日后会成为项目的必需部分。在后续的文章中,我将讨论mocks,自定义测试框架和自动化测试工具,希望能和园子里的uter多多讨论。