一个技巧轻松构造复杂测试用例的前置条件

304 阅读11分钟

一个技巧轻松实现复杂逻辑bug-free 文章中介绍了面对复杂需求时如何设计测试用例,并留了一个问题:面对复杂需求的测试用例,如何直观、快速地实现对应的测试用例代码?下面就结合在之前的文章 单元测试技巧-如何快速构造测试的前置条件 提到的技巧来快速构建测试。

需求补充

在开始介绍如何快速构建测试时,需要将实际需求再完善一下。在上一篇文章中,因为生产端的需求相对来说比较简单,所以只讨论了消费端的需求。但在实际的需求里,还需要考虑生产者在生产端是如何将任务添加到队列里面的。如图所示:

image.png

要将任务添加到队列中,需要先进行路由配置,然后生产端将任务添加到队列时,任务会根据路由配置里的规则投递到对应的队列里。具体的路由配置规则不是这篇文章讨论的重点,我们只需要关注整个流程就可以了。也就是说,假设我们有一个服务类,在这个服务类中,增加任务到队列里的方法定义如下所示:


public class QueueService {

	public void addTask(Task task, Route route) {
		// 任务会根据路由规则添加到队列中
		// ...
	}
}

然后在消费端,有一个负责从队列中取出任务进行消费的类:


public class ConsumerService {

	public void consumeTasks() {
		List<CandidateTask> tasks = findCandidateTasks();	
		dispatchTasks(tasks);
	}

	public void dispatchTasks(List<CandidateTask> tasks) {
		// 给消费者分发对应的任务
		// ... 
	}

	public List<CandidateTask> findCandidateTasks() {
		// 找到队列中的队头任务列表
		List<Task> tasks = findHeadTasks();
		// 根据队头任务列表找到对应的消费者列表
		List<Subscription> subscriptions = findSubscriptionsByTasks(tasks);
		List<Consumer> consumers = findSubscribeReadyConsumersByTasks(tasks);
		return createCandidateTasks(tasks, consumers, subscriptions);
	}
}


其中 TaskCandidateTask 的定义如下:



public class Task {

	// 任务id
	private long id;

	// 任务信息
	private TaskPayload taskPayload;
}


public class CandidateTask {

	// 任务对象
	private Task task;

	// 可以消费上述任务对象的候选消费者
	private List<Consumer> consumers;

}

除此之外,还需要定义 任务信息 TaskPayload 、队列对象 Queue,队列组对象 QueueGroup,订阅对象 Subscription, 这些对象包含什么字段对我们理解如何快速写测试来说并不重要,大家只需要了解这些对象的作用即可。

另外,由于写测试需要用到此前设计好的测试用例,这里再将既有的测试用例设计列出来:

c28bcd49018d4e79b20b31fd80a3f176.jpg

细化测试用例

在编写测试用例代码之前,根据上述思维导图,我们依然很难列举出要构造哪些前置条件。因此,我们需要针对上面的思维导图,设计出详细的测试用例。因为需求比较复杂,为方便描述,所以需要用符号来简化测试用例。

对象符号含义:任务T, 队列Q, VIP队列QV, 在队列中的顺序用数字来表示。比如,在某个VIP队列中,有任务1、2,则对应的符号表示为: QV1: TV1-1, TV1-2。 其中:

  • QV1中的Q表示队列,V表示这是一个VIP队列,1是队列的标识,表示VIP队列1。
  • 在QV1这个队列中,排在第一位的任务是 TV1-1,排在第二位的任务是 TV1-2,TV1的含义是指在VIP队列1中的任务(T: Task)。

在理解这些对象符号之后,以这个用例作为例子:[消费者专属队列无任务]_[VIP队列有任务]_[优先级最高的队列有1个]_[选择排队时间最长的任务], 我们就可以用对象符号来构造在这个用例中对应的前置条件了:


// 表示VIP队列QV1中有任务 TV1-1, TV1-2,其他的两个队列的含义以此类推
QV1: TV1-1, TV1-2
QV2: TV2-1, TV2-2
Q1: T1-1, T1-2
其中,优先级: QV1 > QV2

这么设计用例之后,我们就可以知道,要完成这个用例的测试,需要构造这些参数:

  1. 创建3条队列,QV1、QV2和Q1
  2. 创建6个任务:TV1-1, TV1-2, TV2-1, TV2-2, T1-1, T1-2
  3. 创建3个路由配置:要将任务加到这3条队列中,每条队列都对应不同的路由,所以需要3个路由配置
  4. 创建1个队列组:消费者要消费到队列中的任务,只能队列组绑定。这个用例目前只关注具有相同订阅组的消费者的场景,所以只需要1个队列组queueGroup1 即可
  5. 创建2个消费者:为表示多个消费者都可以同时对应一个任务的情况,默认至少需要2个消费者: user1和user2
  6. 创建2个订阅关系:因为有2个消费者,对应地需要有2个订阅关系: subscription1: {user1-queueGroup1}subscription2: {user2-queueGroup2}

可见,单单是一个用例,就已经需要构造这么多参数。从思维导图上看,类似这样的测试用例一共有5个。接下来再看看要怎么写对应的测试代码。

“直白”地构造测试

平常写单测的时候,都是直接开写,这样在构造前置条件的时候,往往就要写很多前置的代码,最终才能测到自己想要测试的那个函数。以这个需求为例子,如果想要测试这条路径下的用例:[消费者专属队列无任务]_[VIP队列有任务]_[优先级最高的队列有1个]_[选择排队时间最长的任务] ,你可能会写出这样的代码:


@Autowired
QueueService queueService;

@Autowired
ConsumerService consumerService;


@Test
@DisplayName("排队调度策略:[消费者专属队列无任务]_[VIP队列有任务]_[优先级最高的队列有1个]_[选择排队时间最长的任务]")
void A2_1_1() {

	// Given: 构造前置条件
    User user1 = new User("u001", "user1");
    User user2 = new User("u002", "user2");

    TaskPayload taskPayload = new TaskPayload("task1", "this is task 1", "type1");

    Consumer consumer1 = new Consumer(user1);
    Consumer consumer2 = new Consumer(user2);

    Queue QV1 = new Queue(1, true);
    Queue QV2 = new Queue(2, true);
    Queue Q1 = new Queue(2, false);

    Task TV1_1 = new Task(taskPayload, TaskStateEnum.PENDING);
    Task TV1_2 = new Task(taskPayload, TaskStateEnum.PENDING);
    Task TV2_1 = new Task(taskPayload, TaskStateEnum.PENDING);
    Task TV2_2 = new Task(taskPayload, TaskStateEnum.PENDING);
    Task T1_1 = new Task(taskPayload, TaskStateEnum.PENDING);
    Task T1_2 = new Task(taskPayload, TaskStateEnum.PENDING);

    Route routeQV1 = new Route(QV1, taskPayload);
    Route routeQV2 = new Route(QV2, taskPayload);
    Route routeQ1 = new Route(Q1, taskPayload);

    QueueGroup queueGroup1 = new QueueGroup(Arrays.asList(QV1, QV2, Q1));

    List<QueueGroup> queueGroups = Arrays.asList(queueGroup1);

    Subscription subscription1 = new Subscription(user1, queueGroups);
    Subscription subscription2 = new Subscription(user2, queueGroups);
    List<Subscription> subscription = Arrays.asList(subscription1, subscription2);

    queueService.addTask(TV1_1, routeQV1);
    queueService.addTask(TV1_2, routeQV2);
    queueService.addTask(TV2_1, routeQV1);
    queueService.addTask(TV2_2, routeQV2);
    queueService.addTask(T1_1, routeQ1);
    queueService.addTask(T1_2, routeQ1);

	// When: 测试消费逻辑
	consumerService.consumeTasks();

	// Then: 断言期望的任务是否已经被消费
	...
}


这样构造测试,会有以下几个问题:

  1. 不直观:这种编码方式会导致前置条件的构造非常冗长,也非常不直观,并不能一下子看到其中的对应关系是什么。
  2. 难复用:如果仅仅只有一个用例倒也还好,但是从用例设计上可以计算得到,要测试完这些用例,一共要写5个类似的测试才能完成,每个用例都需要理顺这样复杂的对应关系才可以。
  3. 难维护:这个需求还要考虑订阅了不同队列组的消费者的场景,要写的测试用例就更多了。如果依然采用这种方式写测试,将会导致测试用例代码变得非常难以维护,且容易出错。

那有没有什么办法可以解决上述问题呢?这就要用到在之前的文章单元测试技巧-如何快速构造测试的前置条件中介绍的JUnit5的参数解析器的特性了。

巧妙地构造测试

关于参数解析器的用法,在之前的文章中已经介绍过了。这里直接说明如何结合当前的需求来写测试。先直接展示最终的效果:


@Test
@DisplayName("排队调度策略:在[消费者专属队列无客户]_[VIP队列有客户]_[优先级最高的队列有多个]_[选择排队时间最长的任务]")
void A2_1_1(
		User user1, User user2, TaskPayload taskPayload,
		// 消费者定义
		@ConsumerDefinition(userName = "user1") Consumer consumer1,
		@ConsumerDefinition(userName = "user2") Consumer consumer2,

		// 队列定义
		@QueueDefinition(priority = 1, vip = 1) Queue QV1,
		@QueueDefinition(priority = 2, vip = 1) Queue QV2,
		@QueueDefinition(priority = 2, vip = 0) Queue Q1,

		// 路由配置定义
		@RouteDefinition(queueName = "QV1", taskPayloadName = "taskPayload") Route routeQV1,
		@RouteDefinition(queueName = "QV2", taskPayloadName = "taskPayload") Route routeQV2,
		@RouteDefinition(queueName = "Q1", taskPayloadName = "taskPayload") Route routeQ1,

		// 任务定义
		@TaskDefinition(
				routeTo = @RouteTo(routeName = "routeQV1"),
				taskPayloadName = "taskPayload",
				state = TaskStateEnum.PENDING
		) Task TV1_1,
		@TaskDefinition(
				routeTo = @RouteTo(routeName = "routeQV1"),
				taskPayloadName = "taskPayload",
				state = TaskStateEnum.PENDING
		) Task TV1_2,


		@TaskDefinition(
				routeTo = @RouteTo(routeName = "routeQV2"),
				taskPayloadName = "taskPayload",
				state = TaskStateEnum.PENDING
		) Task TV2_1,
		@TaskDefinition(
				routeTo = @RouteTo(routeName = "routeQV2"),
				taskPayloadName = "taskPayload",
				state = TaskStateEnum.PENDING
		) Task TV2_2,


		@TaskDefinition(
				routeTo = @RouteTo(routeName = "routeQ1"),
				taskPayloadName = "taskPayload",
				state = TaskStateEnum.PENDING
		) Task T1_1,
		@TaskDefinition(
				routeTo = @RouteTo(routeName = "routeQ1"),
				taskPayloadName = "taskPayload",
				state = TaskStateEnum.PENDING
		) Task T1_2,

		// 队列组配置定义		
		@QueueGroupMappingDefinition(queueNames = {"QV1", "QV2", "Q1"}) QueueGroup queueGroup1,

		// 订阅配置定义
		@SubscriptionDefinitions(subscriptions = {
				@SubscriptionDefinition(user = "user1", queueGroupNames = "queueGroup1"),
				@SubscriptionDefinition(user = "user2", queueGroupNames = "queueGroup1"),
		}) List<Subscription> subscription
) {
	
	// When: 测试消费逻辑
	consumerService.consumeTasks();

	// Then: 断言期望的任务是否已经被消费
	...
}

有几点需要说明:

  • 这里使用到了参数解析器 ParameterResolver,参数解析器的定义放到了对应的测试类上
  • 对于每一个需要用到的对象,几乎都定义了一个注解:@XXXDefinition ,然后在注解上通过指定对应的属性,来引用已经定义好的对象。

以Consumer作为例子,它与User之间的关系可以参考如下图示:

Pasted image 20230522211124.png

在消费者的定义中, @ConsumerDefinition(userName = "user1") Consumer consumer1 表示创建了一个消费者对象,然后注解上的userName属性指定了 user1,其含义是,利用在函数中已经定义的User对象 user1,作为Consumer对象使用的用户数据。因为在一个函数定义中,变量的命名是不能重复的,所以可以直接用变量名 user1 作为 User对象的标识,这样其他参数想要使用已经创建好的对象时,就可以直接根据变量名找到这个对象。通过这种方式,在数据上有依赖的前置条件就得到了解决。

这样我们就可以根据参数之间的依赖关系,自底向上地构造好所需要的参数。

那这个机制是如何利用参数解析器来完成的呢?依然是以Consumer作为例子:



public class CustomerParameterResolver implements ParameterResolver {

    @Override
    public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException {
        return parameterContext.isAnnotated(ConsumerDefinition.class);
    }

    @Override
    public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException {
        ConsumerDefinition consumerDefinition = parameterContext.getParameter().getAnnotation(ConsumerDefinition.class);
        ApplicationContext applicationContext = getApplicationContext(context);

		// 获取user对象,若没有从Store中获取到,则创建一个新的user对象
        User user = getUser(consumerDefinition, parameterContext, extensionContext);

		// 根据user对象创建customer
		CustomerService customerService = applicationContext.getBean(CustomerService.class);
		Customer customer = customerService.createCustomer(user);

		// 以customer的参数名称作为key,保存创建好的customer对象到Store中,这样下一个参数就可以通过customer的参数名称从Store中获取到Customer对象了
		// 这一步是通过参数名称获取到参数对象的关键
        store.put(parameterContext.getParameter().getName(), customer);
        return customer;
    }


	private User getUser(ConsumerDefinition consumerDefinition,
                         ParameterContext parameterContext, ExtensionContext extensionContext) {
        if (consumerDefinition != null) {
            String userName = consumerDefinition.userName();
            if (StringUtils.hasText(userName)) {
                return getStore(extensionContext).get(userName, User.class);
            }
        }

        return getApplicationContext(extensionContext).getBean(UserService.class).createUser();
    }

	// 用测试类名+方法名构建Store的命名空间,这样可以确保每个测试之间,即使存在同名的参数,也不会有冲突的情况出现
	private ExtensionContext.Store getStore(ExtensionContext context) {  
		return context.getStore(ExtensionContext.Namespace.create(context.getRequiredTestClass(), context.getRequiredTestMethod()));  
	}  
	  
	private ApplicationContext getApplicationContext(ExtensionContext context) {  
		return SpringExtension.getApplicationContext(context);  
	}  
}

我们构造了一个参数解析器,有两个关键点:

  • resolveParameter 方法中,首先获取 @ConsumerDefinition 注解中的 userName 值,并通过 getUser 方法从 ExtensionContext 中的 Store 中获取对应的 User 对象。如果 Store 中没有对应的 User 对象,则通过 ApplicationContext 创建一个新的对象。这样可以在没有预先创建 User对象的情况下,也能直接使用 Customer对象,做到所想即所得。
  • 根据获取到的 User 对象创建 Customer 对象,并将其保存到 ExtensionContext 中的 Store 中。这里使用参数的名称作为 key 保存,这样可以确保每个参数都有对应的唯一 key,下一个参数就可以通过参数名称customer,从Store中获取到Customer对象了

对于订阅了不同队列组的消费者的场景,我们依然可以用这种方式直观地写出来:


@Test
@DisplayName("调度任务给消费者-消费者B订阅的队列是消费者A订阅队列的子集")
void subset(
        User user0, User user1, TaskPayload taskPayload,
        @ConsumerDefinition(userName = "user1") Consumer consumer1,
        @ConsumerDefinition(userName = "user2") Consumer consumer2,
        @QueueDefinition Queue Q0,
        @QueueDefinition Queue Q1,
        @QueueGroupMappingDefinition(queueNames = {"Q0"}) QueueGroup QG0,
        @QueueGroupMappingDefinition(queueNames = {"Q1"}) QueueGroup QG1,

        @RouteDefinition(queueName = "Q0", taskPayloadName = "taskPayload") Route route0,
        @RouteDefinition(queueName = "Q1", taskPayloadName = "taskPayload") Route route1,

        @TaskDefinition(
                routeTo = @RouteTo(routeName = "route0"),
                taskPayloadName = "taskPayload",
                state = TaskStateEnum.PENDING) Task T0,

        @TaskDefinition(
                routeTo = @RouteTo(routeName = "route1"),
                taskPayloadName = "taskPayload",
                state = TaskStateEnum.PENDING) Task T1,

        @TaskDefinition(
                routeTo = @RouteTo(routeName = "route0"),
                taskPayloadName = "taskPayload",
                state = TaskStateEnum.PENDING) Task T2,


        @SubscriptionDefinitions(subscriptions = {
                @SubscriptionDefinition(user = "user0", queueGroupNames = {"QG0", "QG1"}),
                @SubscriptionDefinition(user = "user1", queueGroupNames = {"QG0"})
        }) List<Subscription> subscriptions
) {
        // When: 测试消费逻辑
        consumerService.consumeTasks();

        // Then: 断言期望的任务是否已经被消费
        ...
}

这种写法简单且直观, 我们可以通过注解上字段的定义,很直观地看到它们不同对象之间的联系,编写测试用例时只需要关心业务逻辑即可。而且这是可以快速复制的到下一个用例上的,基本上可以做到对着细化后的测试用例来做填空题的效果。

总结

大多数测试比较麻烦的地方都在于前置条件的构造。而参数解析器就是解决这个问题的一个好工具。其实不只是Java语言,在其他的测试框架中应该也有类似的机制,上面只是提供了一种思路,大家可以借鉴这个思路在各自熟悉的语言或框架上灵活运用。