单元测试

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

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

目录:

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

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<Exception>(() => 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
}

结果:

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值