代码改变世界

辅助方法不嫌多

2009-04-12 19:25  Jeffrey Zhao  阅读(...)  评论(...编辑  收藏

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

在开发项目过程中,总是会出现大量的辅助方法,例如字符串处理,代码检验,格式输出等等。如果您发现自己在多次编写类似的代码,可能就要想着如何把这些代码进行提取,变成辅助方法(亦或是类库甚至框架,关于这方面粒度问题在此不作讨论)。辅助方法的作用除了遵循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目前也提供了类似的功能。