JUnit 假定测试的所有方面都是开发人员的地盘而集成测试框架(FIT)在编写需求的业务客户和实现需求的开发人员之间做了协作方面的试验这是否意味着 FIT 和 JUnit 是竞争关系呢?绝对不是!代码质量完美主义者 Andrew Glover 介绍了如何把 FIT 和 JUnit 两者最好的地方结合在一起实现更好的团队工作和有效的端到端测试 在软件开发的生命周期中每个人都对质量负有责任理想情况下开发人员在开发周期中用像 Junit 和 TestNG 这样的测试工具保证早期质量而质量保证团队用功能性系统测试在周期末端跟进使用像 Selenium 这样的工具但是即使拥有优秀的质量保证有些应用程序在交付的时候仍然被认为是质量低下的为什么呢?因为它们并没有做它们应当做的事 在客户(编写应用程序需求的)业务部门和(实现需求的)开发团队之间的沟通错误通常是摩擦的原因有时还是开发项目彻底失败的常见原因幸运的是存在一些方法可以帮助需求作者和实现者之间尽早 沟通 FIT 化的解决方案 集成测试框架 (FIT)是一个测试平台可以帮助需求编写人员和把需求变成可执行代码的人员之间的沟通使用 FIT需求被做成表格模型充当开发人员编写的测试的数据模型表格本身充当输入和测试的预期输出 图 显示了用 FIT 创建的结构化模型第一行是测试名称下一行的三列是与输入(value 和 value )和预期结果(trend() )有关的标题 图 用 FIT 创建的结构化模型 好消息是对于编程没有经验的人也能编写这个表格FIT 的设计目的就是让消费者或业务团队在开发周期中尽早与实现他们想法的开发人员协作创建应用程序需求的简单表格式模型可以让每个人清楚地看出代码和需求是否是一致的 清单 是与图 的数据模型对应的 FIT 代码不要太多地担心细节 —— 只要注意代码有多么简单而且代码中没有包含验证逻辑(例如断言等)可能还会注意到一些与表 中的内容匹配的变量和方法名称关于这方面的内容后面介绍 清单 根据 FIT 模型编写的代码 package acmefitimpl;import comacmesedlptrendTrender;import fitColumnFixture;public class TrendIndicator extends ColumnFixture { public double value; public double value; public String trend(){ return TrenderdetermineTrend(value value)getName(); }}
清单 中的代码由研究上面表格并插入适当代码的开发人员编写最后把所有东西合在一起FIT 框架读取表 的数据调用对应的代码并确定结果 FIT 和 JUnit FIT 的优美之处在于它让组织的消费者或业务端能够尽早参与测试过程(例如在开发期间)JUnit 的力量在于编码过程中的单元测试而 FIT 是更高层次的测试工具用来判断规划的需求实现的正确性 例如虽然 JUnit 擅长验证两个 Money 对象的合计与它们的两个值的合计相同但 FIT 可以验证总的订单价格是其中商品的价格减去任何相关折扣之后的合计区别虽然细微但的确重大!在 JUnit 示例中要处理具体的对象(或者需求的实现)但是使用 FIT 时要处理的是高级的业务过程 这很有意义因为编写需求的人通常不太考虑 Money 对象 —— 实际上他们可能根本不知道这类东西的存在!但是他们确实要考虑当商品被添加到订单时总的订单价格应当是商品的价格减去所有折扣 FIT 和 JUnit 之间绝不是竞争关系它们是保证代码质量的好搭档正如在后面的 案例研究 中将要看到的 测试用的 FIT 表格 表格是 FIT 的核心有几种不同类型的表格(用于不同的业务场景)FIT 用户可以用不同的格式编写表格用 HTML 编写表格甚至用 Microsoft Excel 编写都是可以的如图 所示 图 用 Microsoft Excel 编写的表格 也有可能用 Microsoft Word 这样的工具编写表格然后用 HTML 格式保存如图 所示 图 用 Microsoft Word 编写的表格 开发人员编写的用来执行表格数据的代码叫作装备(fixture)要创建一个装备类型必须扩展对应的 FIT 装备它映射到对应的表如前所述不同类型的表映射到不同的业务场景 用装备进行装配 最简单的表和装备组合也是 FIT 中最常用的是一个简单的列表格其中的列映射到预期过程的输入和输出对应的装备类型是 ColumnFixture 如果再次查看 清单 将注意到 TrendIndicator 类扩展了 ColumnFixture 而且也与图 对应请注意在图 中第一行的名称匹配完全限定名称(acmefitimplTrendIndicator )下一行有三列头两个单元格的值匹配 TrendIndicator 类的 public 实例成员(value 和 value )最后一个单元格的值只匹配 TrendIndicator 中的方法(trend ) 现在来看清单 中的 trend 方法它返回一个 String 值可以猜测得到对于表中每个剩下的行FIT 都会替换值并比较结果在这个示例中有三个 数据 行所以 FIT 运行 TrendIndicator 装备三次第一次value 被设置成 value 设置成 然后 FIT 调用 trend 方法并把从方法得到的值与表中的值比较应当是 decreasing 通过这种方式FIT 用装备代码测试 Trender 类每次 FIT 执行 trend 方法时都执行类的 determineTrend 方法当代码测试完成时FIT 生成如图 所示的报告 图 FIT 报告 trend 测试的结果 trend 列单元格的绿色表明测试通过(例如FIT 设置 value 为 value 为 调用 trend 得到返回值 decreasing) 查看 FIT 运行 可以通过命令行用 Ant 任务并通过 Maven 调用 FIT从而简单地把 FIT 测试插入构建过程因为自动进行 FIT 测试就像 JUnit 测试一样所以也可以定期运行它们例如在持续集成系统中 最简单的命令行运行器如清单 所示是 FIT 的 FolderRunner 它接受两个参数 —— 一个是 FIT 表格的位置一个是结果写入的位置不要忘记配置类路径! 清单 FIT 的命令行 %>java fitrunnerFolderRunner /test/fit /target/
FIT 通过插件还可以很好地与 Maven 一起工作如清单 所示只要下载插件运行 fit:fit 命令就 OK 了! 清单 Maven 得到 FIT C:\dev\proj\edoa>maven fit:fit __ __| \/ |__ _Apache__ ___| |\/| / _` \ V / _) \ ~ intelligent projects ~|_| |_\___|\_/\___|_||_| v build:start:java:preparefilesystem:java:compile: [echo] Compiling to C:\dev\proj\edoa/target/classesjava:jarresources:test:preparefilesystem:test:testresources:test:compile:fit:fit: [java] right wrong ignored exceptionsBUILD SUCCESSFULTotal time: secondsFinished at: Thu Feb :: EST
试用 FIT案例研究 现在已经了解了 FIT 的基础知识我们来做一个练习如果还没有 下载 FIT现在是下载它的时候了!如前所述这个案例研究显示出可以容易地把 FIT 和 JUnit 测试组合在一起形成多层质量保证 假设现在要为一个酿酒厂构建一个订单处理系统酿酒厂销售各种类型的酒类但是它们可以组织成两大类季节性的和全年性的因为酿酒厂以批发方式运作所以酒类销售都是按桶销售的对于零售商来说购买多桶酒的好处就是折扣而具体的折扣根据购买的桶数和酒是季节性还是全年性的而不同 麻烦的地方在于管理这些需求例如如果零售店购买了 桶季节性酒就没有折扣但是如果这 桶不是 季节性的那么就有 % 的折扣如果零售店购买 桶季节性酒那就有折扣但是只有 % 桶更陈的非季节性酒的折扣达到 %购买量达到 时也有类似的规矩 对于开发人员像这样的需求集可能让人摸不着头脑但是请看我们的啤酒酿造行业分析师用 FIT 表可以很容易地描述出这个需求如图 所示 图 我的业务需求非常清晰! 表格语义 这个表格从业务的角度来说很有意义它确实很好地规划出需求但是作为开发人员还需要对表格的语言了解更多一些以便从表格得到值首先也是最重要的表格中的初始行说明表格的名称它恰好与一个匹配的类对应(orgacmestorediscountDiscountStructureFIT )命名要求表格作者和开发人员之间的一些协调至少需要指定完全限定的表格名称(也就是说必须包含包名因为 FIT 要动态地装入对应的类) 请注意表格的名称以 FIT 结束第一个倾向可能是用 Test 结束它但要是这么做那么在自动环境中运行 FIT 测试和 JUnit 测试时会与 JUnit 产生些沖突JUnit 的类通常通过命名模式查找所以最好避免用 Test 开始或结束 FIT 表格名称 下一行包含五列每个单元格中的字符串都特意用斜体格式这是 FIT 的要求前面学过单元格名称与装备的实例成员和方法匹配为了更简洁FIT 假设任何值以括号结束的单元格是方法任何值不以括号结束的单元格是实例成员 特殊智能 FIT 在处理单元格的值进行与对应装备类的匹配时采用智能解析如 图 所示第二行单元格中的值是用普通的英文编写的例如 number of casesFIT 试图把这样的字符串按照首字母大写方式连接起来例如number of cases 变成 numberOfCases 然后 FIT 试图找到对应的装备类这个原则也适用于方法 —— 如图 所示discount price() 变成了 discountPrice() FIT 还会智能地猜测单元格中值的具体类型例如在 图 余下的八行中每一列都有对应的类型或者可以由 FIT 准确地猜出或者要求一些定制编程在这个示例中图 有三种不同类型与 number of cases 关联的列匹配到 int 而与 is seasonal 列关联的值则匹配成 boolean 剩下的三列list price per casediscount price() 和 discount amount() 显然代表当前值这几列要求定制类型我将把它叫作 Money 有了它之后应用程序就要求一个代表钱的对象所以在我的 FIT 装备中遵守少量语义就可以利用上这个对象! FIT 语义总结 表 总结了命名单元格和对应的装备实例变量之间的关系 表 单元格到装备的关系实例变量 单元格值对应的装备实例变量类型list price per caselistPricePerCase Money number of casesnumberOfCases int is seasonalisSeasonal boolean 表 总结了 FIT 命名单元格和对应的装备方法之间的关系 表 单元格到装备的关系方法 表格单元格的值对应的装备方法返回类型discount price()discountPrice Money discount amount()discountAmount Money 该构建了! 要为酿酒厂构建的订单处理系统有三个主要对象一个 PricingEngine 处理包含折扣的业务规则一个 WholeSaleOrder 代表订单一个 Money 类型代表钱 Money 类 第一个要编写的类是 Money 类它有进行加乘和减的方法可以用 JUnit 测试新创建的类如清单 所示 清单 JUnit 的 MoneyTest 类 package orgacmestore;import junitframeworkTestCase;public class MoneyTest extends TestCase { public void testToString() throws Exception{ Money money = new Money(); Money total = moneympy(); assertEquals($ totaltoString()); } public void testEquals() throws Exception{ Money money = Moneyparse($); Money control = new Money(); assertEquals(control money); } public void testMultiply() throws Exception{ Money money = new Money(); Money total = moneympy(); Money discountAmount = totalmpy(); assertEquals($ discountAmounttoString()); } public void testSubtract() throws Exception{ Money money = new Money(); Money total = moneympy(); Money discountAmount = totalmpy(); Money discountedPrice = totalsub(discountAmount); assertEquals($ discountedPricetoString()); }}
WholeSaleOrder 类 然后定义 WholeSaleOrder 类型这个新对象是应用程序的核心如果 WholeSaleOrder 类型配置了桶数每桶价格和产品类型(季节性或全年性)就可以把它交给 PricingEngine 由后者确定对应的折扣并相应地在 WholeSaleOrder 实例中配置它 WholesaleOrder 类的定义如清单 所示
清单 WholesaleOrder 类 package orgacmestorediscountengine;import orgacmestoreMoney;public class WholesaleOrder { private int numberOfCases; private ProductType productType; private Money pricePerCase; private double discount; public double getDiscount() { return discount; } public void setDiscount(double discount) { thisdiscount = discount; } public Money getCalculatedPrice() { Money totalPrice = thispricePerCasempy(thisnumberOfCases); Money tmpPrice = totalPricempy(thisdiscount); return totalPricesub(tmpPrice); } public Money getDiscountedDifference() { Money totalPrice = thispricePerCasempy(thisnumberOfCases); return totalPricesub(thisgetCalculatedPrice()); } public int getNumberOfCases() { return numberOfCases; } public void setNumberOfCases(int numberOfCases) { thisnumberOfCases = numberOfCases; } public void setProductType(ProductType productType) { thisproductType = productType; } public String getProductType() { return productTypegetName(); } public void setPricePerCase(Money pricePerCase) { thispricePerCase = pricePerCase; } public Money getPricePerCase() { return pricePerCase; }}
从清单 中可以看到一旦在 WholeSaleOrder 实例中设置了折扣就可以通过分别调用 getCalculatedPrice 和 getDiscountedDifference 方法得到折扣价格和节省的钱 更好地测试这些方法(用 JUnit)! 定义了 Money 和 WholesaleOrder 类之后还要编写 JUnit 测试来验证 getCalculatedPrice 和 getDiscountedDifference 方法的功能测试如清单 所示 清单 JUnit 的 WholesaleOrderTest 类 package orgacmestorediscountenginejunit;import junitframeworkTestCase;import orgacmestoreMoney;import orgacmestorediscountengineWholesaleOrder;public class WholesaleOrderTest extends TestCase { /* * Test method for WholesaleOrdergetCalculatedPrice() */ public void testGetCalculatedPrice() { WholesaleOrder order = new WholesaleOrder(); ordersetDiscount(); ordersetNumberOfCases(); ordersetPricePerCase(new Money()); assertEquals($ ordergetCalculatedPrice()toString()); } /* * Test method for WholesaleOrdergetDiscountedDifference() */ public void testGetDiscountedDifference() { WholesaleOrder order = new WholesaleOrder(); ordersetDiscount(); ordersetNumberOfCases(); ordersetPricePerCase(new Money()); assertEquals($ ordergetDiscountedDifference()toString()); }}
PricingEngine 类 PricingEngine 类利用业务规则引擎在这个示例中是 DroolsPricingEngine 极为简单只有一个 public 方法applyDiscount 只要传递进一个 WholeSaleOrder 实例引擎就会要求 Drools 应用折扣如清单 所示
清单 PricingEngine 类 package orgacmestorediscountengine;import orgdroolsRuleBase;import orgdroolsWorkingMemory;import orgdroolsioRuleBaseLoader;public class PricingEngine { private static final String RULES=BusinessRulesdrl; private static RuleBase businessRules; private static void loadRules() throws Exception{ if (businessRules==null){ businessRules = RuleBaseLoader loadFromUrl(PricingEngineclassgetResource(RULES)); } } public static void applyDiscount(WholesaleOrder order) throws Exception{ loadRules(); WorkingMemory workingMemory = businessRulesnewWorkingMemory( ); workingMemoryassertObject(order); workingMemoryfireAllRules(); }}
Drools 的规则 必须在特定于 Drools 的 XML 文件中定义计算折扣的业务规则例如清单 中的代码段就是一个规则如果桶数大于 小于 不是季节性产品则订单有 % 的折扣 清单 BusinessRulesdrl 文件的示例规则 <ruleset name=BusinessRulesSample xmlns= xmlns:java= xmlns:xs=instance xs:schemaLocation= rulesxsd javaxsd><rule name=st Tier Discount> <parameter identifier=order> <class>WholesaleOrder</class> </parameter> <java:condition>ordergetNumberOfCases() > </java:condition> <java:condition>ordergetNumberOfCases() < </java:condition> <java:condition>ordergetProductType() == yearround</java:condition> <java:consequence> ordersetDiscount(); </java:consequence></rule></ruleset>
标记团队测试 有了 PricingEngine 并定义了应用程序规则之后可能渴望验证所有东西都工作正确现在问题就变成用 JUnit 还是 FIT?为什么不两者都用呢?通过 JUnit 测试所有组合是可能的但是要进行许多编码最好是用 JUnit 测试少数几个值迅速地验证代码在工作然后依靠 FIT 的力量运行想要的组合请看看当我这么尝试时发生了什么从清单 开始 清单 JUnit 迅速地验证了代码在工作 package orgacmestorediscountenginejunit;import junitframeworkTestCase;import orgacmestoreMoney;import orgacmestorediscountenginePricingEngine;import orgacmestorediscountengineProductType;import orgacmestorediscountengineWholesaleOrder;public class DiscountEngineTest extends TestCase { public void testCalculateDiscount() throws Exception{ WholesaleOrder order = new WholesaleOrder(); ordersetNumberOfCases(); ordersetPricePerCase(new Money()); ordersetProductType(ProductTypeYEAR_ROUND); PricingEngineapplyDiscount(order); assertEquals( ordergetDiscount() ); } public void testCalculateDiscountNone() throws Exception{ WholesaleOrder order = new WholesaleOrder(); ordersetNumberOfCases(); ordersetPricePerCase(new Money()); ordersetProductType(ProductTypeSEASONAL); PricingEngineapplyDiscount(order); assertEquals( ordergetDiscount() ); }}
还没用 FIT?那就用 FIT! 在 图 的 FIT 表格中有八行数据值可能已经在 清单 中编写了前两行的 JUnit 代码但是真的想编写整个测试吗?编写全部八行的测试或者在客户添加新规则时再添加新的测试需要巨大的耐心好消息就是现在有了更容易的方法不过不是忽略测试 —— 而是用 FIT! FIT 对于测试业务规则或涉及组合值的内容来说非常漂亮更好的是其他人可以完成在表格中定义这些组合的工作但是在为表格创建 FIT 装备之前需要给 Money 类添加一个特殊方法因为需要在 FIT 表格中代表当前货币值(例如像 $ 这样的值)需要一种方法让 FIT 能够认识 Money 的实例做这件事需要两步首先必须把 static parse 方法添加到定制数据类型如清单 所示 清单 添加 parse 方法到 Money 类 public static Money parse(String value){ return new Money(DoubleparseDouble(StringUtilsremove(value $))); }
Money 类的 parse 方法接受一个 String 值(例如FIT 从表格中取出的值)并返回配置正确的 Money 实例在这个示例中$ 字符被删除剩下的 String 被转变成 double 这与 Money 中现有的构造函数匹配
不要忘记向 MoneyTest 类添加一些测试来来验证新添加的 parse 方法按预期要求工作两个新测试如清单 所示 清单 测试 Money 类的 parse 方法 public void testParse() throws Exception{ Money money = Moneyparse($); assertEquals($ moneytoString()); } public void testEquals() throws Exception{ Money money = Moneyparse($); Money control = new Money(); assertEquals(control money);}
编写 FIT 装备 现在可以编写第一个 FIT 装备了实例成员和方法已经在表 和表 中列出所以只需要把事情串在一起添加一两个方法来处理定制类型Money 为了在装备中处理特定类型还需要添加另一个 parse 方法这个方法的签名与前一个略有不同这个方法是个对 Fixture 类进行覆盖的实例方法这个类是 ColumnFixture 的双亲 请注意在清单 中DiscountStructureFIT 的 parse 方法如何比较 class 类型如果存在匹配就调用 Money 的定制 parse 方法否则就调用父类(Fixture )的 parse 版本 清单 中剩下的代码是很简单的对于图 所示的 FIT 表格中的每个数据行都设置值并调用方法然后 FIT 验证结果!例如在 FIT 测试的第一次运行中DiscountStructureFIT 的 listPricePerCase 被设为 $numberOfCases 设为 isSeasonal 为 true然后执行 DiscountStructureFIT 的 discountPrice 返回的值与 $ 比较然后执行 discountAmount 返回的值与 $ 比较 清单 用 FIT 进行的折扣测试 package orgacmestorediscount;import orgacmestoreMoney;import orgacmestorediscountenginePricingEngine;import orgacmestorediscountengineProductType;import orgacmestorediscountengineWholesaleOrder;import fitColumnFixture;public class DiscountStructureFIT extends ColumnFixture { public Money listPricePerCase; public int numberOfCases; public boolean isSeasonal; public Money discountPrice() throws Exception { WholesaleOrder order = thisdoOrderCalculation(); return ordergetCalculatedPrice(); } public Money discountAmount() throws Exception { WholesaleOrder order = thisdoOrderCalculation(); return ordergetDiscountedDifference(); } /** * required by FIT for specific types */ public Object parse(String value Class type) throws Exception { if (type == Moneyclass) { return Moneyparse(value); } else { return superparse(value type); } } private WholesaleOrder doOrderCalculation() throws Exception { WholesaleOrder order = new WholesaleOrder(); ordersetNumberOfCases(numberOfCases); ordersetPricePerCase(listPricePerCase); if (isSeasonal) { ordersetProductType(ProductTypeSEASONAL); } else { ordersetProductType(ProductTypeYEAR_ROUND); } PricingEngineapplyDiscount(order); return order; }}
现在比较 清单 的 JUnit 测试用例和清单 是不是清单 更有效率?当然可以 用 JUnit 编写所有必需的测试但是 FIT 可以让工作容易得多!如果感觉到满意(应当是满意的!)可以运行构建调用 FIT 运行器生成如图 所示的结果 图 这些结果真的很 FIT ! 结束语 FIT 可以帮助企业避免客户和开发人员之间的沟通不畅误解和误读把编写需求的人尽早 带入测试过程是在问题成为开发恶梦的根源之前发现并修补它们的明显途径而且FIT 与现有的技术(比如 JUnit)完全兼容实际上正如本文所示JUnit 和 FIT 互相补充请把今年变成您追逐代码质量 的重要纪年 —— 由于决心采用 FIT! |