单元测试(C#版)

96
丑小丫大笨蛋
2016.05.22 01:49* 字数 2951

所谓单元测试(unit testing),就是对软件中的最小单元进行检查和验证,其一般验证对象是一个函数或者一个类。值得一提的是,虽然单元测试是开发者为了验证一段代码功能正确性而写的一段代码,但是我们写一个单元测试的出发点并不是针对一段代码或者一个方法,而是针对一个应用场景(scenario),即在某些条件下某个特定的函数的行为。

0. 单元测试的必要性

单元测试不但会使你的工作完成得更轻松,而且会令你的设计变得更好,甚至大大减少你花在调试上面的时间。

(1)单元测试能让你确定自己的代码功能和逻辑的正确性,还可以让你增加对程序的信心,并且能够及早发现程序中的不足。
(2)在写好功能模块之前、之中和之后考虑好单元测试怎么写,不仅可以让你更加清楚你写的功能模块的逻辑,还能及早地改进一些不当的设计。
(3)每完成一块功能模块就用单元测试进行验证修改bug,比整个软件写完再验证调试要容易得多。而且有了单元测试,在整体软件出问题的时候,我们可以直接对怀疑的某模块在单元测试中进行debug,这往往比调试整个系统要容易得多。
(4)帮助我们及早地发现问题。有的时候对A的修改可能会影响看起来毫不相关的B,如果没有单元测试,A的修改checkin之后可能就会引发比较严重的问题。而如果在checkin之前能够运行所有的单元测试的话,B的单元测试可能就会发现引入的问题,从而阻止此次不当修改的checkin。

我想,其实很多程序员都应该知道单元测试重要性的那些大道理,只是要改变它就像要戒掉拖延症一样。明明知道那样不好并发誓下一次改进,却一直没有摆脱掉那些恶习。拜托,不要从明天或者从下一次开始了,就从现在开始吧!当你真正开始去写单元测试并坚持写,你会从中得到好处的,那时候你才会真正领悟到它的必要性。

1. 开始写你的第一个单元测试吧

我们先来用VS2012中自带的测试模块来写一个简单的单元测试吧。
新建一个solution,并添加工程MyMathLib,在该工程中添加MyMathLib类,并书写一个静态的Largest()函数来找出一个整型列表中的最大值。然后添加一个TestLargest工程,如图1所示,Add -> New Project 之后选择Test -> Unit Test Project。新建好test工程之后,你会得到一个test模板,即一个带有[TestClass] attribute标记的类和一个带有[TestMethod] attribute标记的空方法public void TestMethod1()

Figure 1. Add unit test project

现在我们的solution就具有了图2中所示的目录结构,打开刚添加的TestLargest工程下的references,我们可以看到它自动引用了Microsoft.VisualStudio.QuanlityTools.UnitTestFramework

Figure 2. Projects in the solution

分别在MyMathLibTestLargest添加代码如下:

// MyMathLib.cs
namespace FirstUnitTest.MyMathLib
{
    public static class MyMathLib
    {
        public static int Largest(List<int> list)
        {
            int maxNum = Int32.MaxValue;
            foreach (var num in list)
            {
                if (num > maxNum) maxNum = num;
            }
            return maxNum;
        }

        static void Main(string[] args)
        {
        }
    }
}
// UnitTest1.cs
using FirstUnitTest.MyMathLib;
namespace FirstUnitTest.Test
{
    [TestClass]
    public class UnitTest1
    {
        [TestMethod]
        public void TestMethod1()
        {
            var list = new List<int>() { 9, 8, 7 };
            Assert.AreEqual(9, MyMathLib.MyMathLib.Largest(list));
        }
    }
}

写好之后你会发现有编译错误,cannot resolve MyMathLib.MyMathLib.Largest,所以我们在TestLargest工程里光添加using FirstUnitTest.MyMathLib;是不够的,还需要在references中增加对MyMathLib工程的引用。这样在TestMethod1()上单击右键选择Run Tests就可以在Test Explorer里看到单元测试的运行结果(如图3所示)。

Figure 3. Unit test failed

可以看到,我们在单元测试中提供的例子的期望最大值是9,运行结果却是2147483647。再看一看我们得Largest方法,原来是在对maxNum进行第一次赋值的时候不小心把Int32.MinValue写成了Int32.MaxValue。你看,单元测试就是能够发现一些意向不到的错误。不要以为这里的bug很低级,类似的情况确实会在现实中发生。
我们把上面的错误更正后,再次运行TestMethod1()就会得到test passed的结果(如图4所示)。

Figure 4. Unit test passed

2. 一个例子告诉你该如何写单元测试

我们现在要利用List来写一个模拟栈操作的类,该类提供PushPopTopEmpty方法,现在要对这个类进行单元测试。

首先我们要明确这个类的主要功能:这里的栈用来存储数据,这些数据按照存入时间有序(为方便描述,不妨将最早进入的数据位置称为栈底,最后进来的位置称为栈顶);当需要存入数据时,采用Push操作将该数据放在栈顶;当需要从栈中取出数据时,采用Pop操作将栈顶的数据取出;当需要查看栈顶元素时,采用Top操作即可得到栈顶元素值;当需要知道栈中是否有数据时,采用Empty查看它是否为空。

那我们就针对这些功能来想想我们应用这个栈的场景吧,然后就可以把这些场景写成单元测试。
(1)往一个空栈中Push数据,该操作成功的话栈应该不空,并且栈顶元素就是刚Push进去的那个数据。
(2)连续地往栈中Push数据,每次操作后查看栈顶元素都是刚刚放进去的那个数据。
(3)往栈中Push特殊的数据,我们这里存放的是string,所以添加string.Emptynull也应该是成功的。
(4)连续地Pop操作,确认每次取出的都是栈顶元素。
(5)对空栈进行PopTop操作,会抛出异常。

// StackExercise Class
namespace FirstUnitTest
{
    public class StackExercise
    {
        private List<string> _stack;

        public StackExercise()
        {
            _stack = new List<string>();
        }

        public void Push(string str)
        {
            _stack.Add(str);
        }

        public void Pop()
        {
            if (Empty())
            {
                throw new InvalidOperationException("Empty stack cannot pop");
            }

            _stack.Remove(_stack.Last());
        }

        public string Top()
        {
            if (Empty())
            {
                throw new InvalidOperationException("Empty stack cannot get top");
            }

            return _stack.Last();
        }

        public bool Empty()
        {
            return (!_stack.Any());
        }
    }
}
// Unit Tests
namespace FirstUnitTest
{
    [TestClass]
    public class TestStackExercise
    {
        [TestMethod]
        public void Test_SuccessAndNotEmpty_AfterPush()
        {
            // Arrange
            var stack = new StackExercise();
            var testElement = "testElement";

            // Action
            stack.Push(testElement);

            // Assert
            Assert.IsFalse(stack.Empty());
            Assert.AreEqual(testElement, stack.Top());
        }

        [TestMethod]
        public void Test_Success_PushMoreThanOnce()
        {
            // Arrange
            var stack = new StackExercise();
            var testElement = "testElement_{0}";

            // Action & Assert
            for (int i = 0; i < 10; ++i)
            {
                stack.Push(string.Format(testElement, i));
                Assert.AreEqual(string.Format(testElement, i), stack.Top());
            }
        }

        [TestMethod]
        public void Test_Success_PushEmptyString()
        {
            // Arrange
            var stack = new StackExercise();
            string emptyString = string.Empty;
            string nullString = null;

            // Action & Assert
            stack.Push(emptyString);
            Assert.AreEqual(emptyString, stack.Top());

            stack.Push(nullString);
            Assert.AreEqual(nullString, stack.Top());
        }

        [TestMethod]
        public void Test_Success_PopLastTwoElements()
        {
            // Arrange
            var stack = new StackExercise();
            var testElement1 = "test1";
            var testElement2 = "test2";

            // Action & Assert
            stack.Push(testElement1);
            stack.Push(testElement2);
            Assert.AreEqual(testElement2, stack.Top());

            stack.Pop();
            Assert.IsFalse(stack.Empty());
            Assert.AreEqual(testElement1, stack.Top());

            stack.Pop();
            Assert.IsTrue(stack.Empty());            
        }

        [TestMethod]
        [ExpectedException(typeof(InvalidOperationException))]
        public void Test_ThrowException_PopFromEmptyStack()
        {
            var stack = new StackExercise();
            stack.Pop();
        }

        [TestMethod]
        [ExpectedException(typeof(InvalidOperationException))]
        public void Test_ThrowException_TopFromEmptyStack()
        {
            var stack = new StackExercise();
            stack.Top();
        }
    }
}

所以,究竟该如何写单元测试呢?《单元测试之道C#版》里总结得很好:Right-BICEP。

Right,验证结果(主要功能和逻辑)是否正确;
B,边界条件是否正确;
I,是否可以检查反向关联;这里所谓反向关联,是指用反向逻辑来验证我们的结果,比如说要验证平方根是否正确时,可以求这个平方根的平方跟我们的输入是否一致。
C,是否可以采用其他方法来cross-check结果;cross-check是在单元测试中采用与实际模块中不同的方法来实现同样的功能作为期望结果,去与实际模块中得到的结果做对比。
E,错误条件是否可以重现;
P,性能方面是否满足条件。

3. 单元测试中不得不说的知识点

(1)断言Assertion
要验证代码的行为是否与期望一致时,我们需要使用断言来判断某个语句为真或为假,以及某些结果值与期望值是否相等,如IsTrue()IsFalse()AreEqual()等。

Assert.AreEqual(expected, actual [, string message]);
其中前两个参数很好理解,分别为期望值和实际值,最后一个可选参数是发生错误时报告的消息。如果不提供的话,出错后会看到这样的error message:Assert.AreEqual failed. Expected: xx. Actual: yy.。如果你的那个单元测试函数中有很多Assert.AreEqual的话,你就不清楚究竟是在哪个Assertion出错的,而当你对每个Assertion放上相应的message的话,出错时就可以一眼看出具体出错的Assertion。
另外,在用断言进行浮点数的比较时还需要提供另外一个参数tolerance
有时候每个test里我们都需要进行一系列相同或者类似的断言,那么我们可以尝试编写自定义的断言,这样测试的时候使用这个自定义的断言即可。

(2)test 组成
从上面的例子可以看到,test project与普通project的区别就是在class和method上面增加了一个属性。在不同的框架下这些属性还是不一样的,比如说我们上面用到的VS里自带的test框架,使用的是[TestClass][TestMethod],而大家最常用的NUint框架则使用的是[TestFixture][Test]
另外,还有几个attribute在实际项目中我们也会经常用到,那就是[SetUp][TearDown][TestFixtureSetUp][TestFixtureTearDown]。它们用来在调用test之前设置测试环境和在test之后释放资源。前两个是per-method,即每个用[Test]修饰的方法在运行前后都会调用[SetUp][TearDown];而后两个则是per-class的,即用于[TestFixture]修饰的类的前后。

(3)对于异常的测试
对于预期的异常,只要在测试方法上添加[ExpectedException(typeof(YourExpectedExcetion))]属性即可。但是需要注意的是,一旦这个方法期望的异常抛出了,测试方法中剩余的代码就会被跳过。
所以NUint里面还有一种方式来验证异常,即Assert.Throws<ExpectedException>(() => methodToTest());,这样就可以在一个test method里面验证多个抛出异常的情况了。

(4)使用mock对象
单元测试的目标是一次只验证一个方法或一个类,但是如果这个方法依赖一些其他难以操控的东西,比如网络、数据库等。这时我们就要使用mock对象,使得在运行unit test的时候使用的那些难以操控的东西实际上是我们mock的对象,而我们mock的对象则可以按照我们的意愿返回一些值用于测试。

比如说,我们在某个函数中需要利用HttpClient通过SendAsync方法从某个EndPoint获取数据进行处理。但是在local测试的时候不一定能够连上那个EndPoint,或者不能保证那个EndPoint会返回什么东西。所以我们可以写mock一个ResponseHandler,这样我们就可以把mock的返回结果放进httpClient中传给需要测试的模块,这样就可以测试该模块内后续部分的处理了。

internal class MockResponseHandler : DelegatingHandler
{
    public HttpStatusCode StatusCode { get; set; }

    public HttpContent Content { get; set; }
    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
                System.Threading.CancellationToken cancellationToken)
    {
        return await ReturnRespsonse();
    }

    private Task<HttpResponseMessage> ReturnRespsonse()
    {
        var response = new HttpResponseMessage()
        {
            StatusCode = this.StatusCode,
            Content = this.Content
        };

        return Task.Run(() => response);
    }
}
var successHttpClient = new HttpClient(
    new MockResponseHandler 
    { 
        StatusCode = HttpStatusCode.OK 
    });

var forbidHttpClient = new HttpClient(
    new MockResponseHandler 
    { 
        StatusCode = HttpStatusCode.Forbidden, 
        Content = new StringContent(testError) 
    });

实际上,.NET中现在很多mock对象的框架供选择(参见http://www.mockobjects.org ),很多常用的mock都可以直接使用框架,而不需要自己去写。

4. 帮助你更好地进行单元测试的工具

NUnit
ReShaper
奈何家里的笔记本下载它们一直失败,所以这里先给个链接,以后有机会再介绍一下它们吧(⊙﹏⊙)b

参考文献:
《单元测试之道C#版》
单元测试之道C#版 第一章
单元测试 百度百科
谈谈单元测试之(一):为什么要进行烦人的单元测试?
C#中的单元测试
A Unit Testing Walkthrough with Visual Studio Team Test

工作与工具
Web note ad 1