Hello World
Spiga

辅助方法不嫌多

2009-04-12 19:25 by 老赵, 21035 visits

经过了一些《表达式树》、《尾递归》等冷门内容,我们再回到一些人民群众喜闻乐见的话题上来,继续《最佳实践》的讨论。

在开发项目过程中,总是会出现大量的辅助方法,例如字符串处理,代码检验,格式输出等等。如果您发现自己在多次编写类似的代码,可能就要想着如何把这些代码进行提取,变成辅助方法(亦或是类库甚至框架,关于这方面粒度问题在此不作讨论)。辅助方法的作用除了遵循DRY原则之外,也能让代码更容易编写,更为清晰,可读性也能更好——而且只要您“去做”,就会发现要得到这些好处并不困难。

在这里举一个最简单的例子,对Index方法的单元测试:

[TestMethod]
public void IndexTest()
{
    UserIdentity identity = new UserIdentity();

    Mock<HomeController> mockController = new Mock<HomeController>() { CallBase = true };
    mockController.Setup(c => c.Identity).Returns(identity);

    var result = mockController.Object.Index() as ViewResult;
    if (result == null)
    {
        throw new Exception("result is expected to be ViewResult but not.");
    }

    Assert.AreEqual("", result.ViewName,
        "the view name is expected to be the default one but '{0}'", result.ViewName);
    Assert.AreEqual("", result.MasterName,
        "the master name is expected to be the default one but '{0}'", result.MasterName);

    var model = result.ViewData.Model as IndexModel;
    if (model == null)
    {
        throw new Exception("model is expected to be IndexModel but not.");
    }

    Assert.AreEqual(identity, model.Identity);
    Assert.AreEqual("Welcome to ASP.NET MVC!", model.Message);
}

从“var result = ...”这一行代码开始到结尾,都是对Index方法调用结果的断言,其中包括以下几点:

  1. 返回值为ViewResult对象
  2. ViewName是默认值
  3. MasterName是默认字符串
  4. Model为IndexModel对象
  5. Model的各属性为正确的值

这不可或缺的五点要求总共占用了十几行代码(虽然它们都非常清晰明白)。如果每个单元测试方法都需要编写这些代码,这无疑是一件令人乏味的事情。这时,您就可以提供辅助方法来简化单元测试的编写。

“等一下,你说要为单元测试编写辅助方法,这值得吗?”的确,老赵也见过不少朋友认为,为这种“非功能性”的代码投入太多成本是一件价值不大的事情。其实关于这一点和讨论“单元测试是否有必要”是差不多的事情,如果您把单元测试视为一种可有可无的辅助品,那么的确不值得这么做1。如果您认为单元测试是项目的一部分,那么让这部分代码更容易编写又有何不可呢?更何况……您不妨先看一下使用辅助方法之后这部分代码的模样:

[TestMethod]
public void IndexTest()
{
    UserIdentity identity = new UserIdentity();

    Mock<HomeController> mockController = new Mock<HomeController>() { CallBase = true };
    mockController.Setup(c => c.Identity).Returns(identity);

    var result = mockController.Object.Index().Is<ViewResult>().IsView(null, null);
    var model = result.ViewData.Model.Is<IndexModel>();

    Assert.AreEqual(identity, model.Identity);
    Assert.AreEqual("Welcome to ASP.NET MVC!", model.Message);
}

不知道您的感受如何,不过这些代码当时的确让老赵欣喜了一把。长篇冗繁的判断代码变成寥寥数行,而且如果您也可以想象一下在编写这些代码时的感觉——几乎都由IDE提示完成。而且,编写这些辅助方法其实非常容易:

public static class AssertHelpers
{
    public static T Is<T>(this object result)
    {
        Assert.IsTrue(
            result is T,
            "actionResult is expected to be '{0}' but '{1}'", typeof(T), result.GetType());
        return (T)result;
    }

    public static T IsView<T>(this T result, string viewName, string masterName) where T : ViewResult
    {
        viewName = viewName ?? "";
        masterName = masterName ?? "";

        Assert.IsTrue(
            String.Equals(viewName, result.ViewName, StringComparison.InvariantCultureIgnoreCase),
            "The view name is expected to be {0} but {1}",
            viewName == "" ? "the default one" : "'" + viewName + "'",
            result.ViewName == "" ? "the default one" : "'" + result.ViewName + "'");

        Assert.IsTrue(
            String.Equals(masterName, result.MasterName, StringComparison.InvariantCultureIgnoreCase),
            "The master name is expected to be {0} but {1}",
            masterName == "" ? "the default one" : "'" + masterName + "'",
            result.MasterName == "" ? "the default one" : "'" + result.MasterName + "'");

        return result;
    }
}

这里用到了C# 3.0的“扩展方法”特性,这是个非常重要的“语法糖”。由于没有任何的侵入性,在实际使用过程中,扩展方法的美妙之处往往体现在一些非常有趣的地方,例如:

  • 针对某个特定枚举类型定义扩展方法,甚至针对Enum这个所有枚举类型的基类添加扩展方法,这样可以使原本无法包含其它成员的枚举类型似乎也有了方法。这个示例提供了一个扩展方法,能够从每个枚举类型中获取附加的数据。
  • 针对接口类型定义扩展方法,这样所有实现这个接口的类型都会获得额外的方法——是不是有种获得“多继承”特性的感觉?同样是这个示例,针对ICustomAttributeProvider定义扩展方法,为Type,MethodInfo,ProperyInfo等类型同时添加了扩展。
  • 把原本定义在某些基类才能让所有子类访问到的方法,转移成扩展方法,这样降低了代码之间耦合性。当然,这样的修改需要您重新编译(但不需要修改)代码。这个示例通过针对Control类型的扩展,为所有的控件、页面和模板页添加了FastEval扩展方法。

此外,测试代码的可读性也提高了一个级别,我们使用了Is…IsView等方法“模拟”了自然的英语语法。在Java和C#等语言中实现这种自然的文法并不是一件简单的事情(相对于Ruby,F#等语言来说)。不过我们也可以朝这个方向去努力一把,而最后的结果似乎也令人较为满意。

在这里还有个题外话:如今API的优劣已经大大影响一个语言、平台、框架在开发群体中的地位。开发人员往往会因为“顺手”这个看似“无理的理由”改变自己对于某个框架、平台或者语言的选择——其实原因也很容易理解,因为良好的优秀的API设计能够大大提高开发效率。这是个不争的事实,我们有时会说某某语言“它就是在写英文啊”(例如传说中的AppleScript),其实就是再指这门语言在描述程序的“语义”时与真实语法特别接近。举个更贴近.NET的例子,使用NMock,RhinoMocks这两个.NET单元测试领域中大名鼎鼎的Mock框架对一个方法调用作期望(Expect)时,就可以看出它们在API设计上就有很大的不同:

interface ICalculator
{
    int Sum(int a, int b);
}

class TestFixture
{
    void TestByNMock()
    {
        var mocks = new Mockery();
        var mockCalculator = mock.NewMock<ICalculator>();

        Expect.Once.On(mockCalculator)
            .Method("Sum")
            .With(1, 2)
            .Will(Return.Value(3));

        // use the mockCalculator object...

        mocks.VerifyAllExpectationsHaveBeenMet();
    }

    void TestByRhinoMocks()
    {
        var mocks = new MockRepository();
        var mockCalculator = mocks.CreateMock<ICalculator>();

        Expect.Call(mockCalculator.Sum(1, 2)).Return(3);
        mocks.ReplayAll();

        // use the mockCalculator object...
        
        mocks.VerifyAll();
    }
}

作为流行的Mock框架,无论是NMock的Expect...On...Method...With...Will Return式语法,或者RhinoMocks的Expect.Call...Return式语法在编程的“语义”方面都做得不错——不过Rhino Mocks明显更胜一步2。其原因就在于RhinoMocks使用了显式的方法调用和参数传递替代了NMock的字符串传递语法。这个优势使得开发人员在编写单元测试时可以在编机器中得到良好的代码提示,在重构时也可以让编辑器同时修改Mock对象的方法名,至少也可以让编译器提示错误。反之,如果使用字符串,则在Mock方法名修改之后还必须在运行时才能发现问题。一个简单重构就会破坏数个甚至更多的单元测试,这无疑是一个令人沮丧的现象。

作为一个从VB 5/6(2年)转向Delphi(1年),后又转向Java(1年半),最后立足于.NET平台,同时也在不断地关注着各类语言/平台发展的开发人员,我的看法应该不是井蛙之见。微软的产品以“易用性”著称,这一点在其开发领域也得到了继承。在对语言特性和API设计这方面,.NET平台总体来说让我非常满意。例如在.NET里使用C# 3.0的特性进行开发经常让我有一种愉快的感觉。.NET框架在其大部分类库中也提供了非常方便、直观的API设计,在编辑器的代码提示帮助下,一个有经验的开发人员甚至可以摆脱文档来写出一段能够“解决问题”的程序来。而微软在.NET框架中提炼出来的设计准则也被写入了《Framework Design Guidelines》一书中,它是第16届年度Jolt大奖的图书,现在其第二版也已经上市。我想您应该不会错过这些。

 

注1:如果您觉得单元测试可有可无,那么可能ASP.NET MVC并不适合您,您不妨继续使用更容易掌握的ASP.NET WebForms框架。

注2:Moq利用了Lambda表达式在语义方法又比RhinoMocks更胜一筹,不过现在RhinoMocks目前也提供了类似的功能。

Creative Commons License

本文基于署名 2.5 中国大陆许可协议发布,欢迎转载,演绎或用于商业目的,但是必须保留本文的署名赵劼(包含链接),具体操作方式可参考此处。如您有任何疑问或者授权方面的协商,请给我留言

Add your comment

31 条回复

  1. 侯垒
    *.*.*.*
    链接

    侯垒 2009-04-12 19:29:00

    抢老赵的沙发。

  2. @^o^@[未注册用户]
    *.*.*.*
    链接

    @^o^@[未注册用户] 2009-04-12 19:32:00

    不会吧,你瘦身如此成功!
    说实话,你本应该用DV拍下整个过程,片名就叫《大肥鹅变健美男》。。。。。

  3. 老赵
    admin
    链接

    老赵 2009-04-12 19:34:00

    @@^o^@
    我也后悔了应该每天拍一张照片的

  4. 紫色永恒
    *.*.*.*
    链接

    紫色永恒 2009-04-12 19:36:00

    扩展方法爽之爽之

  5. xiao_p(未登陆)[未注册用户]
    *.*.*.*
    链接

    xiao_p(未登陆)[未注册用户] 2009-04-12 19:41:00

    老赵嗷嗷瘦,这图片是老赵不?

  6. 老赵
    admin
    链接

    老赵 2009-04-12 20:14:00

    @xiao_p(未登陆)
    绝对真实,没有化妆没有PS。

  7. 的撒旦撒[未注册用户]
    *.*.*.*
    链接

    的撒旦撒[未注册用户] 2009-04-12 21:00:00

    可以提供怎么样减掉肚子上的肉不,现在蹲下来就不舒服,呵呵。
    扩展方法,LAMBDA表达式就是美。MVC,学了一点点,想深入,一直没时间细看,每次看到MVC的文章,总感觉像欠了别人什么东西一样。

  8. Tony  Qu
    *.*.*.*
    链接

    Tony Qu 2009-04-12 21:26:00

    老赵可以写本书叫《敏捷减肥》

  9. 王德水
    *.*.*.*
    链接

    王德水 2009-04-12 21:32:00

    我要胖

    我只关心老赵当年是咋胖起来的

  10. James.Ying
    *.*.*.*
    链接

    James.Ying 2009-04-12 21:59:00

    学习了~单元测试以后我还得加强一下。
    赵哥的瘦不是偶然,是坚持不懈的结果,而且还健康的瘦o(∩_∩)o...

  11. Anytao
    *.*.*.*
    链接

    Anytao 2009-04-12 22:09:00

    哈哈,我也不嫌多。

  12. 老赵
    admin
    链接

    老赵 2009-04-12 22:24:00

    @的撒旦撒
    就是跑步……其实我肚子上还有一块肥肉没有减掉,减不掉啊

  13. 老赵
    admin
    链接

    老赵 2009-04-12 22:25:00

    @王德水
    多吃,少睡,干扰身体的调解能力,记得每天晚上12点打吃一顿。

  14. 麒麟.NET
    *.*.*.*
    链接

    麒麟.NET 2009-04-12 23:59:00

    @Jeffrey Zhao
    肚子上是否会留下类似妊娠纹的东西?如果以前肚子很大的话……

  15. 老赵
    admin
    链接

    老赵 2009-04-13 00:03:00

    @麒麟.NET
    年轻人怕什么,皮肤有弹性自己会好得。

  16. CoderZh
    *.*.*.*
    链接

    CoderZh 2009-04-13 00:15:00

    又见MOCK,看来老赵对单元测试也深有研究那~

  17. 麒麟.NET
    *.*.*.*
    链接

    麒麟.NET 2009-04-13 00:16:00

    @Jeffrey Zhao
    哈哈……

  18. ziqiu.zhang
    *.*.*.*
    链接

    ziqiu.zhang 2009-04-13 01:20:00

    老赵比想象中壮多了...

  19. 古巴[未注册用户]
    *.*.*.*
    链接

    古巴[未注册用户] 2009-04-13 08:49:00

    越来越帅了

  20. 老赵
    admin
    链接

    老赵 2009-04-13 09:09:00

    --引用--------------------------------------------------
    CoderZh: 又见MOCK,看来老赵对单元测试也深有研究那~
    --------------------------------------------------------
    这……使用Mock就是对单元测试深有研究的充分条件吗?

  21. 老赵
    admin
    链接

    老赵 2009-04-13 09:10:00

    似乎大家都对文章不太感兴趣的样子……

  22. Jerry Qian
    *.*.*.*
    链接

    Jerry Qian 2009-04-13 09:37:00

    图片是在北京开会时照得啊。还做俯卧撑呢。哈哈。

  23. pzq[未注册用户]
    *.*.*.*
    链接

    pzq[未注册用户] 2009-04-13 10:02:00

    --引用--------------------------------------------------
    Jeffrey Zhao: 似乎大家都对文章不太感兴趣的样子……
    --------------------------------------------------------
    因为这篇文章有两个内容:一个是胖子变大帅哥;另一个是辅助方法不嫌多。你应该另外写一篇叫胖子变帅哥的文章,不要两篇混在一起。

  24. 支持LZ[未注册用户]
    *.*.*.*
    链接

    支持LZ[未注册用户] 2009-04-13 11:25:00

    @Jeffrey Zhao

    有什么linq的源代码项目 可以推荐的吗?谢谢了

    你的文章一直关注中。。。。。。。。。。。。。。

  25. misu[未注册用户]
    *.*.*.*
    链接

    misu[未注册用户] 2009-04-13 17:09:00

    瘦了之后,没有以前可爱了

  26. 老赵
    admin
    链接

    老赵 2009-04-13 19:20:00

    @misu
    你是女人吗?

  27. 1-2-3
    *.*.*.*
    链接

    1-2-3 2009-04-13 22:49:00

    呃……咳咳……我不是对文章不感兴趣,只是实在想问,老赵换了造型师?

  28. 老赵
    admin
    链接

    老赵 2009-04-13 23:02:00

    @1-2-3
    老赵没有造型师很多年了……

  29. 李胜攀
    *.*.*.*
    链接

    李胜攀 2009-04-14 09:07:00

    越来越瘦了
    嗯,看着比以前精神了。

  30. YSL
    *.*.*.*
    链接

    YSL 2010-03-20 17:47:00

    请问我新建一个MVC1.0项目,把自带的单元测试中测试方法改为Moq测试,可是一直提示错误,请老赵帮找一个原因,代码如下:

    [TestMethod]
            public void Index()
            {
                //// Arrange
                //HomeController controller = new HomeController();
    
                //// Act
                //ViewResult result = controller.Index() as ViewResult;
    
                //// Assert
                //ViewDataDictionary viewData = result.ViewData;
                //Assert.AreEqual("Welcome to ASP.NET MVC!", viewData["Message"]);
    
                var mockController = new Mock<HomeController>();
                mockController.Setup(c => c.Index()).Returns(mockController.Object.Index());
    
                ViewResult result = mockController.Object.Index() as ViewResult;
    
                Assert.AreEqual("Welcome to ASP.NET MVC!", result.ViewData["Message"]);
            }
    

    ------------------------------
    错误提示:
    测试方法 MvcTest.Tests.Controllers.HomeControllerTest.Index 引发异常: System.ArgumentException: Invalid setup on a non-overridable member:c => c.Index()。
    不知道是为什么,请老赵解决一下。

  31. 老赵
    admin
    链接

    老赵 2010-03-20 22:39:00

    @YSL
    错误信息都已经写了那么清楚了啊

发表回复

登录 / 登录并记住我 ,登陆后便可删除或修改已发表的评论 (请注意保留评论内容)

昵称:(必填)

邮箱:(必填,仅用于Gavatar

主页:(可选)

评论内容(大于5个字符):

  1. Your Name yyyy-MM-dd HH:mm:ss

使用Live Messenger联系我