开篇:上一篇我们学习单元测试和核心技术:存根、模拟对象和隔离框架,它们是我们进行高质量单元测试的技术基础。本篇会集中在管理和组织单元测试的技术,以及如何确保在真实项目中进行高质量的单元测试。

系列目录:

1.入门

2.核心技术

3.测试代码

一、测试层次和组织

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 注入横切关注点

  当需要处理类似时间管理、异常或日志的横切关注点时,使用它们的地方会非常多,如果把它们实现成可注入的,产生的代码会很容易测试,但却很难阅读和理解。这里我们来看一个例子,假设应用程序使用当前时间进行写日志,相关代码如下:

  1. public static class TimeLogger
  2. {
  3. public static string CreateMessage(string info)
  4. {
  5. return DateTime.Now.ToShortDateString() + " " + info;
  6. }
  7. }

  为了使这段代码容易测试,如果使用之前的依赖注入技术,那么我们需要创建一个ITimeProvider接口,还必须在每个用到DateTime的地方使用到这个接口。这样做非常耗时,实际上,还有更直接的方法解决这个问题。

  Step1.创建一个名为SystemTime的定制类,在所有的产品代码里边使用这个定制类,而非标准的内建类DateTime。

  1. public class SystemTime
  2. {
  3. private static DateTime _date;
  4.  
  5. public static void Set(DateTime custom)
  6. {
  7. _date = custom;
  8. }
  9.  
  10. public static void Reset()
  11. {
  12. _date = DateTime.MinValue;
  13. }
  14.  
  15. public static DateTime Now
  16. {
  17. get
  18. {
  19. // 如果设置了时间,SystemTime就返回假时间,否则返回真时间
  20. if (_date != DateTime.MinValue)
  21. {
  22. return _date;
  23. }
  24. return DateTime.Now;
  25. }
  26. }
  27. }

  阅读这段代码,其中有一个小技巧:SystemTime类提供一个特殊方法Set,它会修改系统中的当前时间,也就是说,每个使用这个SystemTime类的人看到的都是你指定的日期和时间。有了这样的代码,每个使用这个SystemTime类的人看到的都会是你指定的日期和时间。

  Step2.在测试项目中使用SystemTime进行测试。

  1. [TestFixture]
  2. public class TimeLoggerTests
  3. {
  4. [Test]
  5. public void SettingSystemTime_Always_ChangesTime()
  6. {
  7. SystemTime.Set(new DateTime(, , ));
  8. string output = TimeLogger.CreateMessage("a");
  9.  
  10. StringAssert.Contains("2000/1/1", output);
  11. }
  12.  
  13. /// <summary>
  14. /// 在每个测试结束时重置日期
  15. /// </summary>
  16. [TearDown]
  17. public void AfterEachTest()
  18. {
  19. SystemTime.Reset();
  20. }
  21. }

  在测试中,我们首先假定设置一个日期,然后进行断言。并且借助TearDown方法,确保当前测试不会改变其他测试的值

Note : 这样做的好处就在于不用注入一大堆接口,我们所付出的代价仅仅在于在测试类中加入一个简单的[TearDown]方法,确保当前测试不会改变其他测试的值。

1.5 使用继承使测试代码可重用

  推荐大家在测试代码中使用继承机制,通过实现基类,可以较好地展现面向对象的魔力。在实践中,一般有三种模式会被使用到:

  (1)抽象测试基础结构类模式

  1. /// <summary>
  2. /// 测试类集成模式
  3. /// </summary>
  4. [TestFixture]
  5. public class BaseTestsClass
  6. {
  7. /// <summary>
  8. /// 重构为通用可读的工具方法,由派生类使用
  9. /// </summary>
  10. /// <returns>FakeLogger</returns>
  11. public ILogger FakeTheLogger()
  12. {
  13. LoggingFacility.Logger = Substitute.For<ILogger>();
  14. return LoggingFacility.Logger;
  15. }
  16.  
  17. [TearDown]
  18. public void ClearLogger()
  19. {
  20. // 测试之间要重置静态资源
  21. LoggingFacility.Logger = null;
  22. }
  23. }
  24.  
  25. [TestFixture]
  26. public class LogAnalyzerTests : BaseTestsClass
  27. {
  28. [Test]
  29. public void Analyze_EmptyFile_ThrowsException()
  30. {
  31. // 调用基类的辅助方法
  32. FakeTheLogger();
  33.  
  34. LogAnalyzer analyzer = new LogAnalyzer();
  35. analyzer.Analyze("myemptyfile.txt");
  36.  
  37. // 测试方法的其余部分
  38. }
  39. }

  使用此模式要注意继承最好不要超过一层,如果继承层数过多,不仅可读性急剧下降,编译也很容易出错。

  (2)测试类类模板模式

  1. /// <summary>
  2. /// 测试模板类模式
  3. /// </summary>
  4. [TestFixture]
  5. public abstract class TemplateStringParserTests
  6. {
  7. [Test]
  8. public abstract void TestGetStringVersionFromHeader_SingleDigit_Found();
  9. [Test]
  10. public abstract void TestGetStringVersionFromHeader_WithMinorVersion_Found();
  11. [Test]
  12. public abstract void TestGetStringVersionFromHeader_WithRevision_Found();
  13. }
  14.  
  15. [TestFixture]
  16. public class XMLStrignParserTests : TemplateStringParserTests
  17. {
  18. protected IStringParser GetParser(string input)
  19. {
  20. return new XMLStringParser(input);
  21. }
  22.  
  23. [Test]
  24. public override void TestGetStringVersionFromHeader_SingleDigit_Found()
  25. {
  26. IStringParser parser = GetParser("<Header>1</Header>");
  27.  
  28. string versionFromHeader = parser.GetTextVersionFromHeader();
  29. Assert.AreEqual("", versionFromHeader);
  30. }
  31.  
  32. [Test]
  33. public override void TestGetStringVersionFromHeader_WithMinorVersion_Found()
  34. {
  35. IStringParser parser = GetParser("<Header>1.1</Header>");
  36.  
  37. string versionFromHeader = parser.GetTextVersionFromHeader();
  38. Assert.AreEqual("1.1", versionFromHeader);
  39. }
  40.  
  41. [Test]
  42. public override void TestGetStringVersionFromHeader_WithRevision_Found()
  43. {
  44. IStringParser parser = GetParser("<Header>1.1.1</Header>");
  45.  
  46. string versionFromHeader = parser.GetTextVersionFromHeader();
  47. Assert.AreEqual("1.1", versionFromHeader);
  48. }
  49. }

  使用此模式可以确保开发者不会遗忘重要的测试,基类包含了抽象的测试方法,派生类必须实现这些抽象方法。

  (3)抽象测试驱动类模式

  1. /// <summary>
  2. /// 抽象“填空”测试驱动类模式
  3. /// </summary>
  4. public abstract class FillInTheBlankStringParserTests
  5. {
  6. // 返回接口的抽象方法
  7. protected abstract IStringParser GetParser(string input);
  8. // 抽象输入方法(属性),为派生类提供特定格式的数据
  9. protected abstract string HeaderVersion_SingleDigit { get; }
  10. protected abstract string HeaderVersion_WithMinorVersion { get; }
  11. protected abstract string HeaderVersion_WithRevision { get; }
  12. // 如果需要,预先为派生类定义预期的输出
  13. public const string EXPECTED_SINGLE_DIGIT = "";
  14. public const string EXPECTED_WITH_MINORVERSION = "1.1";
  15. public const string EXPECTED_WITH_REVISION = "1.1.1";
  16.  
  17. [Test]
  18. public void TestGetStringVersionFromHeader_SingleDigit_Found()
  19. {
  20. string input = HeaderVersion_SingleDigit;
  21. IStringParser parser = GetParser(input);
  22.  
  23. string versionFromHeader = parser.GetTextVersionFromHeader();
  24. Assert.AreEqual(EXPECTED_SINGLE_DIGIT, versionFromHeader);
  25. }
  26.  
  27. [Test]
  28. public void TestGetStringVersionFromHeader_WithMinorVersion_Found()
  29. {
  30. string input = HeaderVersion_WithMinorVersion;
  31. IStringParser parser = GetParser(input);
  32.  
  33. string versionFromHeader = parser.GetTextVersionFromHeader();
  34. Assert.AreEqual(EXPECTED_WITH_MINORVERSION, versionFromHeader);
  35. }
  36.  
  37. [Test]
  38. public void TestGetStringVersionFromHeader_WithRevision_Found()
  39. {
  40. string input = HeaderVersion_WithRevision;
  41. IStringParser parser = GetParser(input);
  42.  
  43. string versionFromHeader = parser.GetTextVersionFromHeader();
  44. Assert.AreEqual(EXPECTED_WITH_REVISION, versionFromHeader);
  45. }
  46. }
  47.  
  48. public class DBLogStringParserTests : GenericParserTests<DBLogStringParser>
  49. {
  50. protected override string GetInputHeaderSingleDigit()
  51. {
  52. return "Header;1";
  53. }
  54.  
  55. protected override string GetInputHeaderWithMinorVersion()
  56. {
  57. return "Header;1.1";
  58. }
  59.  
  60. protected override string GetInputHeaderWithRevision()
  61. {
  62. return "Header;1.1.1";
  63. }
  64. }

  此模式在基类中实现测试方法,并提供派生类可以实现的抽象方法钩子。当然,只是大部分的测试代码在基类中,派生类也可以加入自己的特殊测试。

  此模式的要点在于:你不是具体地测试一个类,而是测试产品代码中的一个接口或者基类。

  当然,在.NET中我们也可以通过泛型来实现此模式,例如下面的代码:

  1. public abstract class GenericParserTests<T> where T : IStringParser // 01.定义参数的泛型约束
  2. {
  3. protected abstract string GetInputHeaderSingleDigit();
  4. protected abstract string GetInputHeaderWithMinorVersion();
  5. protected abstract string GetInputHeaderWithRevision();
  6.  
  7. // 02.返回泛型变量而非接口
  8. protected T GetParser(string input)
  9. {
  10. // 03.返回泛型
  11. return (T)Activator.CreateInstance(typeof(T), input);
  12. }
  13.  
  14. [Test]
  15. public void TestGetStringVersionFromHeader_SingleDigit_Found()
  16. {
  17. string input = GetInputHeaderSingleDigit();
  18. T parser = GetParser(input);
  19.  
  20. bool result = parser.HasCorrectHeader();
  21. Assert.AreEqual(false, result);
  22. }
  23.  
  24. [Test]
  25. public void TestGetStringVersionFromHeader_WithMinorVersion_Found()
  26. {
  27. string input = GetInputHeaderWithMinorVersion();
  28. T parser = GetParser(input);
  29.  
  30. bool result = parser.HasCorrectHeader();
  31. Assert.AreEqual(false, result);
  32. }
  33.  
  34. [Test]
  35. public void TestGetStringVersionFromHeader_WithRevision_Found()
  36. {
  37. string input = GetInputHeaderWithRevision();
  38. T parser = GetParser(input);
  39.  
  40. bool result = parser.HasCorrectHeader();
  41. Assert.AreEqual(false, result);
  42. }
  43. }
  44.  
  45. public class DBLogStringParserTests : GenericParserTests<DBLogStringParser>
  46. {
  47. protected override string GetInputHeaderSingleDigit()
  48. {
  49. return "Header;1";
  50. }
  51.  
  52. protected override string GetInputHeaderWithMinorVersion()
  53. {
  54. return "Header;1.1";
  55. }
  56.  
  57. protected override string GetInputHeaderWithRevision()
  58. {
  59. return "Header;1.1.1";
  60. }
  61. }

二、优秀单元测试的支柱

  要编写优秀的单元测试,它们应该同时具有 可靠性可维护性可读性

2.1 编写可靠的测试

  一个可靠的测试能让你觉得自己对事态了如指掌,能够从容应对。以下是一些指导原则和技术:

  (1)决定何时删除或修改测试

  一旦测试写好并通过,通常我们不应该修改或删除这些测试,因为它们是我们得绿色保护网。但是,有时候我们还是需要修改或者删除测试,所以需要理解什么情况下修改或删除测试会带来问题,什么情况下又是合理的。一般来说,如果有产品缺陷、测试缺陷、语义或者API更改或者是由于冲突或无效测试,我们需要修改和删除测试代码。

  (2)避免测试中的逻辑

  随着测试中逻辑的增多,出现测试缺陷的几率就会呈现指数倍的增长。如果单元测试中包含了下列语句就是包含了不应该有的逻辑:

  • switch、if或else语句;
  • foreach、for或while循环;

  这种做法不值得推荐,因为这样的测试可读性较差,也比较脆弱。通常来说,一个单元测试应该是一系列方法的调用和断言,但是不包含控制流程语句,甚至不应该将断言语句放在try-catch中

  (3)只测试一个关注点

  如果我们的单元测试对多个对象进行了断言,那么这个测试有可能测试了多个关注点。在一个单元测试中验证多个关注点会使得事情变得复杂,却没有什么价值。你应该在分开的、独立的单元测试中验证多余的关注点,这样才能发现真正失败的地方。

  (4)把单元测试和集成测试分开

  掐面讨论了测试的绿色安全区,我们需要的就是准备一个单独的单元测试项目,项目中仅包含那些在内存中运行,结果稳定,可重复执行的测试。

  (5)用代码审查确保代码覆盖率

  如果覆盖率低于20%,说明我们缺少很多测试,我们不会知道下一个开发人员将怎么修改我们得代码。如果没有回失败的测试,可能就不会发现这些错误。

2.2 编写可维护性的测试

  可维护性是大多数开发者在编写单元测试时面对的核心问题之一。为此我们需要:

  (1)只测试公共契约

  (2)删除重复测试(去除重复代码)

  (3)实施测试隔离

  测试隔离的基本概念是:一个测试应该总是在它自己的小世界中运行,与其他类似或不同的工作的测试隔离,甚至不知道其他测试的存在

2.3 编写可读性的测试

  不可读的测试几乎没有任何意义,它是我们向项目的下一代开发者讲述的故事,帮助开发者理解一个应用程序的组成及其开端。

  (1)单元测试命名

  这个前面我们讨论过,应该包括三部分:被测试方法名_测试场景_预期行为,如果开发人员都是用这种规范,其他的开发人员就能很容易进入项目,理解测试。

  (2)变量命名

  通过合理命名变量,你可以确保阅读测试的人可以尽快地理解你要验证什么(相对于理解产品代码中你想要实现什么)。请看下面的一个例子:

  1. [Test]
  2. public void BadlyNameTest()
  3. {
  4. LogAnalyzer log = new LogAnalyzer();
  5. int result = log.GetLineCount("abc.txt");
  6.  
  7. Assert.AreEqual(-, result);
  8. }
  9.  
  10. [Test]
  11. public void GoodNameTest()
  12. {
  13. LogAnalyzer log = new LogAnalyzer();
  14. int result = log.GetLineCount("abc.txt");
  15. const int COULD_NOT_READ_FILE = -;
  16.  
  17. Assert.AreEqual(-COULD_NOT_READ_FILE, result);
  18. }

  经过改进后,我们会很容易理解这个返回值的意义。

  (3)有意义的断言

  只有当测试确实需要,并且找不到别的办法使测试更清晰时,你才应该编写定制的断言信息。编写好的断言信息就像编写好的异常信息,一不小心就会犯错,使读者产生误解,浪费他们的时间。

  (4)断言和操作分离

  为了可读性,请不要把断言和方法调用写在同一行。

  1. // 断言和操作写在了同一行
  2. Assert.AreEqual(-COULD_NOT_READ_FILE, log.GetLineCount("abc.txt"));

三、小结

  这一篇我们学习了:

  • 尽量将测试自动化,尽可能多次地运行测试,尽可能持续地进行产品交付;
  • 把集成测试和单元测试分开,为整个团队构建一个绿色安全区,该区域中所有的测试都必须通过;
  • 按照项目和类型组织测试,把测试分别放在不同的目录、文件夹或者命名空间中;
  • 使用测试类层次,对一个层次中相关的几个类进行同一组测试,或者对共享一个通用接口或者基类的类型进行同一组测试;
  • 优秀单元测试具有三大支柱:可读性、可维护性与可靠性,它们相辅相成。
  • 如果人们能读懂你的测试,就能理解和维护测试,如果测试能够通过,它们也会信任测试。一旦实现这个目标,你就能知道系统是否正常工作,具有了处理变更和在需要时修改代码的能力;

附件下载

  本系列文章的示例代码:点此下载

参考资料

  (1)Roy Osherove 著,金迎 译,《单元测试的艺术(第2版)》

作者:周旭龙

出处:http://edisonchou.cnblogs.com

本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文链接。

.NET单元测试的艺术-3.测试代码的更多相关文章

  1. iOS开发:XCTest单元测试(附上一个单例的测试代码)

    测试驱动开发并不是一个很新鲜的概念了.在我最开始学习程序编写时,最喜欢干的事情就是编写一段代码,然后运行观察结果是否正确.我所学习第一门语言是c语言,用的最多的是在算法设计上,那时候最常做的事情就是编 ...

  2. .NET单元测试的艺术-1.入门

    开篇:最近在看Roy Osherove的<单元测试的艺术>一书,颇有收获.因此,将其记录下来,并分为四个部分分享成文,与各位Share.本篇作为入门,介绍了单元测试的基础知识,例如:如何使 ...

  3. .NET单元测试的艺术-2.核心技术

    开篇:上一篇我们学习基本的单元测试基础知识和入门实例.但是,如果我们要测试的方法依赖于一个外部资源,如文件系统.数据库.Web服务或者其他难以控制的东西,那又该如何编写测试呢?为了解决这些问题,我们需 ...

  4. NET单元测试的艺术

    NET单元测试的艺术 开篇:上一篇我们学习基本的单元测试基础知识和入门实例.但是,如果我们要测试的方法依赖于一个外部资源,如文件系统.数据库.Web服务或者其他难以控制的东西,那又该如何编写测试呢?为 ...

  5. angular单元测试与自动化UI测试实践

    关于本文:介绍通过karma与jsmine框架对angular开发的应用程序进行单元与E2E测试. angular单元测试与集成测试实践 先决条件 创建项目 webstorm中创建空白web项目 创建 ...

  6. 使用Microsoft Fakes隔离测试代码

    在单元测试(Unit Test)中我们遇到的问题之一是:假如被测试组件(类或项目)为A,组件A依赖于组件B,那么在组件A的单元测试ATest中测试A时,也需要依赖于B,在B发生改动后,就可能影响到A的 ...

  7. mvn编写主代码与测试代码

    maven编写主代码与测试代码 3.2 编写主代码 项目主代码和测试代码不同,项目的主代码会被打包到最终的构件中(比如jar),而测试代码只在运行测试时用到,不会被打包.默认情况下,Maven假设项目 ...

  8. maven编写主代码与测试代码

    3.2 编写主代码 项目主代码和测试代码不同,项目的主代码会被打包到最终的构件中(比如jar),而测试代码只在运行测试时用到,不会被打包.默认情况下,Maven假设项目主代码位于src/main/ja ...

  9. Visual Studio 单元测试之六---UI界面测试

    原文:Visual Studio 单元测试之六---UI界面测试 UI界面测试其实就是录制操作路径(Mapping),然后按照路径还原操作顺序的一个过程.这个方法对于Winform和Webform都同 ...

随机推荐

  1. Duilib源码分析(四)绘制管理器—CPaintManagerUI—(前期准备二)

    接下来,我们继续分析UIlib.h文件中余下的文件,当然部分文件可能顺序错开分析,这样便于从简单到复杂的整个过程的里面,而避免一开始就出现各种不理解的地方. 1. UIManager.h:UI管理器, ...

  2. JAVA中MAP值保持顺序不变

    今天在进行JAVA开发过程中,因需要使用MAP来存放数据,同时希望MAP中KEY的顺序与放入顺序保持一致. 在使用HashMap之后,发现KEY的顺序是乱序的,每次打印还不太一样.上网查询资料之后发现 ...

  3. Python-匿名函数

    lambda 函数是一种快速定义单行的最小函数,可以用在任何需要函数的地方   常规版本: def fun(x,y) return x*y lambda版本: r = lambda x,y:x*y p ...

  4. CSS3属性box-sizing

    1.box-sizing属性是CSS3的属性,有3个值:border-box.content-box.inherit. 2.默认是content-box,就是我们知道的width.padding.bo ...

  5. while 循环 。。

    这是一个可以循环到天荒地老的循环: 如果while 为真就一直循环下去: count=0 while True: count+=1 if count>50 and count<60: co ...

  6. Tween Animation----Alpha渐变透明度动画

    本博文是我自己操作过的并且能运行才给大家分享的 layout ----activity_main.xml 在res/新建一个anim文件夹,用来保存动画属性的xml 在anim文件夹里新建一个alph ...

  7. iOS 检测状态栏点击事件

    当tableView.scrollsToTop=YES不管用时,可以使用以下方法实现点击状态栏使tableView滚动到顶部. - (void) touchesBegan:(NSSet *)touch ...

  8. DirectX9 Sample_Empty Project

    作为第一个程序,EmpytProject仅仅示范了如何绑定DXUTstate结构中的回调函数. 回调函数 回调函数就是一个通过函数指针调用的函数.如果你把函数的指针(地址)作为参数传递给另一个函数,当 ...

  9. Codeforces 624

    B. Making a String time limit per test 1 second memory limit per test 256 megabytes input standard i ...

  10. 如何设置GridView中某个字段显示数据的一部分?

    后台方法: /// <summary> /// 截取字符串 /// </summary> /// <param name="str">要截取的字 ...