17370845950

jqwik中@Provide方法结合@ForAll处理集合类型参数的最佳实践

本文探讨了在jqwik中使用@forall注解与@provide方法处理集合类型参数时常见的陷阱。核心内容包括:明确@domain注解的正确作用域(应应用于属性方法或测试类,而非@provide方法本身),以及当@provide方法需要生成集合类型的arbitrary时,应避免在参数中使用@forall,转而直接在方法体内构建集合arbitrary,以避免潜在的arbitrary查找失败和不必要的扁平化映射。通过遵循这些指导原则,可以有效避免cannotfindarbitraryexception,并编写出更健壮、意图更明确的基于属性的测试。

在jqwik中进行基于属性的测试时,我们经常需要自定义复杂类型的任意值生成器(Arbitrary)。@Provide方法是实现这一目标的关键,它允许我们定义如何生成特定类型的任意值。然而,当尝试在@Provide方法中使用@ForAll注解来接收集合类型参数时,开发者可能会遇到CannotFindArbitraryException,这通常是由于对@Domain注解的作用域和@Provide方法的预期用途存在误解。

问题剖析:@ForAll与@Provide集合参数的误用

考虑以下场景,我们有一个Name领域模型,并希望生成一个Set,其中包含解析后的Name对象。一个常见的错误尝试是在@Provide方法中这样定义:

// 领域模型
public class Name {
  public final String first;
  public final String last;
  public Name(String f, String l) { 
    this.first = f;
    this.last = l;
  }
}

// jqwik领域上下文
public class NameDomain extends DomainContextBase {
  @Provide
  public Arbitrary arbName() {
    return Combinators.combine(
      Arbitraries.strings().alpha(), 
      Arbitraries.strings().alpha()
    ).as(Name::new);
  }
}

// 属性测试类中的错误尝试
public class NameProperties {
  @Provide
  @Domain(NameDomain.class) // 错误:@Domain不应在此处
  public Arbitrary> namesToParse(
    @ForAll @Size(min = 1, max = 4) Set names) {
    // 假设此处将Set转换为Set
    // ... code here
    return Arbitraries.just(new HashSet<>()); // 示例返回
  }

  @Property
  public void namesAreParsed(@ForAll("namesToParse") Set names) {
    // ... code here
  }
}

当运行上述代码时,jqwik会抛出CannotFindArbitraryException,指出无法为namesToParse方法中Set类型的参数找到Arbitrary。这背后的原因是@Domain注解的错误放置,以及对@Provide方法中@ForAll参数处理机制的误解。

解决方案一:@Domain注解的正确作用域

@Domain注解的目的是将一个DomainContext关联到属性方法或整个测试类,从而使该上下文中的@Provide方法生成的Arbitrary对这些属性方法可见。它不应直接应用于@Provide方法本身。

正确的@Domain注解放置方式有两种:

  1. 应用于属性方法:

    public class NameProperties {
      // ... 其他代码 ...
    
      @Property
      @Domain(NameDomain.class) // 正确:应用于属性方法
      public void namesAreParsed(@ForAll("namesToParse") Set names) {
        // ... code here
      }
    }
  2. 应用于整个测试类:

    @Domain(NameDomain.class) // 正确:应用于测试类
    class NameProperties { 
      // ... 其他代码 ...
    
      @Property
      public void namesAreParsed(@ForAll("namesToParse") Set names) {
        // ... code here
      }
    }

通过将@Domain注解放置在正确的位置,NameDomain中提供的Arbitrary就能被NameProperties类中的属性方法识别和使用。然而,即使@Domain放置正确,原始的@Provide方法定义仍然存在问题。

解决方案二:重构@Provide方法以直接构建集合Arbitrary

在@Provide方法中,当您需要生成一个复杂类型(如集合)的Arbitrary时,通常不建议在其参数中使用@ForAll。这是因为在@Provide方法中使用@ForAll参数,jqwik会采用扁平化映射(flat mapping)的方式来处理这些参数,这使得逻辑变得复杂,并且可能不是您期望的行为。

对于生成集合类型的Arbitrary,最清晰和推荐的方法是在@Provide方法体内直接构建所需的Arbitrary。您可以使用Arbitraries.defaultFor(Type.class)来获取指定类型的默认Arbitrary,然后利用其链式调用方法来构建集合。

以下是重构后的namesToParse方法示例:

import net.jqwik.api.*;
import net.jqwik.api.arbitraries.SetArbitrary;
import net.jqwik.api.domains.DomainContextBase;
import net.jqwik.api.domains.Domain;

import java.util.HashSet;
import java.util.Set;
import java.util.stream.Collectors;

// 领域模型
public class Name {
  public final String first;
  public final String last;
  public Name(String f, String l) { 
    this.first = f;
    this.last = l;
  }
}

// jqwik领域上下文
public class NameDomain extends DomainContextBase {
  @Provide
  public Arbitrary arbName() {
    return Combinators.combine(
      Arbitraries.strings().alpha().ofMinLength(1).ofMaxLength(10), // 增加长度限制
      Arbitraries.strings().alpha().ofMinLength(1).ofMaxLength(10)
    ).as(Name::new);
  }
}

// 属性测试
@Domain(NameDomain.class) // @Domain应用于测试类
public class NameProperties {

  @Provide
  public Arbitrary> namesToParse() {
    // 直接在方法体内构建Set的Arbitrary
    SetArbitrary namesArbitrary = Arbitraries.defaultFor(Name.class)
                                                  .set().ofMinSize(1).ofMaxSize(4);

    // 将Set映射为Set
    return namesArbitrary.map(nameSet -> 
        nameSet.stream()
               .map(n -> n.first + " " + n.last) // 示例:将Name对象转换为字符串
               .collect(Collectors.toSet())
    );
  }

  @Property
  public void namesAreParsed(@ForAll("namesToParse") Set names) {
    // 确保生成的集合不为空且大小在预期范围内
    System.out.println("Generated names: " + names);
    Assertions.assertThat(names).isNotEmpty();
    Assertions.assertThat(names.size()).isBetween(1, 4);
    // ... 实际的解析和验证逻辑
  }
}

在这个重构后的@Provide方法中:

  • 我们不再使用@ForAll参数。
  • 我们通过Arbitraries.defaultFor(Name.class)获取了Name类型的默认Arbitrary,该Arbitrary的生成逻辑由NameDomain中的arbName()方法提供。
  • 接着,我们使用.set().ofMinSize(1).ofMaxSize(4)链式调用来创建一个生成Set的Arbitrary,并指定了集合的大小约束。
  • 最后,使用.map()方法将生成的Set转换为Set,这正是我们namesToParse方法期望返回的类型。

这种方式清晰地表达了namesToParse方法的目标:它提供了一个生成Set的Arbitrary,并且这个Set是基于Name对象生成的。

总结与最佳实践

在使用jqwik进行高级属性测试时,请牢记以下几点:

  1. @Domain注解的作用域: DomainContext通过@Domain注解关联到属性方法测试类,而不是@Provide方法本身。这确保了DomainContext中定义的Arbitrary对属性测试逻辑可见。
  2. @Provide方法的职责: Provide方法的核心职责是返回一个Arbitrary实例,该实例定义了如何生成特定类型的值。
  3. 避免@Provide方法中的@ForAll参数(特别是对于集合): 在@Provide方法中使用@ForAll参数会导致jqwik进行扁平化映射,这通常不是您在生成复杂类型或集合Arbitrary时所期望的行为。
  4. 直接构建Arbitrary: 当您需要一个复杂类型(如Set、List等)的Arbitrary时,最佳实践是在@Provide方法体内直接使用Arbitraries.defaultFor(Type.class)结合链式调用(如.set()、.list()、.map()、.filter()等)来构建所需的Arbitrary。这使得代码意图更明确,也更易于理解和维护。

遵循这些指导原则,您将能够更有效地利用jqwik的强大功能,编写出健壮且可读性强的基于属性的测试。