编写高质量的代码---单元测试 Nunit+NCover
进行NUnit单元测试时需要编写相应的测试代码,代码也是使用标准的C#语法,并可以使用NUnit框架提供的一系列基础的功能。在编写测试代码前请保证你正确的安装了NUnit,并在项目中引用了NUnit.Framework,并在代码文件中引用了NUnit.Framework命名空间。
下面是开始前的几条建议:
l 建议对每个重要的、复杂的、易出错的、包含重要业务逻辑的方法进行单元测试,测试代码需要有权访问到产品代码(被测代码);
l 测试代码可以和产品在同一个项目中也可以在不同的项目中,视具体情况而定;
正常的测试流程:
l 准备测试所需要的各种条件(创建所需要的对象,分配必要的资源等)
l 调用要测试的方法
l 验证被测试方法的行为和期望是否一致
l 完成后清理各种资源
先来看一个简单的产品代码和测试代码
产品代码:
using System;
namespace NUnitDemo
{
///<summary>
/// Class1 的摘要说明。
///</summary>
public class Class1
{
public Class1()
{
}
//演示处理结果的正确性和异常的处理
public int GetMaxItem(int[] ints)
{
int Max = ints[0];
foreach(int intItem in ints)
{
if(intItem > Max)
{
Max = intItem;
}
}
return Max;
}
}
}
测试代码:
using System;
using NUnit.Framework;
namespace NUnitDemo
{
class TargetClass
{}
///<summary>
/// Class1_Test 的摘要说明。
///</summary>
[TestFixture]
public class Class1_Test
{
public Class1_Test()
{
}
[Category("simple")]
[Test]
public void TestGetMax()
{
Class1 Cls1 = new TargetClass();
int[] Ints = new int[10]{1,2,3,4,5,6,7,8,9,10};
Assert.AreEqual(10,Cls1.GetMaxItem(Ints));
Ints = new int[10]{1,2,3,4,5,6,15,8,9,10};
Assert.AreEqual(15,Cls1.GetMaxItem(Ints));
}
}
}
代码中,上面是一个实现了获取整数树组中最大值的完整的产品代码程序,下面是该产品代码的测试程序,我们可以看到测试代码也是标准的C#代码,不过使用了一些NUnit 框架特有的方法和属性。
测试中断言测试的主要组成,其中大多数都是包含在Assert类中的静态方法。下面我们来一一介绍。
l AreEquals
语法:Assert.AreEqual(expected,actual[,string Message])
描述:这种断言应该是使用最多的一种,expected是期望得到的值,actual是被测代码产生的值,Message是可选的,如果提供该参数,则在错误发生时,会报告该消息。
l IsNull
语法:Assert.IsNull(object [,string message])
描述:验证一个对象是否为null,对象不是null则验证失败, message可选。
l AreSame
语法:Assert.AreSame(excepted ,actual[,string message])
描述:验证excepted和actual是否引用的是同一个,message可选。
l IsTrue
语法:Assert.IsTrue(bool condition [,string message])
描述:验证一个二元条件是否为真,message可选。
l IsFail
语法:Assert.IsFail([string message])
描述:该断言回使测试立即失败,message可选,常用于验证某个不应运行到达的地方。
l 自定义断言
虽然NUnit提供的大部分断言都已经能够满足日常测试需要,但如果遇到了特殊的测试要求就需要自己自定义测试断言。自定义断言由自己实现一个公共的测试方法的逻辑,然后在测试时调用。
测试中也常通过属性的方式来标记代码块的作用,下面介绍常用属性。
l [TestFixture]
用于标记一个测试类,该测试类可以包含多个测试方法,
l [Suit]
用于将多个TestFixture组织在一起,任何一个测试类都可以包含一个用[Suit]标记的静态方法,该方法返回一个TestSuit,该TestSuit是TestFixture的集合(注意:使用TestSuit类时请添加NUnit.Core;的引用)
l [Test]
标记一个测试方法
l [Category("name")]
l 用于把不同的测试方法进行分类管理,使用相同[Category("name")]标记的Test可以一起运行,弥补了[Suit]只能对TestFixture分类的不足
l [SetUp]
标记用于在每个测试方法开始前需要执行的方法,如申请某些资源
l [TearDown]
标记用于在每个测试方法结束后需要执行的方法,如释放某些资源
l [TestFixtureSetUp]
标记用于在每个测试类开始前需要执行的方法,如申请某些资源
l [TestFixtureTearDown]
标记用于在每个测试类开始前需要执行的方法,如申请某些资源
/c 运行需要检测的应用的命令行.
/a 需要检测的程序集的列表. 如 "MyAssembly1;MyAssembly2"
/v 允许详细的日志信息,该命令行非常适合于测试,不过会使得coverage变的非常大
NCover会在检测完代码运行后生成3个文件
- Coverage.log – 该文件主要是记录覆盖检测期间产生的事件和消息日志,还有错误日志,当然,如果使用verbose logging,则会包括更详细的中间语言相关的信息
- Coverage.xml – NCover的分析输出文件. 例子如下
- Coverage.xsl - Coverage.xml的转换文件,便于将Coverage.xml转换成友好的表 格形式显示
转换后的Coverage文件
Visit Count |
Line |
Column |
End Line |
End Column |
Document |
1 |
48 |
13 |
48 |
58 |
C:\Dev\Utilities\ncover\NCoverTest\NCoverTest.cs |
1 |
49 |
13 |
49 |
22 |
C:\Dev\Utilities\ncover\NCoverTest\NCoverTest.cs |
1 |
50 |
17 |
50 |
24 |
C:\Dev\Utilities\ncover\NCoverTest\NCoverTest.cs |
0 |
51 |
13 |
51 |
48 |
C:\Dev\Utilities\ncover\NCoverTest\NCoverTest.cs |
0 |
52 |
9 |
52 |
10 |
C:\Dev\Utilities\ncover\NCoverTest\NCoverTest.cs |
表示成功,并讲结果存在了Coverage.xml中,可以通过IE等工具进行查看,在Coverage.txt中保存了更详细的日志信息。可以将这些信息作为编写测试方法的依据。
由于NUnit是通过反射的方式对测试方法进行调用,所以说对于测试方法是由NUnit框架驱动运行的,所以在使用NCover中进行代码覆盖测试的时候应该输入NUnit命令行,但是NCover的/c中不支持该命令行,如:NUnit-console testprj.exe,所以替代的解决方案是把复杂的命令行写在批处理文件中,这样将该.bat文件做为NCover的/c中的参数就OK。
//日志接口 using System;
public interface ILogger {
void SetName(String name);
void Log(String msg);
//实现了日志接口的MOCK using System;
using DotNetMock;
public class MockLogger2 : MockObject, ILogger { private ExpectationValue _name =
new ExpectationValue("name");
// Mock 对象的接口
public String ExpectedName {
set { _name.Expected = value; }
}
// 实现ILog对象的接口 public void SetName(String name) {
_name.Actual = name;
}
public void Log(String msg) {
}
//使用日志接口的产品代码 using System;
using System.Data;
using System.Data.SqlClient;
public class AccessController1 {
private ILogger logger;
private String resource;
private IDbConnection conn;
public static readonly String CHECK_SQL =
"select count(*) from access where " +
"user=@user and password=@password " +
"and resource=@resource";
public AccessController1(String resource,
ILogger logger,
IDbConnection conn) {
this.logger = logger;
this.resource = resource;
this.conn = conn;
logger.SetName("AccessControl");
}
public bool CanAccess(String user, String password) {
logger.Log("Checking access for " + user +
" to " + resource);
if (password == null || password.Length == 0) {
logger.Log("Missing password. Access denied");
return false;
}
IDbCommand cmd = conn.CreateCommand();
cmd.CommandText = CHECK_SQL;
cmd.Parameters.Add(
new SqlParameter("@user", user));
cmd.Parameters.Add(
new SqlParameter("@password", password));
cmd.Parameters.Add(
new SqlParameter("@resource", resource));
IDataReader rdr = cmd.ExecuteReader();
int rows = 0;
if (rdr.Read())
rows = rdr.GetInt32(0);
cmd.Dispose();
if (rows == 1) {
logger.Log("Access granted");
return true;
}
else {
logger.Log("Access denied");
return false;
}
}
}
//测试代码 using System;
using NUnit.Framework;
[TestFixture]
public class TestAccessController {
[Test]
public void MissingPassword1() {
MockLogger1 logger = new MockLogger1();
AccessController access =
new AccessController("secrets", logger);
Assert.IsFalse(access.CanAccess("dave", null));
Assert.IsFalse(access.CanAccess("dave", ""));
}
[Test]
public void MissingPassword2() {
MockLogger2 logger = new MockLogger2();
logger.ExpectedName = "AccessControl";
AccessController access =
new AccessController("secrets", logger);
Assert.IsFalse(access.CanAccess("dave", null));
logger.Verify();
}
[Test]
public void MissingPassword3() {
MockLogger3 logger = new MockLogger3();
logger.ExpectedName = "AccessControl";
logger.AddExpectedMsg(
"Checking access for dave to secrets");
logger.AddExpectedMsg(
"Missing password. Access denied");
AccessController access =
new AccessController("secrets", logger);
Assert.IsFalse(access.CanAccess("dave", null));
logger.Verify();
}
}
|
要使用NAnt,第一步当然是从NAnt网站下载这个工具。正在用NAnt作为整合和构造工具的开发者注意一下,最新的NAnt集成了优秀的单元测试工具NUnit 2.0。NUnit 2.0在1.0的基础上作了重大的改进,如果你使用NUnit 2,应该使用最新的NAnt以充分发挥它的优势。
下面我们通过一个最简单的例子看看NAnt的使用过程——构造一个由单个执行文件组成的C#控制台程序。应用程序的代码如下:
public class HelloWorld { static void Main() { System.Console.WriteLine("Hello world."); } } |
当然,对于这样一个简单的项目,用C#命令行编译器也可以很方便地编译,只要执行一个“csc *.cs”命令就可以了。编译得到的结果是一个二进制可执行文件HelloWorld.exe。要用NAnt完成同样的任务,首先要创建一个扩展名为.build的XML构造文件,下面是一个NAnt构造文件的例子default.build,它完成的任务与执行一个简单的csc编译命令一样:
【Listing 1:创建单个执行文件的简单NAnt构造脚本】
<?xml version="1.0"?> <project name="Hello World" default="build" basedir="."> <target name="build"> <csc target="exe" output="HelloWorld.exe"> <sources> <includes name="HelloWorld.cs"/> </sources> </csc> </target> </project> |
目标描述了一组要求NAnt执行的任务,它是一种将任务分成逻辑组的手段。例如,假设我们要求NAnt删除bin目录的内容、编译5个执行文件、把编译得到的二进制文件复制到某个位置,可以把这些动作组织成一个target。
相关性可以看作是两个target之间的关系。不过Listing 1只有一个target,它的名称是build,它的任务是运行编译器编译指定的源文件。把标记的default属性设置为build,NAnt就会处理名称为build的target。
在csc任务内有一个子节点,它指定了要编译的源文件。
现在我们加入第二个target——编译好HelloWorld.exe文件后立即执行。修改后的构造文件如Listing 2所示。
【Listing 2:包含两个相关target的构造脚本】
<?xml version="1.0"?> <project name="Hello World" default="run" basedir="."> <target name="build"> <csc target="exe" output="HelloWorld.exe"> <sources> <includes name="HelloWorld.cs"/> </sources> </csc> </target>
<target name="run" depends="build"> <exec program="HelloWorld.exe"/> </target> </project>
|
新添加的target名叫run,只包含一个用来执行程序的动作exec,此外它还有一个对build目标的相关性。这个相关性表示,在执行run这个target之前,必须先实现build这个target且必须执行成功。注意在节点中,我们把default属性由原来的build改成了run。由于run依赖于build,因此确保了在运行应用之前先编译好应用。
如果由于某种原因build目标没有达到(通常是由于编译器发现了代码存在的错误),run目标也不会执行。你可以试验一下:先在HelloWorld的代码中故意加入一个语法错误,然后再次运行NAnt,NAnt将把编译器的错误信息显示到控制台,可以方便地看出哪里出现了错误。
如果有一个编译好的二进制文件比源文件还新,NAnt不会再执行编译操作——换句话说,NAnt不会编译任何无需编译的文件。此外,如果构造文件定义了多重相关性(即,二个或二个以上的组件依赖于另一个组件),NAnt很“聪明”,它只构造被依赖的组件一次,不会重复构造同一个组件。这种处理方式大大提高了构造大型项目所需的时间,但有的时候,人们需要能够说“不管我有什么,你都编译不误”的权利,也就是说,要能够清除所有已经编译好的二进制文件,从头开始构造。
为此,许多构造文件会包含一个clean目标,开发者可以利用它来清除上一次编译留下的所有文件。下面是一个包含clean目标的构造文件例子:
【Listing 3:包含clean目标的构造脚本】
<?xml version="1.0"?> <project name="Hello World" default="run" basedir="."> <target name="build"> <mkdir dir="bin" />
<csc target="exe" output="bin\HelloWorld.exe"> <sources> <includes name="HelloWorld.cs"/> </sources> </csc> </target>
<target name="clean"> <delete dir="bin" failonerror="false"/> </target>
<target name="run" depends="build"> <exec program="bin\HelloWorld.exe"/> </target> </project>
|
clean目标并不是每次构造时都要运行,只是偶尔需要运行一下。要运行clean目标,只需执行nant clean命令即可。nant clean命令要求NAnt只执行clean目标(也就是说,不会执行构造项目的操作,只是清除一下bin目录的内容)。另外还可以看到,这个修改之后的构造脚本包含了一个mkdir动作,用来创建bin子目录以存放编译好的二进制文件。如果既要清除bin目录,又要构造项目,执行命令:nant clean build。
如果要将构造过程和其他操作结合,例如email提醒和自动化的单元测试,NAnt也能够很好地完成。Listing 4就是这样一个构造文件的例子,它构造一个应用,并把执行NUnit也作为构造过程中很自然的一部分。
【Listing 4:集成了单元测试的构造文件】
<?xml version="1.0"?> <project name="NUnit Integration" default="test"> <property name="build.dir" value="\dev\src\myproject\" />
<target name="build"> <csc target="library" output="account.dll"> <sources> <includes name="account.cs" /> </sources> </csc> </target>
<target name="test" depends="build"> <csc target="library" output="account-test.dll"> <sources> <includes name="account-test.cs" /> </sources> <references> <includes name="nunit.framework.dll" /> <includes name="account.dll" /> </references> </csc>
<nunit2> <formatter type="Plain" usefile="true" extension=".txt" outputdir="${build.dir}/results"/> <test assemblyname="${build.dir}account-test.dll" /> </nunit2> </target> </project>
|
造文件的开头声明,但如有必要,也可以改为通过命令行参数提供。在这个例子中,以属性的形式指定项目文件带来不少方便,因为在后面的构造过程中我们要把这些信息传递给NUnit。
接下来,构造文件依次构造出account.dll组件和测试工具account-test.dll。这两个构造过程都包含target="library"选项,这是告诉编译器我们要构造的是一个组件程序集,而不是一个.exe文件。另外,从Listing 4还可以看出,测试工具还通过references节点引用了两个它依赖的程序集——被测试的业务逻辑组件account.dll和NUnit框架。当我们构造的项目依赖于外部库时,就要用到这个节点。
构造好测试工具和业务逻辑组件后,构造脚本调用NUnit,并指定了包含测试组件的程序集的名称,要求生成一个XML格式的文件记录测试结果。
关于NUnit集成,有一点必须注意:如果你正在用NUnit 2.0,必须使用最新的NAnt版本,这是因为NUnit最近作了重大的修改,某些“稳定”的NAnt根本不能与NUnit 2.0一起运行,但最新的NAnt对NUnit 2.0的支持相当稳定。