将断言变成领域特定的语言

断言是我们单元测试的重要组成部分。 但是,忽略它们是如此容易。 令人遗憾的是,如果我们忽略断言的重要性,则测试的断言部分会变得冗长而混乱。 可悲的是,我见过(和写过)的大多数测试都遇到了这个问题。

这篇博客文章描述了如何摆脱混乱的断言。 我们学习使用领域专家理解的语言来编写断言。

测试了什么?

让我们开始快速看一下测试过的类。

Person类是包含单个人的信息的类。 它有四个字段( idemailfirstNamelastName ),我们可以使用构建器模式创建新的Person对象。

Person类的源代码如下所示:

public class Person {

    private Long id;

    private String email;

    private String firstName;

    private String lastName;

    private Person() {

    }

    public static PersonBuilder getBuilder(String firstName, String lastName) {
        return new PersonBuilder(firstName, lastName);
    }

    //Getters are omitted for the sake of clarity
   
    public static class PersonBuilder {

        Person build;

        private PersonBuilder(String firstName, String lastName) {
            build = new Person();
            build.firstName = firstName;
            build.lastName = lastName;
        }

        public PersonBuilder email(String email) {
            build.email = email;
            return this;
        }

        public PersonBuilder id(Long id) {
            build.id = id;
            return this;
        }

        public Person build() {
            return build;
        }
    }
}

何必呢?

为了了解使用标准JUnit断言有什么问题,我们必须分析使用它们的单元测试。 我们可以按照以下步骤编写一个单元测试,以确保新的Person对象的构造工作正常:

  1. 通过使用builder类创建一个新的Person对象。
  2. 通过使用Assert类的assertEquals()方法编写断言。

我们的单元测试的源代码如下所示:

import org.junit.Test;

import static org.junit.Assert.assertEquals;

public class PersonTest {

    @Test
    public void build_JUnitAssertions() {
        Person person = Person.getBuilder("Foo", "Bar")
                .email("foo.bar@email.com")
                .id(1L)
                .build();

        assertEquals(1L, person.getId().longValue());
        assertEquals("Foo", person.getFirstName());
        assertEquals("Bar", person.getLastName());
        assertEquals("foo.bar@email.com", person.getEmail());
    }
}

这个单元测试简短而干净,但是标准的JUnit断言有两个大问题:

  • 当断言数量增加时,测试方法的长度也会增加。 这看起来似乎很明显,但是较大的断言部分使测试更难理解。 很难理解我们希望通过此测试实现什么。
  • 标准的JUnit断言使用错误的语言。 标准的JUnit断言使用“技术”语言。 这意味着领域专家无法理解我们的测试(我们也无法理解)。
我们可以做得更好。 好了很多。

紧急救援

FEST-Assert是一个库,使我们可以在测试中编写流畅的断言。 通过执行以下步骤,我们可以使用FEST-Assert 1.4创建一个简单的断言:

  1. 调用Assertions类的静态assertThat()方法,并将实际值作为方法参数传递。 此方法返回一个断言对象。 断言对象是扩展GenericAssert类的类的实例。
  2. 通过使用断言对象的方法来指定断言。 我们可以使用的方法取决于返回对象的类型( Assertions类的assertThat()方法是重载方法,返回对象的类型取决于method参数的类型)。

当我们使用FEST-Assert重写单元测试时,其源代码如下:

import org.junit.Test;

import static org.fest.assertions.Assertions.assertThat;

public class PersonTest {

    @Test
    public void build_FESTAssert()  {
        Person person = Person.getBuilder("Foo", "Bar")
                .email("foo.bar@email.com")
                .id(1L)
                .build();

        assertThat(person.getId()).isEqualTo(1L);
        assertThat(person.getFirstName()).isEqualTo("Foo");
        assertThat(person.getLastName()).isEqualTo("Bar");
        assertThat(person.getEmail()).isEqualTo("foo.bar@email.com");
    }
}

这比使用标准JUnit断言的测试更具可读性。 然而, 它也面临着同样的问题

另一个问题是断言失败时显示的默认消息不是很可读。 例如,如果用户的名字是“ Bar”,则会显示以下消息:

expected:<'[Foo]'> but was:<'[Bar]'>

我们可以通过在声明中添加自定义消息来解决此问题。 让我们看看这是如何完成的。

为FEST声明断言指定自定义消息

通过执行以下步骤,我们可以编写一个包含自定义错误消息的断言:

  1. 通过使用Stringformat()方法创建错误消息。
  2. 调用Assertions类的静态assertThat()方法,并将实际值作为方法参数传递。 此方法返回一个断言对象。 断言对象是扩展GenericAssert类的类的实例。
  3. 调用GenericAssert类的overridingErrorMessage ()方法,并将创建的错误消息作为方法参数传递。
  4. 通过使用断言对象的方法来指定断言。 我们可以使用的方法取决于返回对象的类型( Assertions类的assertThat()方法是重载方法,返回对象的类型取决于method参数的类型)。

我们的单元测试的源代码如下所示:

import org.junit.Test;

import static org.fest.assertions.Assertions.assertThat;

public class PersonTest {

    @Test
    public void build_FESTAssert_CustomMessages() {
        Person person = Person.getBuilder("Foo", "Bar")
                .email("foo.bar@email.com")
                .id(1L)
                .build();

        String idMessage = String.format("Expected id to be <%d> but was <%d>", 1L, person.getId());
        assertThat(person.getId())
                .overridingErrorMessage(idMessage)
                .isEqualTo(1L);

        String firstNameMessage = String.format("Expected firstName to be <%s> but was <%s>", "Foo", person.getFirstName());
        assertThat(person.getFirstName())
                .overridingErrorMessage(firstNameMessage)
                .isEqualTo("Foo");

        String lastNameMessage = String.format("Expected lastName to be <%s> but was <%s>", "Bar", person.getLastName());
        assertThat(person.getLastName())
                .overridingErrorMessage(lastNameMessage)
                .isEqualTo("Bar");

        String emailMessage = String.format("Expected email to be <%s> but was <%s>", "foo.bar@email.com", person.getEmail());
        assertThat(person.getEmail())
                .overridingErrorMessage(emailMessage)
                .isEqualTo("foo.bar@email.com");
    }
}

如果用户的名字是“ Bar”,则会显示以下消息:

Expected firstName to be <Foo> but was <Bar>

我们修复了一个问题,但修复导致了另一个问题:

该测试不可读! 比我们以前的测试差很多!

但是,所有的希望并没有失去。 让我们找出如何使用在本博文中学到的信息来创建特定领域的语言。

创建特定领域的语言

Wikipedia定义术语领域特定语言如下:

域特定语言(DSL)是专用于特定应用程序域的计算机语言。

当我们遵循此定义时,对于域特定语言我们将获得以下要求:

  1. 它必须说出领域专家理解的语言。 例如,一个人的名字不等于“ Foo”。 一个人的名字叫“ Foo”。
  2. 断言必须具有使用域特定语言的自定义错误消息。
  3. 它必须具有流畅的API。 换句话说,必须有可能链接断言。

注意:如果要获取有关使用Java实现特定于域的语言的更多信息,请阅读标题为《使用Java 内部特定于域的语言的方法》Java Fluent API Designer Crash Course的文章

我们可以通过创建自定义断言来为单元测试创建特定领域的语言。 我们可以按照以下步骤进行操作:

  1. 创建一个PersonAssert类。
  2. 扩展GenericAssert类,并提供以下类型参数:
    1. 第一个type参数指定自定义断言类的类型。 将此类型参数的值设置为PersonAssert
    2. 第二个type参数指定实际值的类型。 将此类型参数的值设置为Person
  3. 创建一个将Person对象作为构造函数参数的构造函数。 通过调用GenericAssert类的构造函数并传递以下对象作为构造函数参数来实现此构造函数:
    1. 第一个构造函数参数指定自定义断言的类。 将此构造函数参数的值设置为PersonAssert.class
    2. 第二个构造函数参数是实际值。 将作为构造函数参数提供的Person对象传递给超类的构造函数。
  4. 向创建的类中添加一个assertThat()方法。 此方法将Person对象作为方法参数,并返回PersonAssert对象。 通过执行以下步骤来实现此方法:
    1. 创建一个新的PersonAssert对象,并将Person对象作为构造函数参数传递。
    2. 返回创建的PersonAssert对象。
  5. 创建用于针对实际的Person对象编写断言的方法。 我们需要为emailfirstNameidlastName字段创建断言方法。 我们可以按照以下步骤实现每种方法:
    1. 通过调用GenericAssertisNotNull()方法,确保实际的Person对象不为null。
    2. 通过使用String类的format()方法创建自定义错误消息。
    3. 确保Person对象的字段的值等于期望值。 我们可以按照以下步骤进行操作:
      1. 调用Assertions类的assertThat()方法,并提供实际的字段值作为方法参数。
      2. 通过调用GenericAssert类的overridingErrorMessage ()方法来覆盖默认错误消息。 将自定义错误消息作为方法参数传递。
      3. 确保实际属性值等于预期值。 我们可以通过调用GenericAssertisEqualTo()方法并将期望值作为方法参数传递来实现。
    4. 返回对PersonAssert对象的引用。 这确保了我们可以在单元测试中链接断言。

PersonAssert类的源代码如下所示:

package example;

import org.fest.assertions.Assertions;
import org.fest.assertions.GenericAssert;

public class PersonAssert extends GenericAssert<PersonAssert, Person> {

    protected PersonAssert(Person actual) {
        super(PersonAssert.class, actual);
    }

    public static PersonAssert assertThat(Person actual) {
        return new PersonAssert(actual);
    }

    public PersonAssert hasEmail(String email) {
        isNotNull();

        String errorMessage = String.format(
                "Expected email to be <%s> but was <%s>",
                email,
                actual.getEmail()
        );

        Assertions.assertThat(actual.getEmail())
                .overridingErrorMessage(errorMessage)
                .isEqualTo(email);

        return this;
    }

    public PersonAssert hasFirstName(String firstName) {
        isNotNull();

        String errorMessage = String.format(
                "Expected first name to be <%s> but was <%s>",
                firstName,
                actual.getFirstName()
        );

        Assertions.assertThat(actual.getFirstName())
                .overridingErrorMessage(errorMessage)
                .isEqualTo(firstName);

        return this;
    }

    public PersonAssert hasId(Long id) {
        isNotNull();

        String errorMessage = String.format(
                "Expected id to be <%d> but was <%d>",
                id,
                actual.getId()
        );

        Assertions.assertThat(actual.getId())
                .overridingErrorMessage(errorMessage)
                .isEqualTo(id);

        return this;
    }

    public PersonAssert hasLastName(String lastName) {
        isNotNull();

        String errorMessage = String.format(
                "Expected last name to be <%s> but was <%s>",
                lastName,
                actual.getLastName()
        );

        Assertions.assertThat(actual.getLastName())
                .overridingErrorMessage(errorMessage)
                .isEqualTo(lastName);

        return this;
    }
}

现在,我们可以使用PersonAssert类重写单元测试。 我们的单元测试的源看起来如下:

import org.junit.Test;

import static net.petrikainulainen.junit.dsl.PersonAssert.assertThat;

public class PersonTest {

    @Test
    public void build_FESTAssert_DSL() {
        Person person = Person.getBuilder("Foo", "Bar")
                .email("foo.bar@email.com")
                .id(1L)
                .build();

        assertThat(person)
                .hasId(1L)
                .hasFirstName("Foo")
                .hasLastName("Bar")
                .hasEmail("foo.bar@email.com");
    }
}

为什么如此重要?

现在,我们已经将凌乱的断言变成了特定领域的语言。 这使我们的测试更具可读性,但所做的更改并不完全是表面上的。

我们的解决方案具有三个主要优点:

  • 我们将实际的断言逻辑从测试方法移至PersonAssert类。 如果Person类的API发生更改,我们只需要对PersonAssert类进行更改。 我们只是使测试不那么脆弱,更易于维护。
  • 因为我们的断言使用领域专家理解的语言,所以我们的测试成为我们文档的重要组成部分。 我们的测试准确定义了我们的应用程序在特定情况下的行为。 它们是可执行的规范 ,始终是最新的。
  • 自定义错误消息和更具可读性的API确保了我们不必浪费时间来尝试找出测试失败的原因。 我们立即知道为什么失败。

实现特定于域的语言需要做一些额外的工作,但是正如我们看到的,值得付出努力。 一个好的单元测试既可读又易于维护,但是一个好的单元测试也说明了其存在的原因。

将断言转换为特定领域的语言使我们离目标更近了一步。


翻译自: https://www.javacodegeeks.com/2013/11/turning-assertions-into-a-domain-specific-language.html

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值