C# 单元测试 测试代码-测试层次和组织
一、测试层次和组织
1.1 测试项目的两种目录结构
(1)集成测试和单元测试在同一个项目里,但放在不同的目录和命名空间里。基础类放在单独的文件夹里。
(2)集成测试和单元测试位于不同的项目中,有不同的命名空间。
实践中推荐使用第二种目录结构,因为如果我们不把这两种测试分开,人们可能就不会经常地运行这些测试。既然测试都写好了,为什么人们不愿意按照需要运行它们呢?一个原因是:开发人员有可能懒得运行测试,或者没有实践运行测试。
1.2 构建绿色安全区
将集成测试和单元测试分开放置,其实就给团队的开发人员构建了绿色安全区,这个区只包含单元测试。
因为集成测试的本质决定了它运行时间较长,开发人员很有可能每天运行多次单元测试,较少运行集成测试。
单元测试全部通过至少可以使开发人员对代码质量比较有信心,专注于提高编码效率。而且我们应该将测试自动化,编写每日构建脚本,并借助持续集成工具帮助我们自动执行这些脚本。
1.3 将测试类映射到被测试代码
(1)将测试映射到项目
创建一个测试项目,用被测试项目的名字加上后缀.UnitTests来命名。
例如:Manulife.MyLibrary → Manulife.MyLibrary.UnitTests 和 Manulife.MyLibrary.IntegrationTests,这种方法看起来简单直观,开发人员能够从项目名称找到对应的所有测试。
(2)将测试映射到类
① 每个被测试类或者被测试工作单元对应一个测试类:LogAnalyzer → LogAnalyzer.UnitTests
② 每个功能对应一个测试类:有一个LoginManager类,测试方法为ChangePassword(这个方法测试用例特别多,需要单独放在一个测试类里边) → 创建两个类 LoginManagerTests 和 LoginManagerTests-ChangePassword,前者只包含对ChangePassword方法的测试,后者包含该类其他所有测试。
(3)将测试映射到具体的工作单元入口
测试方法的命名应该有意义,这样人们可以很容易地找到所有相关的测试方法。
这里,回归一下第一篇中提到的测试方法名称的规范,一般包含三个部分:[UnitOfWorkName]_[ScenarioUnderTest]_[ExpectedBehavior]
-
- UnitOfWorkName 被测试的方法、一组方法或者一组类
- Scenario 测试进行的假设条件,例如“登入失败”,“无效用户”或“密码正确”等
- ExpectedBehavior 在测试场景指定的条件下,你对被测试方法行为的预期
示例:IsValidFileName_BadExtension_ReturnsFalse,IsValidFileName_EmptyName_Throws 等
1.4 注入横切关注点
当需要处理类似时间管理、异常或日志的横切关注点时,使用它们的地方会非常多,如果把它们实现成可注入的,产生的代码会很容易测试,但却很难阅读和理解。这里我们来看一个例子,假设应用程序使用当前时间进行写日志,相关代码如下:
public static class TimeLogger { public static string CreateMessage(string info) { return DateTime.Now.ToShortDateString() + " " + info; } }
为了使这段代码容易测试,如果使用之前的依赖注入技术,那么我们需要创建一个ITimeProvider接口,还必须在每个用到DateTime的地方使用到这个接口。这样做非常耗时,实际上,还有更直接的方法解决这个问题。
Step1.创建一个名为SystemTime的定制类,在所有的产品代码里边使用这个定制类,而非标准的内建类DateTime。
public class SystemTime { private static DateTime _date; public static void Set(DateTime custom) { _date = custom; } public static void Reset() { _date = DateTime.MinValue; } public static DateTime Now { get { // 如果设置了时间,SystemTime就返回假时间,否则返回真时间 if (_date != DateTime.MinValue) { return _date; } return DateTime.Now; } } }
阅读这段代码,其中有一个小技巧:SystemTime类提供一个特殊方法Set,它会修改系统中的当前时间,也就是说,每个使用这个SystemTime类的人看到的都是你指定的日期和时间。有了这样的代码,每个使用这个SystemTime类的人看到的都会是你指定的日期和时间。
Step2.在测试项目中使用SystemTime进行测试。
[TestFixture] public class TimeLoggerTests { [Test] public void SettingSystemTime_Always_ChangesTime() { SystemTime.Set(new DateTime(2000, 1, 1)); string output = TimeLogger.CreateMessage("a"); StringAssert.Contains("2000/1/1", output); } /// <summary> /// 在每个测试结束时重置日期 /// </summary> [TearDown] public void AfterEachTest() { SystemTime.Reset(); } }
在测试中,我们首先假定设置一个日期,然后进行断言。并且借助TearDown方法,确保当前测试不会改变其他测试的值。
Note : 这样做的好处就在于不用注入一大堆接口,我们所付出的代价仅仅在于在测试类中加入一个简单的[TearDown]方法,确保当前测试不会改变其他测试的值。
1.5 使用继承使测试代码可重用
推荐大家在测试代码中使用继承机制,通过实现基类,可以较好地展现面向对象的魔力。在实践中,一般有三种模式会被使用到:
(1)抽象测试基础结构类模式
/// <summary> /// 测试类集成模式 /// </summary> [TestFixture] public class BaseTestsClass { /// <summary> /// 重构为通用可读的工具方法,由派生类使用 /// </summary> /// <returns>FakeLogger</returns> public ILogger FakeTheLogger() { LoggingFacility.Logger = Substitute.For<ILogger>(); return LoggingFacility.Logger; } [TearDown] public void ClearLogger() { // 测试之间要重置静态资源 LoggingFacility.Logger = null; } } [TestFixture] public class LogAnalyzerTests : BaseTestsClass { [Test] public void Analyze_EmptyFile_ThrowsException() { // 调用基类的辅助方法 FakeTheLogger(); LogAnalyzer analyzer = new LogAnalyzer(); analyzer.Analyze("myemptyfile.txt"); // 测试方法的其余部分 } }
使用此模式要注意继承最好不要超过一层,如果继承层数过多,不仅可读性急剧下降,编译也很容易出错。
(2)测试类类模板模式
/// <summary> /// 测试模板类模式 /// </summary> [TestFixture] public abstract class TemplateStringParserTests { [Test] public abstract void TestGetStringVersionFromHeader_SingleDigit_Found(); [Test] public abstract void TestGetStringVersionFromHeader_WithMinorVersion_Found(); [Test] public abstract void TestGetStringVersionFromHeader_WithRevision_Found(); } [TestFixture] public class XMLStrignParserTests : TemplateStringParserTests { protected IStringParser GetParser(string input) { return new XMLStringParser(input); } [Test] public override void TestGetStringVersionFromHeader_SingleDigit_Found() { IStringParser parser = GetParser("<Header>1</Header>"); string versionFromHeader = parser.GetTextVersionFromHeader(); Assert.AreEqual("1", versionFromHeader); } [Test] public override void TestGetStringVersionFromHeader_WithMinorVersion_Found() { IStringParser parser = GetParser("<Header>1.1</Header>"); string versionFromHeader = parser.GetTextVersionFromHeader(); Assert.AreEqual("1.1", versionFromHeader); } [Test] public override void TestGetStringVersionFromHeader_WithRevision_Found() { IStringParser parser = GetParser("<Header>1.1.1</Header>"); string versionFromHeader = parser.GetTextVersionFromHeader(); Assert.AreEqual("1.1", versionFromHeader); } }
使用此模式可以确保开发者不会遗忘重要的测试,基类包含了抽象的测试方法,派生类必须实现这些抽象方法。
(3)抽象测试驱动类模式
/// <summary> /// 抽象“填空”测试驱动类模式 /// </summary> public abstract class FillInTheBlankStringParserTests { // 返回接口的抽象方法 protected abstract IStringParser GetParser(string input); // 抽象输入方法(属性),为派生类提供特定格式的数据 protected abstract string HeaderVersion_SingleDigit { get; } protected abstract string HeaderVersion_WithMinorVersion { get; } protected abstract string HeaderVersion_WithRevision { get; } // 如果需要,预先为派生类定义预期的输出 public const string EXPECTED_SINGLE_DIGIT = "1"; public const string EXPECTED_WITH_MINORVERSION = "1.1"; public const string EXPECTED_WITH_REVISION = "1.1.1"; [Test] public void TestGetStringVersionFromHeader_SingleDigit_Found() { string input = HeaderVersion_SingleDigit; IStringParser parser = GetParser(input); string versionFromHeader = parser.GetTextVersionFromHeader(); Assert.AreEqual(EXPECTED_SINGLE_DIGIT, versionFromHeader); } [Test] public void TestGetStringVersionFromHeader_WithMinorVersion_Found() { string input = HeaderVersion_WithMinorVersion; IStringParser parser = GetParser(input); string versionFromHeader = parser.GetTextVersionFromHeader(); Assert.AreEqual(EXPECTED_WITH_MINORVERSION, versionFromHeader); } [Test] public void TestGetStringVersionFromHeader_WithRevision_Found() { string input = HeaderVersion_WithRevision; IStringParser parser = GetParser(input); string versionFromHeader = parser.GetTextVersionFromHeader(); Assert.AreEqual(EXPECTED_WITH_REVISION, versionFromHeader); } } public class DBLogStringParserTests : GenericParserTests<DBLogStringParser> { protected override string GetInputHeaderSingleDigit() { return "Header;1"; } protected override string GetInputHeaderWithMinorVersion() { return "Header;1.1"; } protected override string GetInputHeaderWithRevision() { return "Header;1.1.1"; } }
此模式在基类中实现测试方法,并提供派生类可以实现的抽象方法钩子。当然,只是大部分的测试代码在基类中,派生类也可以加入自己的特殊测试。
此模式的要点在于:你不是具体地测试一个类,而是测试产品代码中的一个接口或者基类。
当然,在.NET中我们也可以通过泛型来实现此模式,例如下面的代码:
public abstract class GenericParserTests<T> where T : IStringParser // 01.定义参数的泛型约束 { protected abstract string GetInputHeaderSingleDigit(); protected abstract string GetInputHeaderWithMinorVersion(); protected abstract string GetInputHeaderWithRevision(); // 02.返回泛型变量而非接口 protected T GetParser(string input) { // 03.返回泛型 return (T)Activator.CreateInstance(typeof(T), input); } [Test] public void TestGetStringVersionFromHeader_SingleDigit_Found() { string input = GetInputHeaderSingleDigit(); T parser = GetParser(input); bool result = parser.HasCorrectHeader(); Assert.AreEqual(false, result); } [Test] public void TestGetStringVersionFromHeader_WithMinorVersion_Found() { string input = GetInputHeaderWithMinorVersion(); T parser = GetParser(input); bool result = parser.HasCorrectHeader(); Assert.AreEqual(false, result); } [Test] public void TestGetStringVersionFromHeader_WithRevision_Found() { string input = GetInputHeaderWithRevision(); T parser = GetParser(input); bool result = parser.HasCorrectHeader(); Assert.AreEqual(false, result); } } public class DBLogStringParserTests : GenericParserTests<DBLogStringParser> { protected override string GetInputHeaderSingleDigit() { return "Header;1"; } protected override string GetInputHeaderWithMinorVersion() { return "Header;1.1"; } protected override string GetInputHeaderWithRevision() { return "Header;1.1.1"; } }
出处:http://edisonchou.cnblogs.com