单元测试(C#版)

所谓单元测试(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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,999评论 4 368
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 68,102评论 1 302
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 110,709评论 0 250
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,439评论 0 217
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,846评论 3 294
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,881评论 1 224
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 32,062评论 2 317
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,783评论 0 205
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,517评论 1 248
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,762评论 2 253
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,241评论 1 264
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,568评论 3 260
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,236评论 3 241
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,145评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,941评论 0 201
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,965评论 2 283
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,802评论 2 275

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,106评论 18 139
  • Android单元测试介绍 处于高速迭代开发中的Android项目往往需要除黑盒测试外更加可靠的质量保障,这正是单...
    东经315度阅读 3,010评论 6 37
  • 什么是单元测试 在计算机编程中,单元测试(Unit Testing)又称为模块测试, 是针对程序模块(软件设计的最...
    HelloCsl阅读 10,821评论 1 46
  • @Author:彭海波 前言 单元测试(又称为模块测试, Unit Testing)是针对程序模块(软件设计的最小...
    海波笔记阅读 4,889评论 0 52
  • Mock 方法是单元测试中常见的一种技术,它的主要作用是模拟一些在应用中不容易构造或者比较复杂的对象,从而把测试与...
    熊熊要更努力阅读 28,197评论 2 25