[TDD]由SearchCriteriaBinder看Test Driven与Test First

很久没有维护blog,近两年新工作平平淡淡,生活迷迷糊糊,自己都不知道自己在做什么了,最近看了老赵 我的TDD实践:可测试性驱动开发(下)Shuhari用TDD方式实现老赵的SearchCriteriaBinder,一时手痒,也来凑凑热闹,赚赚人气.

我觉得老赵 之所谓觉得实施TDD很困惑,主要是没有把握好"Test Driven"和"Test First"之间的差别. 我们这里先不下结论.先演示我根据自己的理解,利用测试驱动开发SearchCriteriaBinder的过程.

1. 任务需求

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

2.设计Class和第一个Test Unit

相关的数据class:

    public class SearchCriteria
   
{
       
public PriceRange Price;
       
public string Keywords { get; set; }
       
public Color Colors { get; set; }
    }


    [Flags]
   
public enum Color
   
{
        Red = 1,
        Black = 1 << 1,
        White = 1 << 2
    }


   
public class PriceRange
   
{
       
public float Min { get; set; }
       
public float Max { get; set; }
    }


   
public class SearchCriteriaBinder : IModelBinder
   
{
       
public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
       
{
           
throw new NotImplementedException();
        }

    }


   
//由于没有安装MVC环境,所以这边用伪类代替,这个不影响我们的开发
    public interface IModelBinder
   
{
       
object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext);
    }


   
public class ControllerContext
   
{
    }


   
public class ModelBindingContext
   
{
       
public string RawValue
       
{
           
get;
           
set;
        }

    }

第一个TestUnit

    [TestMethod()]
       
public void BindModelOne()
       
{
            ControllerContext controllerContext = new ControllerContext();
            ModelBindingContext modelBindingContext = new ModelBindingContext() { RawValue = "keywords-hello%20world--price-100-200--color-black-red" };

            SearchCriteria criteria = (SearchCriteria)new SearchCriteriaBinder().BindModel(controllerContext, modelBindingContext);
            Assert.IsTrue(criteria.Keywords.Contains("hello world"));
            Assert.IsTrue(criteria.Price.Min == 100);
            Assert.IsTrue(criteria.Price.Max == 200);
            Assert.IsTrue(criteria.Colors == (Color.Black | Color.Red));
           
        }

 

3.用例写好了,运行下用例,看它是否可以通过,不能通过就修改实现代码,让其通过测试.

很显然,当前的代码是无法通过测试,所以我们修改下SearchCriteriaBinder 的代码,让它可以通过这个测试.

    public class SearchCriteriaBinder : IModelBinder
   
{
       
public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
       
{
           
return new SearchCriteria() { Keywords = "hello world", Price = new PriceRange() { Max = 200, Min = 100 }, Colors = Color.Black | Color.Red };
        }

    }

4. 运行测试用例,通过了.

5. 根据需求获取新的用例.

如果你再也提炼不出用例,则证明任务已经完成.目前这个需求明显还没有完成,现在只针对一个固定条件进行测试/实现,而看需求,这个条件是需要被泛化(generalization) 利用三角法(Triangulation)我们获得了一个新的测试用例.

        [TestMethod()]
       
public void BindModelTwo()
       
{
            ControllerContext controllerContext = new ControllerContext();
            ModelBindingContext modelBindingContext = new ModelBindingContext() { RawValue = "keywords-hello%20cnblogs--price-200-300--color-White" };

            SearchCriteria criteria = (SearchCriteria)new SearchCriteriaBinder().BindModel(controllerContext, modelBindingContext);
            Assert.IsTrue(criteria.Keywords.Contains("hello cnblogs"));
            Assert.IsTrue(criteria.Price.Min == 200);
            Assert.IsTrue(criteria.Price.Max == 300);
            Assert.IsTrue(criteria.Colors == Color.White);

        }

 

6.获得新用例之后,先运行测试,失败,必须重写实现让用例通过.

 public class SearchCriteriaBinder : IModelBinder
   
{
       
public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
       
{
           
//throw new NotImplementedException();
           
//return new SearchCriteria() { Keywords = "hello world", Price = new PriceRange() { Max = 200, Min = 100 }, Colors = Color.Black | Color.Red };
            var rawValue = bindingContext.RawValue;

            var text = HttpUtility.UrlDecode(rawValue.ToString());

            var tokenGroups = text.Split(new string[] { "--" }, StringSplitOptions.None).ToList();

            var fieldTokens = tokenGroups.ToDictionary(g => g.ToLowerInvariant().Split(new string[] { "-" }, StringSplitOptions.None)[0], g => g.ToLowerInvariant().Split(new string[] { "-" }, StringSplitOptions.None).Skip(1).ToList());


            var searchCriteria = new SearchCriteria();

            List<string> values;


           
if (fieldTokens.TryGetValue("keywords", out values))
           
{
                searchCriteria.Keywords = values[0];
            }


           
if (fieldTokens.TryGetValue("price", out values))
           
{
                searchCriteria.Price = new PriceRange
               
{
                    Min = float.Parse(values[0]),
                    Max = float.Parse(values[1])
                }
;
            }


           
if (fieldTokens.TryGetValue("color", out values))
           
{
               
foreach (var item in values)
               
{
                   
switch (item)
                   
{
                       
case "red":
                            searchCriteria.Colors = searchCriteria.Colors | Color.Red;
                           
break;
                       
case "black":
                            searchCriteria.Colors = searchCriteria.Colors | Color.Black;
                           
break;
                       
case "white":
                            searchCriteria.Colors = searchCriteria.Colors | Color.White;
                           
break;
                       
default:
                           
break;
                    }

                }


            }


           
return searchCriteria;

        }

    }

 

7. 重新运行两个测试用例,已经可以通过.

8. 重复5~7. 直到没有新的测试用例可以新增,任务完成.

    这边的测试用例,还有一个测试类别和完整性的问题.我们后面再讨论.

9.重构,完善代码.

   到了这一步,算是一个里程碑,接下来的步骤就会发生分歧.

吉日说:任务完成了,可以提交给客人.
老赵说:代码好丑陋,毫无设计可言.

   哈哈,调侃一下.TDD不仅仅是驱动开发,同时也有重构,完善代码的步骤(Test-Code-Test-Refactoring-Test-Code)

   重构就是在不改变可见行为的情况下,整理代码,去除坏味道,使其更加清晰可读.所以我们重构代码如下

  public class SearchCriteriaBinder : IModelBinder
   
{
       
public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
       
{
           
//throw new NotImplementedException();
           
//return new SearchCriteria() { Keywords = "hello world", Price = new PriceRange() { Max = 200, Min = 100 }, Colors = Color.Black | Color.Red };
            var rawValue = bindingContext.RawValue;

            var text = HttpUtility.UrlDecode(rawValue.ToString());

            var tokenGroups = this.Tokenize(text);

            var searchCriteria = this.Build(tokenGroups);

           
return searchCriteria;

        }


   
       
private List<string> Tokenize(string text)
       
{
            var tokenGroups = text.Split(new string[] { "--" }, StringSplitOptions.None).ToList();
           
return tokenGroups;
        }


       
private SearchCriteria Build(List<string> tokenGroups)
       
{
            var fieldTokens = tokenGroups.ToDictionary(g => g.ToLowerInvariant().Split(new string[] { "-" }, StringSplitOptions.None)[0], g => g.ToLowerInvariant().Split(new string[] { "-" }, StringSplitOptions.None).Skip(1).ToList());


            var searchCriteria = new SearchCriteria();

            List<string> values;


           
if (fieldTokens.TryGetValue("keywords", out values))
           
{
                searchCriteria.Keywords = values[0];
            }


           
if (fieldTokens.TryGetValue("price", out values))
           
{
                searchCriteria.Price = new PriceRange
               
{
                    Min = float.Parse(values[0]),
                    Max = float.Parse(values[1])
                }
;
            }


           
if (fieldTokens.TryGetValue("color", out values))
           
{
               
foreach (var item in values)
               
{
                   
switch (item)
                   
{
                       
case "red":
                            searchCriteria.Colors = searchCriteria.Colors | Color.Red;
                           
break;
                       
case "black":
                            searchCriteria.Colors = searchCriteria.Colors | Color.Black;
                           
break;
                       
case "white":
                            searchCriteria.Colors = searchCriteria.Colors | Color.White;
                           
break;
                       
default:
                           
break;
                    }

                }


            }

           
return searchCriteria;
        }

    }


   10.重构之后运行所有测试用例,保证逻辑(可见的行为)没有被更改,这就是测试用例的另一个作用,保证原先的业务逻辑不被更改.

   11.私用对象是否应该被测试.

      原则上来说,私用对象是不需要测试.如果你想对他进行测试,则应该将它抽象出来,独立作为一个需求.这样子才不需要增加/修改对象本身的行为(public/protected)从而破坏本来的业务行为. 我们依然以SearchCriteriaBinder为例,进行下一步动作.

   12.抽象出新的对象出来,并重构原来的对象.

  internal class Tokenizer
   
{
       
public List<string> Tokenize(string text)
       
{
           
throw new NotImplementedException();
        }

    }

 

        public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
       
{
           
//throw new NotImplementedException();
           
//return new SearchCriteria() { Keywords = "hello world", Price = new PriceRange() { Max = 200, Min = 100 }, Colors = Color.Black | Color.Red };
            var rawValue = bindingContext.RawValue;

            var text = HttpUtility.UrlDecode(rawValue.ToString());

          
 var tokenGroups = this.Tokenizer.Tokenize(rawValue);

            var searchCriteria = this.Build(tokenGroups);

           
return searchCriteria;

        }



     
  private Tokenizer Tokenizer
       
{
           
get;
           
set;
        }


       
//private List<string> Tokenize(string text)
       
//{
       
//    var tokenGroups = text.Split(new string[] { "--" }, StringSplitOptions.None).ToList();
       
//    return tokenGroups;
       
//}

 

  13. 编写第一个Test Unit

      [TestMethod()]
       
public void TokenizeTestOne()
       
{
            Tokenizer target = new Tokenizer();
           
string text = "keywords-hello world--price-100-200--color-black-red";
            List<string> expected = new List<string> {
           
"keywords-hello world",
           
"price-100-200",
           
"color-black-red",
            }
;
            List<string> actual = target.Tokenize(text);
        
            Assert.AreEqual(expected[0], actual[0]);
            Assert.AreEqual(expected[1], actual[1]);
            Assert.AreEqual(expected[2], actual[2]);
           
        }

14.因为重构了代码,所以必须运行所有的测试用例.你会发现除了新加的这个测试失败了,以前的用例都失败了.因为Tokenizer没有被实例化,所以我们修改下SearchCriteriaBinder的constructor

        public SearchCriteriaBinder()
       
{
           
this.Tokenizer = new Tokenizer();
        }

  并实现Tokenizer的Tokenize方法

     public List<string> Tokenize(string text)
       
{
           
//throw new NotImplementedException();
            var tokenGroups = text.Split(new string[] { "--" }, StringSplitOptions.None).ToList();
           
return tokenGroups;
        }

  15. 重新运行所有的测试用例,通过

  16. 接下来泛化Tokenizer的参数,重新添加测试用例,重复之前的步骤.(至于build要重构也是类似的步骤).

 

以上的步骤,就是我理解中的TDD的开发顺序.你可以下载演示代码自己进行尝试

http://cid-af3411fff50fdeaa.skydrive.live.com/embedicon.aspx/Public/Demo/SearchCriteriaBinder.rar

接下来,稍微八下理论方面的看法.

    在老赵的文中,基本上是他已经先设计好了实现的思路/设计,然后再写出对应的测试用例,接下来进行编码.(好多"我想要单元测试",哈哈).以这样子的步骤进行TDD的开发,肯定是不会对原有的开发流程起到辅助作用的,相反,反而会增加很多负担,完全没办法发挥TDD的好处. 也就是老赵其实用的是"Test first",而不是靠Test来Dirven development的. 所以实践起来就觉得别扭.

    难道,TDD都要这样子一步步来嘛?我设计能力比较强,一下子就可以把整体的轮廓都设计出来,总不能让我倒退回去,像菜鸟一样一步一步来吧?

    没错,TDD是提倡小步前进,逐步测试,逐步实现. 但是其实这里面有步子迈得多大的问题. 步子迈得越大,就要求更好的综合设计能力/全局把握能力.

    举个简单的例子. 如果刚开始TDD,或者对刚学开发的同学来说,他肯定是按照上面的步骤一步步来更保险的,但是对于一般熟练的人员来说,他就可以直接泛化参数,一下子写好两个测试用例.然后再通过两个用例直接驱动出实现来.    所以这个步子的大小完全取决于你对自己能力的定位^_^,有人只能一步跨过一条水沟,但是有人就是可以一步跳到河对岸,人比人气死人哦,千万别去比

   另外一个问题,就是测试的完整性/覆盖率. 其实测试分类别的,一些是上层测试,一些是下层测试,如果你硬要把它们和在一起,会累死人的.比如上面的SearchCriteriaBinder的两个测试用例,从业务层面(上层),他们已经可以完整体现任务的需求,所以已经算是完整了. 如果你硬要把参数为空/参数结构非法也加进来,那应该让他们来驱动什么业务呢? 这些底层测试,是可以外包给类库,或者内部消化的. 你不能用他们来体现业务,驱动开发,当然也不是绝对的,可能某些特殊的情况正是业务需求也不一定.

   还有另外一个典型的说法,就是阿不在帖子中回复说的

阿不:
从老赵的这篇文章能够体会到,我自己平常很难在项目中实践单元测试开发的一个重要的症结在于设计和抽象能力的不足。

 

这样子的结果就是因为步子太大了,所以对全局观的把握能力要求太高了,导致自己觉得力不从心,好像能力不足的样子.也可是说,这样子的开发不是用测试用例驱动开发而得到的,而是需求经过脑袋(经验)进行整体设计之后,再往TDD上靠,自然就力不从心了,那么大的系统,脑袋的运算量毕竟有限啊.

 

总得来说,我认为要实践TDD,必须把握好二件事情:

1. 需求决定用例

2. 用例驱动开发.

特别是用例驱动开发,它就是我们通常说的"先有用例,再有实现",实现是用例驱动出来的,不是你脑袋先想出来的.脑袋想的应该是行为,比如SearchCriteriaBinderBindModel方法.为了养成这种习惯,必须牢记一句话,从javaeye的gigix那边听来的"do the simplest thing to make the test pass, not the most stupid thing", 这也就是为什么我们为了通过第一个测试,直接hardcode的原因

Test Driven 和Test First是两个完全不一样的概念,目的不一样,效果也不一样.

posted @ 2009-10-20 22:43 浪子 阅读(...) 评论(...) 编辑 收藏