单元测试的定义:一个单元测试是一段自动化的代码,这段代码调用被测试的工作单元,之后对这个单元的单个最终结果的某些假设进行检验。单元测试几乎都是用单元测试框架编写的。单元测试容易编写,能快速运行。单元测试可靠、可读,并且可维护。只要产品代码不发生变化,单元测试的结果是稳定的。
成功进行TDD的三种核心技能:知道如何编写优秀的测试、在编码前编写测试、以及良好的测试设计。
测试命名和位置的基本规则
测试对象 |
测试方创建的对象 |
项目 |
创建一个名为"项目名+.UnitTests"的测试项目 |
类 |
对应被测试项目中的一个类,创建一个名为[ClassName]Tests的类 |
工作单元(一个方法,或者几个方法组成的一个逻辑组,或者几个类) |
对应被测试项目中的一个类,创建一个如下命名的测试方法:[UnitOfWorkName]_[ScenarioUnderTest]_[ExpectedBGheavior]。如果整个工作单元就是一个方法,工作单元名就可以很简单,就是这个方法名;如果工作单元是一个包含多个方法或类的用例,工作单元名就可能比较抽象,如:UserLogin、RemoveUser或Startup。你可以从方法名开始,之后逐渐过渡到比较抽象的工作单元名。如何使用方法名,要确保这些方法是公共的,否者它们不能真正代表一个工作单元的起点。 |
测试方法名称的三部分:
- UnitOfWorkName 被测试的方法、一组方法或者一组类
- Scenario 测试进行的假设条件,例如"登入失败""无效用户"或"密码正确"。你可以用测试场景描述传给公开方法的参数,或者单元测试进行时系统的初始状态,例如:"系统内存不足""无用户存在"或"用户已经存在"。
- ExpectedBehavior 在测试场景指定的条件下,你对被测试方法行为的预期。测试方法的行为有三种可能的结果:返回一个值(一个真实值,或者一个异常),改变系统状态(例如在系统中添加了一个用户,导致在下一次登入时系统的行为发生变化),或调用一个第三方系统(例如一个外部的Web服务)。
在我们对IsValidLogFileName方法进行测试中,场景是你给方法传入一个有效的文件名,预期行为是方法返回一个值true。我们可以把这个测试的方法命名为IsValidFileName_BadExtension_ReturnFalse()。
你应该把测试代码放在产品代码项目中吗?还是应该把测试代码单独放在另一个测试相关的项目里呢?我通常选择把测试和产品代码分开,这样可以使测试相关的所有其他任务更容易进行,而且,在产品代码中包含测试代码容易导致复杂的条件编译设置,还会带来其他的问题,降低代码的可读性,因此很多人都不喜欢这种做法。
一个单元测试通常主要包含三个行为:
- 准备(Arrange)对象,创建对象,进行必要的设置;
- 操作(Act)对象;
- 断言(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的值依赖另一个方法先调用,所以无法通过编写一个获得方法返回值的测试来检测它的功能。需要单独的状态属性进行断言。
以上内容根据《单元测试的艺术----第二版》进行整理的(其内容主要讲解编写优秀的测试)。