A trick to easily construct preconditions for complex test cases

When we start to introduce how to quickly build tests, we need to improve the actual requirements. In the previous article, because the requirements on the production side were relatively simple, only the requirements on the consumer side were discussed. But in actual requirements, it is also necessary to consider how the producer adds tasks to the queue on the production side. as the picture shows:

To add a task to the queue, you need to configure the routing first, and then when the production end adds the task to the queue, the task will be delivered to the corresponding queue according to the rules in the routing configuration. The specific routing configuration rules are not the focus of this article, we only need to focus on the whole process. That is to say, suppose we have a service class. In this service class, the method definition of adding tasks to the queue is as follows:

public class QueueService {

public void addTask(Task task, Route route) {
// Tasks will be added to the queue according to routing rules
//...
}
}

Then on the consumer side, there is a class responsible for taking tasks out of the queue for consumption:

public class ConsumerService {

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

public void dispatchTasks(List<CandidateTask> tasks) {
// Distribute corresponding tasks to consumers
//...
}

public List<CandidateTask> findCandidateTasks() {
// Find the queue head task list in the queue
List<Task> tasks = findHeadTasks();
// Find the corresponding consumer list according to the queue head task list
List<Subscription> subscriptions = findSubscriptionsByTasks(tasks);
List<Consumer> consumers = findSubscribeReadyConsumersByTasks(tasks);
return createCandidateTasks(tasks, consumers, subscriptions);
}
}

The definitions of Task and CandidateTask are as follows:

public class Task {

// task id
private long id;

// task information
private TaskPayload taskPayload;
}


public class CandidateTask {

// task object
private Task task;

// Candidate consumers who can consume the above task objects
private List<Consumer> consumers;

}

In addition, it is also necessary to define task information TaskPayload, queue object Queue, queue group object QueueGroup, subscription object Subscription code>, what fields these objects contain is not important for us to understand how to quickly write tests, you only need to understand the functions of these objects.

In addition, since writing tests requires the use of previously designed test cases, here is a list of existing test case designs:

Detailed test cases

Before writing the test case code, according to the above mind map, it is still difficult for us to enumerate the preconditions to be constructed. Therefore, we need to design detailed test cases for the mind map above. Because the requirements are more complex, for the convenience of description, symbols are needed to simplify the test cases.

Object symbol meaning: task T, queue Q, VIP queue QV, the order in the queue is represented by numbers. For example, if there are tasks 1 and 2 in a VIP queue, the corresponding symbols are: QV1: TV1-1, TV1-2. in:

  • Q in QV1 indicates a queue, V indicates that this is a VIP queue, and 1 is an identifier of the queue, indicating VIP queue 1.
  • In the QV1 queue, the first task is TV1-1, and the second task is TV1-2. TV1 means the task in VIP queue 1 (T: Task).

After understanding these object symbols, take this use case as an example: [Consumer-only queue has no tasks]_[VIP queue has tasks]_[There is 1 queue with the highest priority]_[Select the one with the longest queuing time task], we can use object notation to construct the corresponding preconditions in this use case:

// Indicates that there are tasks TV1-1, TV1-2 in the VIP queue QV1, and the meaning of the other two queues can be deduced by analogy
QV1: TV1-1, TV1-2
QV2: TV2-1, TV2-2
Q1: T1-1, T1-2
Among them, priority: QV1 > QV2

After designing the use case in this way, we can know that to complete the test of this use case, we need to construct these parameters:

  1. Create 3 queues, QV1, QV2 and Q1
  2. Create 6 tasks: TV1-1, TV1-2, TV2-1, TV2-2, T1-1, T1-2
  3. Create 3 routing configurations: To add tasks to these 3 queues, each queue corresponds to a different route, so 3 routing configurations are required
  4. Create a queue group: The tasks that consumers want to consume in the queue can only be bound to the queue group. This use case currently only focuses on the scenario of consumers with the same subscription group, so only one queue group queueGroup1 is required
  5. Create 2 consumers: To indicate that multiple consumers can correspond to a task at the same time, at least 2 consumers are required by default: user1 and user2
  6. Create 2 subscription relationships: Because there are 2 consumers, there need to be 2 subscription relationships: subscription1: {user1-queueGroup1} and subscription2: {user2-queueGroup2}

It can be seen that so many parameters need to be constructed just for one use case. From the mind map, there are a total of 5 test cases like this. Next, let’s see how to write the corresponding test code.

Construct tests “straightforwardly”

Usually, when writing unit tests, they are written directly. In this way, when constructing preconditions, it is often necessary to write a lot of preconditions, and finally the function you want to test can be tested. Taking this requirement as an example, if you want to test the use cases under this path: [Consumer exclusive queue has no tasks]_[VIP queue has tasks]_[There is 1 queue with the highest priority]_[Select queuing Longest task] , you might write code like this:

@Autowired
QueueService queueService;

@Autowired
ConsumerService consumerService;


@Test
@DisplayName("Queuing Scheduling Strategy: [Consumer Exclusive Queue No Task]_[VIP Queue Has Task]_[The highest priority queue has 1]_[Select the task with the longest queuing time]")
void A2_1_1() {

// Given: Construct preconditions
    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: Test consumption logic
consumerService. consumeTasks();

// Then: Assert whether the expected task has been consumed
...
}

There are several problems with constructing the test in this way:

  1. Unintuitive: This encoding method will lead to a very lengthy and unintuitive construction of preconditions, and it is impossible to see what the corresponding relationship is at once.
  2. Difficult to reuse: It’s fine if there is only one use case, but it can be calculated from the use case design. To test these use cases, a total of 5 similar tests must be written to complete. Each use case needs to straighten out such complex Only the corresponding relationship can be used.
  3. Difficult to maintain: This requirement also considers the scenario of consumers subscribing to different queue groups, and there are more test cases to be written. If you still write tests in this way, the test case code will become very difficult to maintain and error-prone.

So is there any way to solve the above problem?

Construct tests smartly

The usage of the parameter parser has been introduced in the previous article. Here is a direct explanation of how to write tests based on current requirements. First show the final effect directly:

@Test
@DisplayName("Queuing Scheduling Strategy: In [consumer exclusive queue without customers]_[VIP queue has customers]_[there are multiple queues with the highest priority]_[choose the task with the longest queuing time]")
void A2_1_1(
User user1, User user2, TaskPayload taskPayload,
// consumer definition
@ConsumerDefinition(userName = "user1") Consumer consumer1,
@ConsumerDefinition(userName = "user2") Consumer consumer2,

// queue definition
@QueueDefinition(priority = 1, vip = 1) Queue QV1,
@QueueDefinition(priority = 2, vip = 1) Queue QV2,
@QueueDefinition(priority = 2, vip = 0) Queue Q1,

// Routing configuration definition
@RouteDefinition(queueName = "QV1", taskPayloadName = "taskPayload") Route routeQV1,
@RouteDefinition(queueName = "QV2", taskPayloadName = "taskPayload") Route routeQV2,
@RouteDefinition(queueName = "Q1", taskPayloadName = "taskPayload") Route routeQ1,

// task definition
@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,

// queue group configuration definition
@QueueGroupMappingDefinition(queueNames = {"QV1", "QV2", "Q1"}) QueueGroup queueGroup1,

// subscribe configuration definition
@SubscriptionDefinitions(subscriptions = {
@SubscriptionDefinition(user = "user1", queueGroupNames = "queueGroup1"),
@SubscriptionDefinition(user = "user2", queueGroupNames = "queueGroup1"),
}) List<Subscription> subscription
) {
\t
// When: Test consumption logic
consumerService. consumeTasks();

// Then: Assert whether the expected task has been consumed
...
}

There are a few points to explain:

  • The parameter parser ParameterResolver is used here, and the definition of the parameter parser is placed on the corresponding test class
  • For each object that needs to be used, an annotation is defined: @XXXDefinition, and then the defined object is referenced by specifying the corresponding attribute on the annotation.

Taking Consumer as an example, the relationship between it and User can refer to the following diagram:

In the definition of the consumer, @ConsumerDefinition(userName = "user1") Consumer consumer1 indicates that a consumer object is created, and then the userName attribute on the annotation specifies the user1, which means to use the User object user1 defined in the function as the user data used by the Consumer object. Because in a function definition, the naming of variables cannot be repeated, so you can directly use the variable name user1 as the identifier of the User object, so that when other parameters want to use the created object, you can Find this object directly based on the variable name. In this way, the precondition of being dependent on the data is resolved.

In this way, we can construct the required parameters from the bottom up according to the dependencies between the parameters.

So how does this mechanism use the parameter parser to complete it? Still take Consumer as an example:

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);

// Get the user object, if not obtained from the Store, create a new user object
        User user = getUser(consumerDefinition, parameterContext, extensionContext);

// create customer based on user object
CustomerService customerService = applicationContext. getBean(CustomerService. class);
Customer customer = customerService. createCustomer(user);

// Use the parameter name of customer as the key, save the created customer object to the Store, so that the next parameter can get the Customer object from the Store through the parameter name of customer
// This step is the key to get the parameter object through the parameter name
        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();
    }

// Use the test class name + method name to build the namespace of the Store, which can ensure that there will be no conflicts between each test even if there are parameters with the same name
private ExtensionContext. Store getStore(ExtensionContext context) {
return context.getStore(ExtensionContext.Namespace.create(context.getRequiredTestClass(), context.getRequiredTestMethod()));
}
\t  
private ApplicationContext getApplicationContext(ExtensionContext context) {
return SpringExtension. getApplicationContext(context);
}
}

We construct a parameter parser with two key points:

  • In the resolveParameter method, first get the userName value in the @ConsumerDefinition annotation, and use the getUser method to get the userName value from the getUser method Get the corresponding User object from Store in >ExtensionContext. If there is no corresponding User object in Store, create a new object through ApplicationContext. In this way, you can directly use the Customer object without pre-creating the User object, so that what you want is what you get.
  • Create a Customer object based on the acquired User object, and save it to Store in ExtensionContext. Here the name of the parameter is saved as key, which ensures that each parameter has a corresponding unique key, and the next parameter can pass the parameter name customer code>, get the Customer object from Store

For the scenario of consumers subscribing to different queue groups, we can still write it intuitively in this way:

@Test
@DisplayName("Scheduling tasks to consumers - the queue subscribed by consumer B is a subset of the queue subscribed by consumer 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: Test consumption logic
        consumerService. consumeTasks();

        // Then: Assert whether the expected task has been consumed
        ...
}

This way of writing is simple and intuitive. We can intuitively see the connection between different objects through the definition of the fields on the annotation. When writing test cases, we only need to care about business logic. And this can be quickly copied to the next use case, basically achieving the effect of filling in the blanks for the refined test case.

Summary

The troublesome part of most tests is the construction of preconditions. The parameter parser is a good tool to solve this problem. In fact, not only the Java language, there should be similar mechanisms in other testing frameworks. The above is just a way of thinking. You can learn from this way of thinking and use it flexibly in the languages or frameworks you are familiar with.

Finally: The complete software testing video tutorial below has been organized and uploaded, friends who need it can get it by themselves [100% free guarantee]

Software testing interview document

We must study to find a high-paying job. The following interview questions are the latest interview materials from first-tier Internet companies such as Ali, Tencent, and Byte, and some Byte bosses have given authoritative answers. Finish this set The interview materials believe that everyone can find a satisfactory job.

image

Get the whole set of information