测试数据构建器:Object Mother的一个替代者

乔梁 | 2022-04-16

如果你对构造函数和不可变值对象的使用要求比较严格,那么,构造有效的对象可能会遇到一点儿麻烦。

通常在应用程序代码中,这样的对象只在很少的地方被构造出来,而构造函数所需的所有信息都在手边,例如由用户输入、从数据库查询获得,或在消息中接收。另一方面,在测试代码中,无论是测试对象的行为,还是每次都必须提供构造函数的所有参数,才能创建一个有效值作为测试代码的输入。

Invoice invoice = new Invoice(
    new Recipient("Sherlock Holmes",
        new Address("221b Baker Street",
                    "London",
                    new PostCode("NW1", "3RX"))),
    new InvoiceLines(
        new InvoiceLine("Deerstalker Hat",
            new PoundsShillingsPence(0, 3, 10)),
        new InvoiceLine("Tweed Cape",
            new PoundsShillingsPence(0, 4, 12))));

创建所有这些对象的代码会让测试变得凌乱、难以阅读,并用大量与被测试行为无关的不必要信息也被填充到了这个测试代码中。它还使测试变得脆弱:只要对构造函数参数或对象结构进行变更,就会让许多测试用例失败。

Object Mother 模式为了避免这个问题而引入的一种模式。一个 Object Mother 是一个类,它包含一系列( 通常是静态的) 工厂方法,用于创建测试用例中所需要的对象。例如,我们可以为某个测试用例创建一张 Invoice

Invoice invoice = TestInvoices.newDeerstalkerAndCapeInvoice();

一个 Object Mother 将创建新对象的代码移出了测试代码本身,并为正在构造的对象提供清晰的名称,有助于保持测试的可读性。并且,通过将创建所有新对象的代码收集到 Object Mother 中,让测试数据比较容易维护,并允许在测试之间重用。

然而在处理测试数据的多样性方面,Object Mother 模式却显得力不从心。每当程序员需要一些稍微有所不同的测试数据时,他们都会向对象母亲添加一个工厂方法。

Invoice invoice1 = TestInvoices.newDeerstalkerAndCapeAndSwordstickInvoice();
Invoice invoice2 = TestInvoices.newDeerstalkerAndBootsInvoice();
...

随着时间的推移,这个 Object Mother 会变得臃肿、凌乱、难以维护。

要么程序员不做重构,直接添加一个新的工厂方法,而这种情况下, Object Mother 到处都是重复代码;

要么程序员会努力重构,在这种情况下,会让 Object Mather充斥了很多细粒度方法,每个方法只包含一条新语句。

一种解决方案是使用 Builder 模式。对于要在测试中使用的每个类,为它创建一个构建器( Builder ):

  1. 每个构造函数参数都有一个实例变量。
  2. 将其实例变量初始化为常用值或安全值。
  3. 创建一个名为 build 的方法,它使用实例变量的某个值来创建一个新对象。
  4. 有一个「链式」的公有方法,用于重写其实例变量中的值。

例如,Invoice 的构建器( builder )看上去应该是这样的:

public class InvoiceBuilder {
    Recipient recipient = new RecipientBuilder().build();
    InvoiceLines lines = new InvoiceLines(new InvoiceLineBuilder().build());
    PoundsShillingsPence discount = PoundsShillingsPence.ZERO;

    public InvoiceBuilder withRecipient(Recipient recipient) {
        this.recipient = recipient;
        return this;
    }

    public InvoiceBuilder withInvoiceLines(InvoiceLines lines) {
        this.lines = lines;
        return this;
    }

    public InvoiceBuilder withDiscount(PoundsShillingsPence discount) {
        this.discount = discount;
        return this;
    }

    public Invoice build() {
        return new Invoice(recipient, lines, discount);
    }
}

那些对 Invoice 具体值并不在意的测试用例来说,只要一行代码就可以创建一个 Invoice

Invoice anInvoice = new InvoiceBuilder().build();

而那些想要使用某个具体值的测试用例,可以内联定义它们,而无需让那些对测试本身不重要的细节数据充斥在测试代码中:

Invoice invoiceWithNoPostcode = new InvoiceBuilder()
    .withRecipient(new RecipientBuilder()
        .withAddress(new AddressBuilder()
            .withNoPostcode()
            .build())
        .build())
    .build();

我在几个项目中使用了构建器来创建测试数据。我发现,与 Object Mather 相比,这让在测试代码中创建测试数据变得更容易,并且不会让测试用例变得脆弱,也不会产生大量重复。

它让对象结构中与该测试用例无关的内容与该测试本身隔离开了。

例如,对于创建Invoice 对象的这段代码来说,它需要知道一张 Invoice 需要包含 RecipientAddressPostcode,但是,它却不依赖于InvoiceRecipientAddress这些结构对像。你只需要添加构造函数的参数,而不会破坏测试代码。另外,使用现代带有重构功能的 IDE 删除构造函数的参数也很容易。

另一个好处是,测试代码更易于编写和阅读,因为参数已明确标识。比较一下下面的两段代码:

TestAddresses.newAddress(
    "Sherlock Holmes",
    "221b Baker Street",
    "London",
    "NW1");

to:

new AddressBuilder()
    .withName("Sherlock Holmes")
    .withStreet("221b Baker Street")
    .withCity("London")
    .withPostCode("NW1", "3RX")
    .build();

在前一个示例中,根本没有告诉您「London」被意外地作为了第二条街道,而不是城市名称传递。

甚至由于Builder 模式对代码进行了极大的改进,在有些情况下,它甚至也被用于生产代码当中。

使用测试数据构建器的更多方式:

参考链接:

Test Data Builders: an alternative to the Object Mother pattern