[TDD]由SearchCriteriaBinder看Test Driven与Test First
很久没有维护blog,近两年新工作平平淡淡,生活迷迷糊糊,自己都不知道自己在做什么了,最近看了老赵 的我的TDD实践:可测试性驱动开发(下)和Shuhari的用TDD方式实现老赵的SearchCriteriaBinder,一时手痒,也来凑凑热闹,赚赚人气.
我觉得老赵 之所谓觉得实施TDD很困惑,主要是没有把握好"Test Driven"和"Test First"之间的差别. 我们这里先不下结论.先演示我根据自己的理解,利用测试驱动开发SearchCriteriaBinder的过程.
1. 任务需求
2.设计Class和第一个Test Unit
相关的数据class:
{
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
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 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)我们获得了一个新的测试用例.
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 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 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.抽象出新的对象出来,并重构原来的对象.
{
public List<string> Tokenize(string text)
{
throw new NotImplementedException();
}
}
{
//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
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
{
this.Tokenizer = new Tokenizer();
}
并实现Tokenizer的Tokenize方法
{
//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. 用例驱动开发.
特别是用例驱动开发,它就是我们通常说的"先有用例,再有实现",实现是用例驱动出来的,不是你脑袋先想出来的.脑袋想的应该是行为,比如SearchCriteriaBinder的BindModel方法.为了养成这种习惯,必须牢记一句话,从javaeye的gigix那边听来的"do the simplest thing to make the test pass, not the most stupid thing", 这也就是为什么我们为了通过第一个测试,直接hardcode的原因
Test Driven 和Test First是两个完全不一样的概念,目的不一样,效果也不一样.
本文基于署名 2.5 中国大陆许可协议发布,正品行货,如有雷同,皆为山寨,作者保留追究权利,但是在保留本文的署名 浪子(包含链接的情况下,允许进行转载,演绎或用于商业目的。如您有任何疑问或者授权方面的协商,请E-Mail/MSN联系我。