51Testing软件测试论坛

 找回密码
 (注-册)加入51Testing

QQ登录

只需一步,快速开始

微信登录,快人一步

手机号码,快捷登录

查看: 3680|回复: 0
打印 上一主题 下一主题

[转贴] 我的TDD实践:可测试性驱动开发(下)

[复制链接]
  • TA的每日心情
    擦汗
    3 小时前
  • 签到天数: 526 天

    连续签到: 3 天

    [LV.9]测试副司令

    跳转到指定楼层
    1#
    发表于 2019-1-8 17:26:58 | 只看该作者 回帖奖励 |倒序浏览 |阅读模式

    在上一篇文章里,我谈到自己在采用传统TDD方式进行开发时感到有些尴尬,最后不得不放弃这种先写测试再写代码最后重构的方式。不过我还是非常注重单元测试的实践,慢慢发现自己的做法开始转向另一种TDD方式,也就是“可测试性驱动开发”。简单的说,我现在采取的做法是,先开发,再测试,一旦发现产品代码不太容易测试,则将其重构为容易测试的代码。我发现,这种时刻注重可测试性的开发方式,其最终也能够得到质量较高的代码。例如,它和SOLID原则也颇为融洽。上次谈的比较理论,而这次我便通过一个简单功能的开发过程,来表现我的思维方式及常用做法。


    任务描述

    这个功能是开发ASP.NET MVC项目时的常见任务:构建一个Model Binder。ASP.NET MVC中Model Binder的职责是根据请求的数据来生成Action方法的参数(即构建一个对象)。那么这次,我们将为负责产品搜索的Action方法提供一个SearchCriteria参数作为查询条件:

    1. public class SearchCriteria
    2. {
    3.     public PriceRange Price;
    4.     public string Keywords { get; set; }
    5.     public Color Colors { get; set; }
    6. }

    7. [Flags]
    8. public enum Color
    9. {
    10.     Red = 1,
    11.     Black = 1 << 1,
    12.     White = 1 << 2
    13. }

    14. public class PriceRange
    15. {
    16.     public float Min { get; set; }
    17.     public float Max { get; set; }
    18. }
    复制代码

    SearchCriteria中包含三个条件,一是复杂类型的Price条件,二是字符串类型的Keywords条件,三是一个Color枚举类型。作为查询条件,它总是需要在URL中表示出来的。例如,如果是这样的URL:

    1. /keywords-hello%20world--price-100-200--color-black-red
    复制代码

    它表示的便是这样的条件:

    • 价格为100到200之间
    • 关键字为“hello world”(注意URL转义)
    • 颜色为黑或红(使用Color.Black | Color.White表示)

    而最终,我要使用“可测试性驱动开发”来实现的便是这个方法:


    1. public class SearchCriteriaBinder : IModelBinder
    2. {
    3.     public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    4.     {
    5.         throw new NotImplementedException();
    6.     }
    7. }
    复制代码

    那么,我又会怎么做呢?


    实现步骤

    其实这也是个比较简单的功能,于是我一开始便用最直接的方式进行开发:

    1. public class SearchCriteriaBinder : IModelBinder
    2. {
    3.     public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    4.     {
    5.         var modelName = bindingContext.ModelName;
    6.         var rawValue = bindingContext.ValueProvider[modelName].RawValue;

    7.         var text = HttpUtility.UrlDecode(rawValue.ToString());
    8.         var tokenGroups = this.Tokenize(text);
    9.         ...
    10.     }

    11.     private List<string[]> Tokenize(string text)
    12.     {
    13.         ...
    14.     }
    15. }
    复制代码

    这个Model Binder会从Value Provider中得到Model Name(一般Action参数的名称,在这里不是重点)所对应的rawValue,经过了URL Decode之后便得到了text,它是一个带有信息的字符串,也便是《趣味编程》所要解析的对象。如上面的例子,text便是:

    1. keywords-hello world--price-10-20--color-black-red
    复制代码

    请注意,原本在URL中表示为%20的字符,已经被URL Decode为一个空格。在得到text之后,我便要将其拆分为一个List<string[]>对象,这便是分割好的结果。拆分字符串的逻辑比较复杂,因此我将其提取到一个独立的Tokenize方法中去。于是我接下来就开始实现Tokenize方法了,写啊写,写完了。但是,我到底写的正不正确?我不知道。我唯一知道的东西是,这个逻辑不简单,我需要测试一下才放心。因此,在继续其他工作之前,我想要为它写一些单元测试。


    这就是涉及到一个问题,我们该如何为一个私有方法作单元测试呢?我以前也想在博客上讨论这个问题,但是最终不知为何没有进行。我的看法是,如果设计得当,每个类的职责单一,应该不会出现需要进行单元测试的私有方法。如果一个私有方法需要测试,那么说明它的逻辑相对较为复杂,而且有独立的职责,应该将其提取到外部的类型中。例如在这里,Tokenize方法便值得我这样么做——因为我想要单元测试。于是我提取出一个Tokenizer抽象,以及一个默认的逻辑实现:

    1. internal interface ITokenizer
    2. {
    3.     List<string[]> Tokenize(string text);
    4. }

    5. internal class Tokenizer : ITokenizer
    6. {
    7.     public List<string[]> Tokenize(string text)
    8.     {
    9.         ...
    10.     }
    11. }
    复制代码

    我现在便可以对Tokenizer进行充分的单元测试,以确保它的功能满足我的要求。测试完成后,我就对它完全放心了。而此时,我的SearchCriteriaBinder便会直接使用Tokenizer对象,而不是内部的私有方法——当然,是基于抽象来的:

    1. public class SearchCriteriaBinder : IModelBinder
    2. {
    3.     public SearchCriteriaBinder()
    4.         : this(new Tokenizer()) { }

    5.     internal SearchCriteriaBinder(ITokenizer tokenizer)
    6.     {
    7.         this.m_tokenizer = tokenizer;
    8.     }

    9.     private readonly ITokenizer m_tokenizer;

    10.     public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    11.     {
    12.         var modelName = bindingContext.ModelName;
    13.         var rawValue = bindingContext.ValueProvider[modelName].RawValue;

    14.         var text = HttpUtility.UrlDecode(rawValue.ToString());
    15.         var tokenGroups = this.m_tokenizer.Tokenize(text);
    16.         ...
    17.     }
    18. }
    复制代码

    原本由私有的Tokenize方法负责的逻辑,现在已经委托给ITokenzier对象了,而这个对象可以在构造SearchCriteriaBinder对象时通过构造函数提供。请注意,提供ITokenizer对象的构造函数访问级别是internal,也就是说,它可以被单元测试代码所访问(通过InternalVisibleToAttribute),但是无法被另一个程序集的使用方调用。也就是说,经过重构的SearchCriteriaBinder,它的可测试性提了,但是对外的表现却没有丝毫变化。


    经过简单思考,便可以发现这一简单的改变其实也较为满足SOLID原则中的一部分:

    • 单一职责:SearchCriteriaBinder职责很单一,解析工作交由同样职责单一的Tokenizer进行。
    • 依赖注入:这里使用了构造函数注入的方式,SearchCriteriaBinder对Tokenizer的依赖不是写死在代码里的。
    • 接口分离:SearchCriteriaBinder并不直接访问Tokenizer,而是通过一个抽象(ITokenizer)使用的。

    这便是我常用的“可测试性驱动开发”,我一开始只是按照惯例直接实现BindModel方法,然后发现一个需要测试的私有方法,因此为了提高可测试性,我将部分功能提取到独立的Tokenizer对象中去。在实际开发过程中,我也可能是直接在脑子里进行简单的分析,然后直接发现我们的确需要一个Tokenizer方法(这点想象应该不难),于是直接实现ITokenzier接口,Tokenizer类以及单元测试。这样,可能SearchCriteriaBinder从一开始就会变成目前的样子。不过更多的情况,的确是写着写着,为了“可测试性”而进行的重构。为了说明这个“思路”,我接下来还是使用“编写代码——尝试测试而不能——重构”的方式来进行开发。


    好,我已经拆分成功了,也就是得到了一个List<string[]>对象。接下来,我要读取其中的数据,将其转化为一个SearchCriteria对象:

    1. public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    2. {
    3.     ...
    4.     var tokenGroups = this.m_tokenizer.Tokenize(text);

    5.     return this.Build(tokenGroups);
    6. }

    7. private SearchCriteria Build(List<string[]> tokenGroups)
    8. {
    9.     var fieldTokens = tokenGroups.ToDictionary(
    10.         g => g[0].ToLowerInvariant(),
    11.         g => g.Skip(1).ToList());

    12.     var searchCriteria = new SearchCriteria();

    13.     List<string> values;
    14.     if (fieldTokens.TryGetValue("keywords", out values))
    15.     {
    16.         searchCriteria.Keywords = values[0];
    17.     }

    18.     if (fieldTokens.TryGetValue("price", out values))
    19.     {
    20.         searchCriteria.Price = new PriceRange
    21.         {
    22.             Min = float.Parse(values[0]),
    23.             Max = float.Parse(values[1])
    24.         };
    25.     }

    26.     if (fieldTokens.TryGetValue("color", out values))
    27.     {
    28.         ...
    29.     }

    30.     return searchCriteria;
    31. }
    复制代码

    在BindModel方法中得到了tokenGroups之后,便交由Build方法进行SearchCriteria对象的构建。首先,我先将List<string[]>对象转化为“字段”和“值”的对应关系,这样我们便可以使用keywords、price等字符串获取数据(也就是一个List<string>对象),并生成SerachCriteria各属性所需要的值了。在这里,我们这一切都放在Build方法中的几个if里进行,但这很显然不是容易单元测试的方法。要知道,这里的代码看上去容易,但事实上每个if里的逻辑其实并不仅仅如此。例如,在输入不合法的情况下是容错,还是抛出异常?如果Min大于Max的情况下,是否直接将其交换再继续处理?因此,其实在每个if之中还会有if,还会有for等复杂的逻辑。对于这样的逻辑,我想要单元测试。


    于是,我为List<string>到特定对象的转换操作也定义一个抽象:

    1. public interface IConverter
    2. {
    3.     object Convert(List<string> values);
    4. }

    5. public class KeywordConverter : IConverter
    6. {
    7.     public object Convert(List<string> values)
    8.     {
    9.         return values[0];
    10.     }
    11. }

    12. public class PriceRangeConverter : IConverter
    13. {
    14.     public object Convert(List<string> values)
    15.     {
    16.         return new PriceRange
    17.         {
    18.             Min = float.Parse(values[0]),
    19.             Max = float.Parse(values[1])
    20.         };
    21.     }
    22. }

    23. public class ColorConverter : IConverter
    24. {
    25.     ...
    26. }
    复制代码




    分享到:  QQ好友和群QQ好友和群 QQ空间QQ空间 腾讯微博腾讯微博 腾讯朋友腾讯朋友
    收藏收藏
    回复

    使用道具 举报

    本版积分规则

    关闭

    站长推荐上一条 /1 下一条

    小黑屋|手机版|Archiver|51Testing软件测试网 ( 沪ICP备05003035号 关于我们

    GMT+8, 2024-11-14 12:45 , Processed in 0.059375 second(s), 23 queries .

    Powered by Discuz! X3.2

    © 2001-2024 Comsenz Inc.

    快速回复 返回顶部 返回列表