xUnit Test Pattern 从很多地方都有听说过,而且网上很早也有相关的学习笔记 《xUnit Test Patterns》学习笔记系列 。看到的评价都是非常不错的,甚至有点感觉是针对自动化测试代码的《设计模式》。最近终于有空阅读,阅读过程中收益不少,因此做一下笔记记录下来。
由于书的内容比较多,因此会以一个一个章节的形式记录下来

章节说明

Refactoring a Test 严格意义上类似于序章,以一个实例的形式演示如何一步一步采用各种本书推荐的模式,重构一个测试用例。

注:一些带有超链接的英文术语是原文自带的,更多是想通过术语或者模式名称表达这种通用性的修改方法。如果不习惯可以略过。

A Complex Test

原始测试用例如下:

public void testAddItemQuantity_severalQuantity_v1(){
      Address billingAddress = null;
      Address shippingAddress = null;
      Customer customer = null;
      Product product = null;
      Invoice invoice = null;
      try {
         //   Set up fixture
         billingAddress = new Address("1222 1st St SW",
               "Calgary", "Alberta", "T2N 2V2","Canada");
         shippingAddress = new Address("1333 1st St SW",
               "Calgary", "Alberta", "T2N 2V2", "Canada");
         customer = new Customer(99, "John", "Doe",
                                 new BigDecimal("30"),
                                 billingAddress,
                                 shippingAddress);
         product = new Product(88, "SomeWidget",
                               new BigDecimal("19.99"));
         invoice = new Invoice(customer);
         // Exercise SUT
         invoice.addItemQuantity(product, 5);
         // Verify outcome
         List lineItems = invoice.getLineItems();
         if (lineItems.size() == 1) {
            LineItem actItem = (LineItem) lineItems.get(0);
            assertEquals("inv", invoice, actItem.getInv());
            assertEquals("prod", product, actItem.getProd());
            assertEquals("quant", 5, actItem.getQuantity());
            assertEquals("discount", new BigDecimal("30"),
                           actItem.getPercentDiscount());
            assertEquals("unit price",new BigDecimal("19.99"),
                              actItem.getUnitPrice());
            assertEquals("extended", new BigDecimal("69.96"),
                           actItem.getExtendedPrice());
         } else {
            assertTrue("Invoice should have 1 item", false);
}
} finally {
         // Teardown
         deleteObject(invoice);
         deleteObject(product);
         deleteObject(customer);
         deleteObject(billingAddress);
         deleteObject(shippingAddress);
} }

这是一个 Obscure Test(令人费解的测试),存在以下问题:

  1. 用例很长
  2. 无法一眼看懂这个用例到底在测试什么,得慢慢看,逐步理解

重构能达到什么程度?大家先瞄一眼重构后的用例:

public void testAddItemQuantity_severalQuantity_v13(){

      final int QUANTITY = 5;
      final BigDecimal UNIT_PRICE = new BigDecimal("19.99");
      final BigDecimal CUST_DISCOUNT_PC = new BigDecimal("30");

      //   Set up fixture
      Customer customer = createACustomer(CUST_DISCOUNT_PC);
      Product product = createAProduct( UNIT_PRICE);
      Invoice invoice = createInvoice(customer);

      // Exercise SUT
      invoice.addItemQuantity(product, QUANTITY);

      // Verify outcome
      final BigDecimal BASE_PRICE =
         UNIT_PRICE.multiply(new BigDecimal(QUANTITY));
      final BigDecimal EXTENDED_PRICE =
         BASE_PRICE.subtract(BASE_PRICE.multiply(
               CUST_DISCOUNT_PC.movePointLeft(2)));
      LineItem expected =
         createLineItem(QUANTITY, CUST_DISCOUNT_PC,
                        EXTENDED_PRICE, product, invoice);
      assertContainsExactlyOneLineItem(invoice, expected);
   }

比重构前好多了吧,而且即使是不大懂代码的人也能基本看懂用例在干嘛。那么,到底要怎么一步一步修改用例达到这个效果?接收后面各种代码的洗礼吧,骚年!

Cleaning Up the Test

接下来从用例的每个部分逐个逐个优化

Cleaning Up the Verification Logic

优化校验的逻辑。我们先把校验逻辑单独拿出来看:

         List lineItems = invoice.getLineItems();
         if (lineItems.size() == 1) {
            LineItem actItem = (LineItem) lineItems.get(0);
            assertEquals("inv", invoice, actItem.getInv());
            assertEquals("prod", product, actItem.getProd());
            assertEquals("quant", 5, actItem.getQuantity());
            assertEquals("discount", new BigDecimal("30"),
                           actItem.getPercentDiscount());
            assertEquals("unit price",new BigDecimal("19.99"),
                              actItem.getUnitPrice());
            assertEquals("extended", new BigDecimal("69.96"),
                           actItem.getExtendedPrice());
         } else {
            assertTrue("Invoice should have 1 item", false);
}

看到末尾的 asserTrue 结合 false 是不是很诡异?这里错误地使用了 Stated Outcome Assertion(校验状态的断言), 实际应该是想只要执行到这里都要抛出一个断言失败,用 Single Outcome Assertion(单一输出的断言),即直接改成 fail 函数,语义更清晰。

         List lineItems = invoice.getLineItems();
         if (lineItems.size() == 1) {
            LineItem actItem = (LineItem) lineItems.get(0);
            assertEquals("inv", invoice, actItem.getInv());
            assertEquals("prod", product, actItem.getProd());
            assertEquals("quant", 5, actItem.getQuantity());
            assertEquals("discount", new BigDecimal("30"),
                           actItem.getPercentDiscount());
            assertEquals("unit price",new BigDecimal("19.99"),
                              actItem.getUnitPrice());
            assertEquals("extended", new BigDecimal("69.96"),
                           actItem.getExtendedPrice());
         } else {
            // 改为 fail 函数
            fail("Invoice should have exactly one line item");
}

接下来看下其他断言。这里断言都是硬编码了预期结果,不够直观。把预期结果抽离出来,放到一个 Expected Object 里面,可读性更强

         List lineItems = invoice.getLineItems();
         if (lineItems.size() == 1) {
            // 添加 expected 对象,表示预期对象
            LineItem expected =
               new LineItem(invoice, product, 5,
                            new BigDecimal("30"),
                            new BigDecimal("69.96"));
            LineItem actItem = (LineItem) lineItems.get(0);
            // 所有断言都是校验预期对象和实际对象是否一致
            assertEquals("invoice", expected.getInv(),
                                    actItem.getInv());
            assertEquals("product", expected.getProd(),
                                    actItem.getProd());
            assertEquals("quantity",expected.getQuantity(),
                                    actItem.getQuantity());
            assertEquals("discount",
                         expected.getPercentDiscount(),
                         actItem.getPercentDiscount());
            assertEquals("unit pr", new BigDecimal("19.99"),
                                    actItem.getUnitPrice());
            assertEquals("extend pr",new BigDecimal("69.96"),
                                     actItem.getExtendedPrice());
         } else {
            fail("Invoice should have exactly one line item");
}

这里写这么多有点长,不如直接把预期对象和实际对象做一次 equal 断言?

List lineItems = invoice.getLineItems();
if (lineItems.size() == 1) {
   LineItem expected =
      new LineItem(invoice, product,5,
                   new BigDecimal("30"),
                   new BigDecimal("69.96"));
   LineItem actItem = (LineItem) lineItems.get(0);
   assertEquals("invoice", expected, actItem);
} else {
   fail("Invoice should have exactly one line item");
}

好了,再看回来。用例里有个 if 并不是什么好事,使用 Conditional Test Logic 意味着我们还需要判断实际执行用的到底是 if 还是 else 分支。幸运的是,有一个 Guard Assertion 模式刚好就是设计来处理这样的问题的。

List lineItems = invoice.getLineItems();
assertEquals("number of items", 1,lineItems.size());
LineItem expected =
   new LineItem(invoice, product, 5,
                new BigDecimal("30"),
                new BigDecimal("69.96"));
LineItem actItem = (LineItem) lineItems.get(0);
assertEquals("invoice", expected, actItem);

好了,到现在,我们已经把 12 行代码通过重构优化到 5 行了,还有优化空间吗?有的。回想一下,我们要验证的是什么?我们要验证的是这个 List 中唯一的对象和预期相符。这个其实可以做成 Custom Assertion(自定义断言)。

LineItem expected =
   new LineItem(invoice, product, 5,
                new BigDecimal("30"),
                new BigDecimal("69.96"));
// 两部分断言封装成一个函数
assertContainsExactlyOneLineItem(invoice, expected);

很不错了,这段代码即使不加注释,任何人都能快速看出想校验的是 invoice 里有且只有一个和 expected 一致的对象,基本足够了。现在回头看看完整的用例。

public void testAddItemQuantity_severalQuantity_v6(){
      Address billingAddress = null;
      Address shippingAddress = null;
      Customer customer = null;
      Product product = null;
      Invoice invoice = null;
      try {
         //   Set up fixture
         billingAddress = new Address("1222 1st St SW",
                "Calgary", "Alberta", "T2N 2V2", "Canada");
         shippingAddress = new Address("1333 1st St SW",
                "Calgary", "Alberta", "T2N 2V2", "Canada");
         customer = new Customer(99, "John", "Doe",
                                 new BigDecimal("30"),
                                 billingAddress,
                                 shippingAddress);
         product = new Product(88, "SomeWidget",
                               new BigDecimal("19.99"));
         invoice = new Invoice(customer);
         // Exercise SUT
         invoice.addItemQuantity(product, 5);
         // Verify outcome
         LineItem expected =
            new LineItem(invoice, product, 5,
                         new BigDecimal("30"),
                         new BigDecimal("69.96"));
         assertContainsExactlyOneLineItem(invoice, expected);
      } finally {
         // Teardown
         deleteObject(invoice);
         deleteObject(product);
         deleteObject(customer);
         deleteObject(billingAddress);
         deleteObject(shippingAddress);
} }

简单小结下我们的优化过程:

最终 12 行代码精简成 3 行(后面会讲最后一个优化项封装带来的额外代码还有其他好处,所以不算在总代码行数中),并且可读性大大提高。

很好,校验的部分差不多了,看下其它可优化的地方吧。

Cleaning Up the Fixture Teardown Logic

接下来,我们看下 finally 这部分代码。

} finally {
         // Teardown
         deleteObject(invoice);
         deleteObject(product);
         deleteObject(customer);
         deleteObject(billingAddress);
         deleteObject(shippingAddress);
}

大部分现代语言里都有 try/finally 的语法,用于确保无论有没有异常,一段代码都会被执行。在测试框架中,框架会捕获用例抛出的 assert 异常并做进一步处理,所以我们要在抛出异常前完成收尾 (tearDown) 工作,也避免了捕获异常后需要进行处理然后再重抛异常这种不好的写法。

但仔细看下,这段收尾工作做得并不好。如果 deleteObject 方法抛出异常怎么办?难不成,我们要写成这样来确保所有方法都被执行?

} finally {
         //      Teardown
         try {
            deleteObject(invoice);
         } finally {
            try {
               deleteObject(product);
            } finally {
               try {
                  deleteObject(customer);
               } finally {
                  try {
                     deleteObject(billingAddress);
                  } finally {
                     deleteObject(shippingAddress);
                  }
                }
         }
}

上面这样的代码无论是写还是读都相当困难,估计真写了一定会被人打死。。。

当然,我们也可以把这段代码从 Test Method(测试方法)移动到 tearDown 方法里执行,也可以借此摆脱 try/finally 这种写法。但这解决不了根本问题:每个方法我们都得对应写一遍 tearDown(每个方法创建的对象不同,所以要销毁的对象也不同)。

我们也可以用 Shared Fixture(可以理解为多个用例共享的 fixture 。像 setUp,tearDown 这类在用例执行前后额外执行的方法一般被称为 Fixture )来避免在测试方法里创建这些要销毁的对象,但这么做会产生一些坏味道(可以理解为虽然能用,但不好用或不好看的代码),像由 Shared Fixture 多用例共用特性带来的 Unrepeatable TestInteracting Tests 。还会产生另一个问题,谁都不知道由 Shared Fixture 创建的对象从哪里来,要到哪里去,变成 Mystery Guests(神秘的客人,即谁都不知道这个对象后面会用来干嘛。)

最好的解决方法是用 Fresh Fixture ,同时使 tearDown 具有通用性,避免为每一个用例单独写一次 tearDown 。我们可以用存在内存里的 fixture ,这样可以被 gc 自动回收,但不适用于持久化的对象(例如把内容存到了数据库里面)。在这种情况下,扩展 Test Automation Framework(自动化测试框架),加入针对这类场景的方法会是最优解。

对于对象的销毁,我们可以在框架里面增加一个注册机制,只需要在创建对象时用把对象注册一下,那么框架会自动帮我们在 tearDown 里面把这些对象全部销毁。

首先,我们把我们创建的对象用 registerTestObject 全部注册一下:

//   Set up fixture
billingAddress = new Address("1222 1st St SW", "Calgary",
                  "Alberta", "T2N 2V2", "Canada");
// 注册
registerTestObject(billingAddress);

shippingAddress = new Address("1333 1st St SW", "Calgary",
                   "Alberta","T2N 2V2", "Canada");
registerTestObject(shippingAddress);

customer = new Customer(99, "John", "Doe",
                        new BigDecimal("30"),
                        billingAddress,
                        shippingAddress);
registerTestObject(shippingAddress);

product = new Product(88, "SomeWidget",
                      new BigDecimal("19.99"));
registerTestObject(shippingAddress);

invoice = new Invoice(customer);
registerTestObject(shippingAddress);

注册方法的实现就是把这些注册的对象存到一个 List 中:

List testObjects;
protected void setUp() throws Exception {
   super.setUp();
   testObjects = new ArrayList();
}
protected void registerTestObject(Object testObject) {
   testObjects.add(testObject);
}

在 tearDown 里,我们遍历这个 List ,用 delectObject 删除每个对象即可:


   public void tearDown() {
      Iterator i = testObjects.iterator();
      while (i.hasNext()) {
         try {
            deleteObject(i.next());
         } catch (RuntimeException e) {
            // 啥都不用干,我们只是为了确保我们会继续遍历 List 的下一个对象
         }
       }
}

译者注:这里的 deleteObject 需要判断对象类型进行不同的 delete 操作,才能确保前面考虑的持久化操作也能被恢复回来。具体实现方式也许可以通过在 registerTestObject 同时提供该对象的销毁方法来实现。

最终,回来看看我们的用例改成什么样了:


public void testAddItemQuantity_severalQuantity_v8(){
      Address billingAddress = null;
      Address shippingAddress = null;
      Customer customer = null;
      Product product = null;
      Invoice invoice = null;
      //   Set up fixture
      billingAddress = new Address("1222 1st St SW", "Calgary",
                        "Alberta", "T2N 2V2", "Canada");
      registerTestObject(billingAddress);
      shippingAddress = new Address("1333 1st St SW", "Calgary",
                         "Alberta","T2N 2V2", "Canada");
      registerTestObject(shippingAddress);
      customer = new Customer(99, "John", "Doe",
                              new BigDecimal("30"),
                              billingAddress,
                              shippingAddress);
      registerTestObject(shippingAddress);
      product = new Product(88, "SomeWidget",
                            new BigDecimal("19.99"));
      registerTestObject(shippingAddress);
      invoice = new Invoice(customer);
      registerTestObject(shippingAddress);
      // Exercise SUT
      invoice.addItemQuantity(product, 5);
      // Verify outcome
      LineItem expected =
         new LineItem(invoice, product, 5,
                      new BigDecimal("30"),
                      new BigDecimal("69.96"));
      assertContainsExactlyOneLineItem(invoice, expected);
}

除了每个对象都有个 registerTestObject 方法显得有点重复,我们的用例已经简化了不少。我们去掉了 try/finally 代码块,也成功修复了 deleteObject 出错会导致对象没有全部销毁的隐藏 bug 。还能再优化下吗?能!

前面我们为了确保 finally 里面 delectObject 尽量不出错,给每个变量的初始化值都是 null ,确保每个变量都存在。既然现在不用顾虑这个问题了,那么我们可以把这个 null 干掉了:

public void testAddItemQuantity_severalQuantity_v9(){
    //   Set up fixture
    Address billingAddress = new Address("1222 1st St SW",
                "Calgary", "Alberta", "T2N 2V2", "Canada");
    registerTestObject(billingAddress);
    Address shippingAddress = new Address("1333 1st St SW",
                "Calgary", "Alberta", "T2N 2V2", "Canada");
    registerTestObject(shippingAddress);
    Customer customer = new Customer(99, "John", "Doe",
                                    new BigDecimal("30"),
                                    billingAddress,
                                    shippingAddress);
    registerTestObject(shippingAddress);
    Product product = new Product(88, "SomeWidget",
                                new BigDecimal("19.99"));
    registerTestObject(shippingAddress);
    Invoice invoice = new Invoice(customer);
    registerTestObject(shippingAddress);
    // Exercise SUT
    invoice.addItemQuantity(product, 5);
    // Verify outcome
    LineItem expected =
    new LineItem(invoice, product, 5,
                    new BigDecimal("30"),
                    new BigDecimal("69.95"));
    assertContainsExactlyOneLineItem(invoice, expected);
}

很好,从干掉 finally 的角度做优化,最终也起到了不错的优化效果。简单小结下:

虽然从代码行数角度,优化效果不如前面的校验逻辑明显。但从可读性和用例编写便利性的角度,得到了很大的提高。

Cleaning Up the Fixture Setup

好了,断言校验和 tearDown 我们都优化过了,该来到最后的 setUp 了。

按照惯例,先看看 setUp 的代码

//   Set up fixture
   Address billingAddress = new Address("1222 1st St SW",
               "Calgary", "Alberta", "T2N 2V2", "Canada");
   registerTestObject(billingAddress);
   Address shippingAddress = new Address("1333 1st St SW",
               "Calgary", "Alberta", "T2N 2V2", "Canada");
   registerTestObject(shippingAddress);
   Customer customer = new Customer(99, "John", "Doe",
                                   new BigDecimal("30"),
                                   billingAddress,
                                   shippingAddress);
   registerTestObject(shippingAddress);
   Product product = new Product(88, "SomeWidget",
                               new BigDecimal("19.99"));
   registerTestObject(shippingAddress);
   Invoice invoice = new Invoice(customer);
   registerTestObject(shippingAddress);

每个对象都要写个 registerTestObject 是不是很烦?很好,我们把对象的构造方法和注册方法封装到 Creation Method(创建方法)里面吧。

//   Set up fixture
Address billingAddress =
   createAddress( "1222 1st St SW", "Calgary", "Alberta",
                  "T2N 2V2", "Canada");
Address shippingAddress =
   createAddress( "1333 1st St SW", "Calgary", "Alberta",
                  "T2N 2V2", "Canada");
Customer customer =
   createCustomer( 99, "John", "Doe", new BigDecimal("30"),
                   billingAddress, shippingAddress);
Product product =
   createProduct( 88,"SomeWidget",new BigDecimal("19.99"));
Invoice invoice = createInvoice(customer);

这样顺眼多了。而且还带来了一个好处:用例中对象的创建方法与被测系统的 API 解耦。一旦被测系统中这个对象的构造方法做了修改,我们只需要统一改 Creation Method 即可。

改完后,这里还是有不少问题。最突出的问题就是每个构造方法参数太多,我们很难看出这些对象和本次测试的直接关系。address 的具体地点和这次测试有关吗?这个用例到底要检查这些构造函数里面这么多参数中的哪些?

此外,这里用了 Hard-Coded Test Data ,测试数据都是固定的。如果这些数据都是在数据库中创建且要求唯一时,就会造成 Unrepeatable TestInteracting Test 甚至 Test Run War(用例乱序执行时相互打架)。

解决方法,还是在 Creation Method 里做文章。这些烦人、且值是啥和我们这次用例无关的数据,我们都可以放在 Creation Method 里自动生成。经过调整,现在的代码长这样

     final int QUANTITY = 5;
      //   Set up fixture
      Address billingAddress = createAnAddress();
      Address shippingAddress = createAnAddress();
      Customer customer = createACustomer(new BigDecimal("30"),
               billingAddress, shippingAddress);
      Product product = createAProduct(new BigDecimal("19.99"));
      Invoice invoice = createInvoice(customer);

// 其中一个 Creation Method 的实现示例
private Product createAProduct(BigDecimal unitPrice) {
      BigDecimal uniqueId = getUniqueNumber();
      String uniqueString = uniqueId.toString();
      return new Product(uniqueId.toBigInteger().intValue(),
                                      uniqueString, unitPrice);
}

这种模式我们细分为 Anonymous Creation Method(匿名的创建方法,表示我们只需要一个特定类型的对象,不关心对象的所有实际值)。当然,如果有个别参数和测试用例息息相关,我们可以将其传入,就像 createAProduct 那样做。

现在好多了,至少不用猜构造方法里那些参数有啥意义了。但还没完。仔细想下,address 和 customer 和我们本次测试有什么关系不?完全没有!所以,我们可以进一步封装,把他们的存在隐藏到 createACustomer 方法中:

//  Set up fixture
Customer cust = createACustomer(new BigDecimal("30"));
Product prod = createAProduct(new BigDecimal("19.99"));
Invoice invoice = createInvoice(cust);

很好,这样就很清晰了。和测试相关的只有 customer,product 及 invoice 三个对象,以及 production 的单位价格、custom 的折扣值。其它无关的对象我们压根不知道它们的存在,所以也不会扰乱视线了。

再来看看这三行代码,什么地方是你看不明白的?对了,就是 new BigDecimal("30")new BigDecimal("19.99") ,我们看不懂这两个数字代表什么。没关系,给这些值加个名字就好了:

public void testAddItemQuantity_severalQuantity_v13(){

      final int QUANTITY = 5;
      final BigDecimal UNIT_PRICE = new BigDecimal("19.99");
      final BigDecimal CUST_DISCOUNT_PC = new BigDecimal("30");

      //   Set up fixture
      Customer customer = createACustomer(CUST_DISCOUNT_PC);
      Product product = createAProduct( UNIT_PRICE);
      Invoice invoice = createInvoice(customer);

      // Exercise SUT
      invoice.addItemQuantity(product, QUANTITY);

      // Verify outcome
      final BigDecimal BASE_PRICE =
         UNIT_PRICE.multiply(new BigDecimal(QUANTITY));
      final BigDecimal EXTENDED_PRICE =
         BASE_PRICE.subtract(BASE_PRICE.multiply(
               CUST_DISCOUNT_PC.movePointLeft(2)));
      LineItem expected =
         new LineItem(invoice, product, QUANTITY,
                      CUST_DISCOUNT_PC, EXTENDED_PRICE);
      assertContainsExactlyOneLineItem(invoice, expected);
   }

可以留意到,把这些值给了变量后,通过变量名我们可以非常直观的看到同一个值在哪些地方用到,以及这些值代表什么。为了统一,校验断言部分我们也做了相同的操作,通过计算公式,我们也说明了 EXTENDED_PRICE 如何得来。

最后,小结一下:

代码行数又大幅度减少了,是不是很棒?

The Cleaned-Up Test

最后,来对比下重构前后的测试用例把。

重构前:

public void testAddItemQuantity_severalQuantity_v1(){
      Address billingAddress = null;
      Address shippingAddress = null;
      Customer customer = null;
      Product product = null;
      Invoice invoice = null;
      try {
         //   Set up fixture
         billingAddress = new Address("1222 1st St SW",
               "Calgary", "Alberta", "T2N 2V2","Canada");
         shippingAddress = new Address("1333 1st St SW",
               "Calgary", "Alberta", "T2N 2V2", "Canada");
         customer = new Customer(99, "John", "Doe",
                                 new BigDecimal("30"),
                                 billingAddress,
                                 shippingAddress);
         product = new Product(88, "SomeWidget",
                               new BigDecimal("19.99"));
         invoice = new Invoice(customer);
         // Exercise SUT
         invoice.addItemQuantity(product, 5);
         // Verify outcome
         List lineItems = invoice.getLineItems();
         if (lineItems.size() == 1) {
            LineItem actItem = (LineItem) lineItems.get(0);
            assertEquals("inv", invoice, actItem.getInv());
            assertEquals("prod", product, actItem.getProd());
            assertEquals("quant", 5, actItem.getQuantity());
            assertEquals("discount", new BigDecimal("30"),
                           actItem.getPercentDiscount());
            assertEquals("unit price",new BigDecimal("19.99"),
                              actItem.getUnitPrice());
            assertEquals("extended", new BigDecimal("69.96"),
                           actItem.getExtendedPrice());
         } else {
            assertTrue("Invoice should have 1 item", false);
}
} finally {
         // Teardown
         deleteObject(invoice);
         deleteObject(product);
         deleteObject(customer);
         deleteObject(billingAddress);
         deleteObject(shippingAddress);
} }

重构后

public void testAddItemQuantity_severalQuantity_v13(){

      final int QUANTITY = 5;
      final BigDecimal UNIT_PRICE = new BigDecimal("19.99");
      final BigDecimal CUST_DISCOUNT_PC = new BigDecimal("30");

      //   Set up fixture
      Customer customer = createACustomer(CUST_DISCOUNT_PC);
      Product product = createAProduct( UNIT_PRICE);
      Invoice invoice = createInvoice(customer);

      // Exercise SUT
      invoice.addItemQuantity(product, QUANTITY);

      // Verify outcome
      final BigDecimal BASE_PRICE =
         UNIT_PRICE.multiply(new BigDecimal(QUANTITY));
      final BigDecimal EXTENDED_PRICE =
         BASE_PRICE.subtract(BASE_PRICE.multiply(
               CUST_DISCOUNT_PC.movePointLeft(2)));
      LineItem expected =
         createLineItem(QUANTITY, CUST_DISCOUNT_PC,
                        EXTENDED_PRICE, product, invoice);
      assertContainsExactlyOneLineItem(invoice, expected);
   }

明显看出,重构后逻辑清晰了,读起来也畅快,也达到了 Tests as Documentation(测试即文档)的效果。现在再来回答这个测试到底是干嘛的问题,应该轻松多了吧?

术语解释

这里只说明文中用到的术语列表。更详细的的术语列表及说明可以看官方网站:http://xunitpatterns.com/XUnit%20Basics.html

Obscure Test

令人费解的测试。详情请看 http://xunitpatterns.com/Obscure%20Test.html

Stated Outcome Assertion

根据状态输出的断言。如 asserTrue ,根据输入值是否为 true 会有不同的输出。详情请看 http://xunitpatterns.com/Assertion%20Method.html

Single Outcome Assertion

单一输出的断言。如 fail ,固定抛出失败的异常。详情请看 http://xunitpatterns.com/Assertion%20Method.html

Expected Object

预期对象。即把所有预期结果放到一个带有 expected 名称的对象或变量中,断言时更方便。

Conditional Test Logic

带有条件判断的测试逻辑,常见于新手写的测试用例(if 成功,xxx,else xxx)。详情请看 http://xunitpatterns.com/Conditional%20Test%20Logic.html

Guard Assertion

告警断言,断言失败出现的是自定义的告警信息,而非默认的 xx 条件不成立。详情请看 http://xunitpatterns.com/Guard%20Assertion.html

Custom Assertion

自定义断言,为了用例中的特殊场景自行封装的断言。详情请看 http://xunitpatterns.com/Assertion%20Method.html

Test Method

测试方法,即去掉了 setUp 和 tearDown 部分的测试用例部分代码。详情请看 http://xunitpatterns.com/Test%20Method.html

Shared Fixture

共享 Fixture 。一般是多个用例共用的 setUp ,tearDown 等。详情请看 http://xunitpatterns.com/Shared%20Fixture.html

Creation Method

创建方法。相比构建方法,创建方法会做进一步的封装,而且创建方法属于测试代码的一部分,而不是像构建方法那样属于被测系统的一部分,解耦性更强。详情请看 http://xunitpatterns.com/Creation%20Method.html

Hard-Coded Test Data

硬编码测试数据,引起 Obscure Test(令人费解的测试)的原因之一。详情请看 http://xunitpatterns.com/Obscure%20Test.html

Test Run War

测试执行战争,当多个人以随机的顺序运行这个用例时,这个用例有可能因此失败。属于 Erratic Test(不稳定的测试)的现象之一。详情请看 http://xunitpatterns.com/Erratic%20Test.html

Anonymous Creation Method

匿名创建方法,创建方法的一个子类。详情请看:http://xunitpatterns.com/Creation%20Method.html

写在最后

还要两个小节 Writing More Tests 和 Further Compaction ,主要说明这次重构抽离的方法不仅能让这个用例得益,还能让其他测试这个模块的用例都得益,所以是值得投入的。更详细的内容后面另外开一篇文章补上吧。

关于术语,目前还没完全梳理完毕,所以只把和本文相关的术语放上来。后续梳理好后再全部补充上来。

PS:做个小调查,这个重构里展现的不好的写法大家都有中枪不?


↙↙↙阅读原文可查看相关链接,并与作者交流