单元测试
前言:目前我们团队为了对程序做质量保证,要求每位成员在每次业务中要对新的编码引入单元测试,单元测试在我以前大学中也有所耳闻,并且也是草草了解与使用,但还是不知所云,而现在既然团队要求,那么就必须做好单元测试相关工作,所以有必要学习一下单元测试。

全文是对《单元测试的艺术》摘录。

目录:

单元测试是什么?
为什么要做单元测试?
如何做单元测试?
不使用单元测试框架
使用单元测试框架
1.单元测试是什么?

定义1.0:一个单元测试是一段代码(通常是一个方法),这段代码调用另一段代码,然后检验某些假设的正确性。如果这些假设是错误的,单元测试就失败了。一个单元可以是一个方法或函数。

一个单元代码系统中的“功能单元”或者一个“用例”。

工作单元:从调用系统的一个公共方法到产生一个测试可见的最终结果,期间这个系统发生的行为总称为工作单元。

工作单元这个概念意味着一个单元即可以小到只包含一个方法,也可以大到包括实现某个功能的多个类和函数。

定义1.1:一个单元测试是一段代码,这段代码调用一个工作单元,并检验该工作单元的一个具体的最终结果。如果关于这个最终结果的假设是错误的,单元测试就失败了。一个单元测试的范围可以小到一个方法,大到多个类。

优秀的单元测试的特性:

它应该是自动化的,可重复执行;
它应该很容易实现;
它应该第二天还有意义;
任何人都应该能一键运行;
它应该运行速度很快;
它的结果应该是稳定的;
它应该能完全控制被测试的单元;
它应该是完全隔离的;
如果它失败了,我们应该很容易发现什么是期待的结果,进而定位问题所在。
区分集成测试:

要用到被测试单元的一个或多个真实依赖物,我就认为它是集成测试。如:一个测试要使用真实的系统时间,真实的文件系统,或者一个真实的数据库,那这个测试就进入了集成测试的领域。//真实时间不受控制,不稳定。

集成测试是“一个循序渐进的测试,软硬件相结合并进行测试直到整个系统集成在一起”。

集成测试定义:集成测试是对一个工作单元进行的测试,这个测试对被测试的工作单元没有完全的控制,并使用该单元的一个或多个真实依赖物,例如时间、网络、数据库、线程或随机数产生器等。

最后区别单元测试:集成测试会使用真实依赖物,而单元测试则把被测试单元和其依赖物隔离开,以保证单元测试结果高度稳定,还可以轻易控制和模拟被测试单元行为的任何方面。

优秀的单元测最终定义1.2:一个单元测试是一段自动化的代码,这段代码调用被测试的工作单元,之后对这个单元的单个最终结果的某些假设进行校验。单元测试几乎都是用单元测试框架编写的。单元测试容易编写,能快速运行。单元测试可靠、可读,并且可维护。只要产品代码不发生变化,单元测试的结果是稳定的。

3.  如何做单元测试:

先来尝试不用单元测试框架构造一个简单单元测试:

①写功能点:

这个功能点是如果输入字符串不包含数值,方法返回0.如果输入包含单个数值,方法返回这个数值的int值。

public int ParseAndSum(string numbers)
{
if (numbers.Length == 0)
{
return 0;
}
if (numbers.Contains(","))
{
return int.Parse(numbers);
}
else
{
throw new InvalidOperationException(
“I CAN ONLY HANDLE 0 OR 1 NUMBERS FOR NOW!”);
}
}
②为功能点写单元测试:

public static int TestReturnsZeroWhenEmptyString()
{
string testName = MethodBase.GetCurrentMethod().Name;
int result = 0;
try
{
SimpleParser sp = new SimpleParser();
result = sp.ParseAndSum(string.Empty);
if (result != 0)
{
SimpleParserTests.ShowProblem(testName,
"Parse and sum should have returned " +
“0 on an empty string”);
}
}
catch (Exception e)
{
Console.WriteLine(e);
}
return result;
}

public static void ShowProblem(string test, string message)
{
string msg = string.Format(@"
----{0}—
{1}
-------------
", test, message);
Console.WriteLine(msg);
}
③使用单元测试:

static void Main(string[] args)
{
try
{
int result = SimpleParserTests.TestReturnsZeroWhenEmptyString();
if (result == 0)
{
Console.WriteLine(“Assert successful”);
}
}
catch(Exception e)
{
Console.WriteLine(e);
}
Console.ReadLine();
}
结果:

使用单元测试框架(NUnit):

命名规则:

项目:[ProjectUnderTest].UnitTests

类:对应被测试项目中的一个类,创建名为[ClassName]Tests的类

工作单元(一个方法,或者几个方法组成的一个逻辑):对每个工作单元,创建一个如下命名的测试方法:[UnitOfWorkName][ScenarioUnderTest][ExpectedBehavior].

使用NUnit属性:

NUnit运行器至少需要两个属性才能知道需要运行什么:

属性[TestFixture]标识一个包含自动化NUnit测试的类 //TestFixtureAttribute is used to mark a class that represents a TestFixture.//等于没说
属性[Test]可以加在一个方法上,标识这个方法是一个需要调用的自动化测试//Adding this attribute to a method makes the method callable from the NUnit test runner.有了Test标识NUnit框架才能调用
一个完整的单元测试包含的三个行为:

准备(Arrange)对象,创建对象,进行必要的设置;
操作(Act)对象;
断言(Assert)某件事情是预期的。
例子:

[TestFixture]
class LogAnalyzerTests
{
[Test]
public void IsValidLogFileName_BadExtension_ReturnFalse()
{
LogAnalyzer analyzer = new LogAnalyzer();//Arrange
bool result = analyzer.IsValidLogFileName(“filewithbadextension.foo”);//Act
Assert.False(result);//Assert
}
}
对于Assert类,几乎每一个方法都有一个包含Message的重载形式,这个Message意思是:The message to display in case of failure。但是请千万不要使用这个参数,而是用你的测试名说明应该发生的结果。

现在来写一个场景,然后使用NUnit来做单元测试吧:

场景:

识别文件名如果是.SLF(不区分大小写)结尾返回true,否则返回false.

public bool IsValidLogFileName(string fileName)
{
if (!fileName.EndsWith(".SLF",StringComparison.CurrentCultureIgnoreCase))
{
return false;
}
return true;
}
单元测试:

①传入非SLF结尾的文件名,断言返回false。

②传入以大写SLF结尾的文件名,断言返回true。

③传入以小写slf结尾的文件名,断言返回true。

[Test]
public void IsValidLogFileName_BadExtension_ReturnFalse()
{
LogAnalyzer analyzer = new LogAnalyzer();//Arrange
bool result = analyzer.IsValidLogFileName(“filewithbadextension.foo”);//Act
Assert.False(result);//Assert
}

[Test]
public void IsValidLogFileName_GoodExtensionLowercase_ReturnTrue()
{
LogAnalyzer analyzer = new LogAnalyzer();
bool result = analyzer.IsValidLogFileName(“filewithgoodextension.slf”);
Assert.True(result);
}

[Test]
public void IsValidLogFileName_GoodExtensionUppercase_ReturnTrue()
{
LogAnalyzer analyzer = new LogAnalyzer();
bool result = analyzer.IsValidLogFileName(“filewithgoodextension.SLF”);
Assert.True(result);
}
结果:

全绿,就很舒服。

重构单元测试,使用参数化测试:

把属性[Test]替换成属性[TestCase];//TestCaseAttribute is used to mark parameterized test cases and provide them with their arguments;
把测试中用到的硬编码替换成这个测试方法的参数;
把替换掉的值放在属性的括号中[TestCase(param1,param2,…)];
用一个比较通用的名字重新命名这个测试方法;
在这个测试方法上,对每个需要合并的测试方法,用其测试值添加一个[TestCase(…)属性];
移除其他测试,只保留这个带有多个[TestCase]属性的测试方法。
对于所有返回true的同类单元测试我们使用这种参数化测试可以有效减少代码量,对于后期单元测试重构有帮助:

[TestCase(“filewithgoodextension.SLF”)]
[TestCase(“filewithgoodextension.slf”)]
public void IsValidLogFileName_ValidExtensions_ReturnsTrue(string flie)
{
LogAnalyzer analyzer = new LogAnalyzer();
bool result = analyzer.IsValidLogFileName(flie);
Assert.True(result);
}
上面是吧大小写判断通过TestCase整合到了一个单元测试中。

结果:

然而,还有一种方法,它还可以将更多的单元测试抽象到一个单元测试中:

比如将正面测试与反面测试组合到一个单元测试中:

[TestCase(“filewithgoodextension.SLF”, true)]
[TestCase(“filewithgoodextension.slf”,true)]
[TestCase(“filewithgoodextension.foo”,false)]
public void IsValidLogFileName_ValidExtensions_ChecksThem(string flie,bool expected)
{
LogAnalyzer analyzer = new LogAnalyzer();
bool result = analyzer.IsValidLogFileName(flie);
Assert.AreEqual(expected, result);
}
但是这样的单元测试不被推荐,因为可读性降低了,不知道单元测试的具体作用。

结果:

更多NUnit属性:

进行单元测试时,很重要的一点是保证之前测试的遗留数据或者实例得到销毁,新测试的状态是重建的,就好像之前没有测试运行过一样。有两个属性可以很方便地控制测试前后的设置和清理状态工作,那就是[SetUp]和[TearDown].

[SetUp]:这个属性可以像属性[Test]一样加在一个方法上,NUnit每次在运行测试类里的任何一个测试时都会先运行这个setup方法。//Attribute used to identify a method that is called immediately before each test is run.

[TearDown]:这个属性标识一个方法应该在测试类里的每个测试运行之后执行。//Attribute used to identify a method that is called immediately after each test is run. The method is guaranteed to be called, even if an exception is thrown

public LogAnalyzer m_analyzer = null;

[SetUp]
public void Setup()
{
m_analyzer = new LogAnalyzer();
}

[TearDown]
public void TearDown()
{
m_analyzer = null; //不是必须的,真实测试中不要使用
}
可以把setup和teardown方法想象成测试类中测试的构造函数和析构函数。在每个测试类中只能有一个setup和一个teardown方法,这两个方法对测试类中的每个方法只执行一次。

检验预期的异常:

使用Assert.Catch + StringAssert.Contains:

Assert.Catch//Verifies that a delegate throws an exception of a certain Type or one derived from it when called and returns it
StringAssert.Contains//Asserts that a string is found within another string.
在场景代码中增加:

public bool IsValidLogFileName(string fileName)
{
if (string.IsNullOrEmpty(fileName))
{
throw new ArgumentException(“filename has to be provided”);
}

}
增加一个测试异常的单元测试:

[Test]
public void IsValidLogFileName_EmptyFileName_ThrowsException()
{
var ex = Assert.Catch(() => m_analyzer.IsValidLogFileName(""));
StringAssert.Contains(“filename has to be provided”, ex.Message);
}
结果:

使用[Ignore]忽略某个单元测试//Constructs the attribute giving a reason for ignoring the test

[Test]
[Ignore(“there is no problem with this test”)]
public void IsValidLogFileName_BadExtension_ReturnFalse()
{
LogAnalyzer analyzer = new LogAnalyzer();//Arrange
bool result = analyzer.IsValidLogFileName(“filewithbadextension.foo”);//Act
Assert.False(result);//Assert
}
结果: