首页 > 代码库 > 单元测试2

单元测试2

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

成功进行TDD的三种核心技能:知道如何编写优秀的测试、在编码前编写测试、以及良好的测试设计。

测试命名和位置的基本规则

测试对象

测试方创建的对象

项目

创建一个名为"项目名+.UnitTests"的测试项目

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

工作单元(一个方法,或者几个方法组成的一个逻辑组,或者几个类)

对应被测试项目中的一个类,创建一个如下命名的测试方法:[UnitOfWorkName]_[ScenarioUnderTest]_[ExpectedBGheavior]。如果整个工作单元就是一个方法,工作单元名就可以很简单,就是这个方法名;如果工作单元是一个包含多个方法或类的用例,工作单元名就可能比较抽象,如:UserLogin、RemoveUser或Startup。你可以从方法名开始,之后逐渐过渡到比较抽象的工作单元名。如何使用方法名,要确保这些方法是公共的,否者它们不能真正代表一个工作单元的起点。

测试方法名称的三部分:

  1. UnitOfWorkName 被测试的方法、一组方法或者一组类
  2. Scenario 测试进行的假设条件,例如"登入失败""无效用户"或"密码正确"。你可以用测试场景描述传给公开方法的参数,或者单元测试进行时系统的初始状态,例如:"系统内存不足""无用户存在"或"用户已经存在"。
  3. ExpectedBehavior 在测试场景指定的条件下,你对被测试方法行为的预期。测试方法的行为有三种可能的结果:返回一个值(一个真实值,或者一个异常),改变系统状态(例如在系统中添加了一个用户,导致在下一次登入时系统的行为发生变化),或调用一个第三方系统(例如一个外部的Web服务)。

 

在我们对IsValidLogFileName方法进行测试中,场景是你给方法传入一个有效的文件名,预期行为是方法返回一个值true。我们可以把这个测试的方法命名为IsValidFileName_BadExtension_ReturnFalse()。

你应该把测试代码放在产品代码项目中吗?还是应该把测试代码单独放在另一个测试相关的项目里呢?我通常选择把测试和产品代码分开,这样可以使测试相关的所有其他任务更容易进行,而且,在产品代码中包含测试代码容易导致复杂的条件编译设置,还会带来其他的问题,降低代码的可读性,因此很多人都不喜欢这种做法。

 

一个单元测试通常主要包含三个行为:

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

下面是一段简单的代码,分别为被测试代码与测试单元,测试单元包含了以上全部三个行为,其中断言部分使用了NUnit框架提供的Assert类。

 

public class LogAnalyzer{    public bool IsValidLogFileName(string fileName)    {        if (string.IsNullOrEmpty(fileName))        {            throw new ArgumentException("filename has to be provided");        }        if (fileName.EndsWith(".SLF")//此中故意丢失!运算符与忽略大小写,就是为了测试其存在缺陷。        {            return false;        }        return true;    }}[Test]public void IsValidLogFileName_BadExtension_ReturnsFalse(){LogAnalyzer analyzer = new LogAnalyzer();//三部分行为“A-A-A”,都隔一行就是便于区分与阅读bool result = analyzer.IsValidLogFileName("filewithbadextension.foo");Assert.False(result);}

如上,当我们需要使用多个文件名来测试单元有效性时,难道要写多个测试方法吗。肯定不是,可以使用TestCase属性标记,该属性与更多属性的详细说明上一节已经列出。如下代码:

 

[TestCase("filewithgoodextension.SLF",true)][TestCase("filewithgoodextension.slf",true)][TestCase("filewithbadextension.foo",false)]public void IsValidLogFileName_VariousExtensions_ChecksThem(string file, bool expected){    LogAnalyzer analyzer = new LogAnalyzer();    bool result = analyzer.IsValidLogFileName(file);    Assert.AreEqual(expected, result);}

检测预期的异常,一个常见的场景是:保证当异常应该抛出时,被测试的方法能够抛出正确的异常。

假设传入一个空文件名的时候,你的方法应该抛出一个ArgumentException异常,如果代码在这种情况下没有抛出异常,你的测试就应该失败,代码上面已列出。对此有两种测试方法,让我们先来看不应该用的那种,因为这种方法很流行,而且曾经是做这种测试的唯一方法。使用ExpectedException属性标记测试异常。代码如下:

 

[Test][ExpectedException(typeof(ArgumentException),ExpectedMessage = "filename has to be provided")]public void IsValidLogFileName_EmptyFileName_ThrowsException(){    LogAnalyzer la = MakeAnalyzer();    la.IsValidLogFileName(string.Empty);}private LogAnalyzer MakeAnalyzer(){    return new LogAnalyzer();}

在这段代码中没有使用Assert调用,[ExceptedException]属性内部包含断言,为什么说不应该使用这种方法呢?因为这个属性基本上是告诉测试运行器把这整个方法包在一个大的try-catch块里,如果没有东西"捕捉"到,就认为测试失败。这种做法有一个很大的问题,就是你不知道是哪一行代码抛出的这个异常。实际上,如果构造函数有问题,抛出了一个异常,你的测试也会通过,而构造函数是绝对不应该抛出异常的,这样的话,使用这个属性,测试结果有可能是不真实。所以尽量不要用这种方法。

NUnit提供了一个更新的API:Assert。Catch<T>(delegate),以下是使用Assert.Catch编写的代码:

 

[Test]public void IsValidLogFileName_EmptyFileName_Throws(){    LogAnalyzer la = MakeAnalyzer();    var ex = Assert.Catch<ArgumentException>(() => la.IsValidLogFileName(""));        StringAssert.Contains("filename has to be provided", ex.Message);}

Assert.Catch函数返回Lambda内抛出的异常实例,你可以在之后的代码中对这个异常对象的消息进行断言。

使用StringAssert,它包含能够简化字符串测试的辅助方法,使用这个类可以提高代码可读性。

没有用Assert.AreEqual进行全字符串相等断言,而是使用StringAssert.Contains断言消息包含你寻找的字符串。随着时间的变化,当代码中加入新功能后,字符串经常会发生变化,经常会包含额外的换行符以及你不关心的多余信息,使用StringAssert.Contains可以使测试更容易维护,否则就不得不对这个测试进行修复。

使用这种方法测试结果的可能性就比较小了,因此我推荐使用Assert.Catch而不是[ExpectedException]。

测试系统状态的改变而非返回值

基于状态的测试(也称为状态验证)通过检查被测试系统极其协作方(依赖物)在被测试方法执行后行为的改变,判定被测试方法是否正确工作。

考虑对LogAnalyzer类的基于状态的简单测试,引入一个新的属性WasLastFilenameValid,这个属性记录IsValidLogFileName方法的上次调用成功与否。代码如下:

 

public class LogAnalyzer{    public bool WasLastFileNameValid { get; set; }    public bool IsValidLogFileName(string fileName)    {        WasLastFileNameValid = false;        if (string.IsNullOrEmpty(fileName))        {            throw new ArgumentException("filename has to be provided");        }        if (!fileName.EndsWith(".SLF",StringComparison.CurrentCultureIgnoreCase))        {            return false;        }        WasLastFileNameValid = true;        return true;    }}[TestCase("badfile.foo", false)][TestCase("goodfile.slf", true)]public void IsValidLogFileName_WhenCalled_ChangesWasLastFileNameValid(string file, bool expected){    LogAnalyzer la = MakeAnalyzer();    la.IsValidLogFileName(file);    Assert.AreEqual(expected, la.WasLastFileNameValid);}

如你在以上代码中所见,LogAnlyzer记住了最后一次验证的结果,因为WasLastFileNameValid的值依赖另一个方法先调用,所以无法通过编写一个获得方法返回值的测试来检测它的功能。需要单独的状态属性进行断言。

 

以上内容根据《单元测试的艺术----第二版》进行整理的(其内容主要讲解编写优秀的测试)。

单元测试2