background

Recently, the team plans to make a tool for automatic test of small program, which is expected to automatically restore the previous operation path after the operation of the small program by business personnel, and capture the anomalies in the operation process, so as to judge whether this release will affect the basic functions of the small program.

The above description seems simple, but there are still some difficulties in the middle. The first difficulty is how to record the operation path when the business personnel operate the small program, and the second difficulty is how to restore the recorded operation path.

Automation of the SDK

How to restore the operation path to this problem, the first choice is the official SDK: miniprogram-automator.

Small program automation SDK provides developers with a set of external scripts to control small programs, so as to achieve the purpose of automatic testing small programs. With the SDK, you can do the following:

  • Controls the applets to jump to the specified page
  • Get applets page data
  • Gets the state of the applet page element
  • Triggers the applets element binding event
  • Inject snippets of code into AppService
  • Call any interface on a WX object
  • .

The descriptions above are from the official documentation. It is recommended that you read the official documentation before you read the following content. Of course, if you have used Puppeteer before, you can get started quickly. The following is a brief introduction to how to use the SDK.

/ / into the SDK
const automator = require('miniprogram-automator')

// Start the wechat developer tool
automator.launch({
  // Cli tool in the installation path of wechat Developer Tool
  // In Windows, cli.bat is in the installation directory
  // In MacOS, the cli is in the installation path
  cliPath: 'path/to/cli'.// The project address, which is the path of the applet to run
  projectPath: 'path/to/project',
}).then(async miniProgram => { // miniProgram is the instance after IDE is started
	// Start the index page in the applet
  const page = await miniProgram.reLaunch('/page/index/index')
  // Wait for 500 ms
  await page.waitFor(500)
  // Get the page element
  const element = await page.$('.main-btn')
  // Click the element
  await element.tap()
	/ / close the IDE
  await miniProgram.close()
})
Copy the code

One caveat: before using the SDK, you need to enable the developer tools service port, otherwise the startup will fail.

Capturing user behavior

Now that we have the means to restore the operation path, we need to solve the problem of logging the operation path.

In an applet, you can’t capture all the events in the window by bubbling them up like you can on the Web. Fortunately, all pages and components in an applet must be wrapped in Page and Component methods, so we can override these methods to intercept incoming methods. And determine if the first parameter is an event object to capture all events.

// hold native methods temporarily
const originPage = Page
const originComponent = Component

/ / Page
Page = (params) = > {
  const names = Object.keys(params)
  for (const name of names) {
    // Perform method interception
    if (typeof obj[name] === 'function') {
      params[name] = hookMethod(name, params[name], false)
    }
  }
  originPage(params)
}
/ / Component
Component = (params) = > {
  if (params.methods) {
      const { methods } = params
      const names = Object.keys(methods)
      for (const name of names) {
        // Perform method interception
        if (typeof methods[name] === 'function') {
          methods[name] = hookMethod(name, methods[name], true)
        }
      }
  }
  originComponent(params)
}

const hookMethod = (name, method, isComponent) = > {
  return function(. args) {
    const [evt] = args // Get the first argument
    // Check whether it is an event object
    if (evt && evt.target && evt.type) {
      // Record user behavior
    }
    return method.apply(this, args)
  }
}
Copy the code

The code here only proxies all the event methods and cannot be used to restore the user’s behavior. To restore the user’s behavior, you must also know whether the event type is required, such as click, long press, type.

const evtTypes = [
    'tap'./ / click
    'input'./ / input
    'confirm'./ / return
    'longpress' / / long press
]
const hookMethod = (name, method) = > {
  return function(. args) {
    const [evt] = args // Get the first argument
    // Check whether it is an event object
    if (
      evt && evt.target && evt.type &&
      evtTypes.includes(evt.type) // Determine the event type
    ) {
      // Record user behavior
    }
    return method.apply(this, args)
  }
}
Copy the code

After determining the event type, it is necessary to specify which element is clicked. However, the pit in the small program is that the target attribute of the event object does not have the element’s class name, but it can obtain the element’s dataset.

To get the elements exactly, we need to add a step to the build by modifying the WXML file to make a copy of the class attributes of all elements into data-className.

<! -- Before build -->
<view class="close-btn"></view>
<view class="{{mainClassName}}"></view>
<! -- Build -->
<view class="close-btn" data-className="close-btn"></view>
<view class="{{mainClassName}}" data-className="{{mainClassName}}"></view>
Copy the code

But once you get the class, there is another pitfall. The automated test tool for the applet cannot directly get the elements of the custom components in the page, it must first get the custom components.

<! -- Page -->
<toast text="loading" show="{{showToast}}" />
<! -- Component -->
<view class="toast" wx:if="{{show}}">
  <text class="toast-text">{{text}}</text>
  <view class="toast-close" />
</view>
Copy the code
Toasts-close: toasts-close: toasts-close: toasts-close: toasts-close: toasts-close: toasts-close
const element = await page.$('.toast-close')
element.tap() // Error!

// The custom component must first be found by its tagName
// Find the corresponding element from the custom component by className
const element = await page.$('toast .toast-close')
element.tap()
Copy the code

So when we build the operation, we also need to insert the tagName for the element.

<! -- Before build -->
<view class="close-btn" />
<toast text="loading" show="{{showToast}}" />
<! -- Build -->
<view class="close-btn" data-className="close-btn" data-tagName="view" />
<toast text="loading" show="{{showToast}}" data-tagName="toast" />
Copy the code

Now we can happily continue recording user behavior.

// An array to record user behavior
const actions = [];
// Add user behavior
const addAction = (type, query, value = ' ') = > {
  actions.push({
    time: Date.now(),
    type,
    query,
    value
  })
}

// Proxy event method
const hookMethod = (name, method, isComponent) = > {
  return function(. args) {
    const [evt] = args // Get the first argument
    // Check whether it is an event object
    if (
      evt && evt.target && evt.type &&
      evtTypes.includes(evt.type) // Determine the event type
    ) {
      const { type, target, detail } = evt
      const { id, dataset = {} } = target
    	const { className = ' ' } = dataset
    	const { value = ' ' } = detail // Input Specifies the value of the input box when the event is triggered
      // Record user behavior
      let query = ' '
      if (isComponent) {
        // If it is a method within a component, you need to get the tagName of the current component
        query = `The ${this.dataset.tagName} `
      }
      if (id) {
        // if id is present, the element is found by id
        query += id
      } else {
        // If the id does not exist, use className to find the element
        query += className
      }
      addAction(type, query, value)
    }
    return method.apply(this, args)
  }
}
Copy the code

All user clicks, inputs, and carriage returns have been recorded. But there are scrollscreen operations that are not logged, so we can proxy Page’s onPageScroll method directly.

// An array to record user behavior
const actions = [];
// Add user behavior
const addAction = (type, query, value = ' ') = > {
  if (type === 'scroll' || type === 'input') {
    // If the last action was also scroll or input, reset value
    const last = this.actions[this.actions.length - 1]
    if (last && last.type === type) {
      last.value = value
      last.time = Date.now()
      return
    }
  }
  actions.push({
    time: Date.now(),
    type,
    query,
    value
  })
}

Page = (params) = > {
  const names = Object.keys(params)
  for (const name of names) {
    // Perform method interception
    if (typeof obj[name] === 'function') {
      params[name] = hookMethod(name, params[name], false)}}const { onPageScroll } = params
  // Intercepts rolling events
  params.onPageScroll = function (. args) {
    const [evt] = args
    const { scrollTop } = evt
    addAction('scroll'.' ', scrollTop)
    onPageScroll.apply(this, args)
  }
  originPage(params)
}
Copy the code

There is an optimization point here, that is, when recording the scrolling operation, you can determine whether the last operation is also a scrolling operation. If it is the same operation, you only need to modify the scrolling distance, because two scrolling can be achieved in one step. The same is true for input events, and values can be entered in one step.

Restoring user behavior

After the user completes the operation, the JSON text of the user’s behavior can be output in the console. After the JSON text is copied, it can be run through the automation tool.

/ / into the SDK
const automator = require('miniprogram-automator')

// User actions
const actions = [
  { type: 'tap'.query: 'goods .title'.value: ' '.time: 1596965650000 },
  { type: 'scroll'.query: ' '.value: 560.time: 1596965710680 },
  { type: 'tap'.query: 'gotoTop'.value: ' '.time: 1596965770000}]// Start the wechat developer tool
automator.launch({
  projectPath: 'path/to/project',
}).then(async miniProgram => {
  let page = await miniProgram.reLaunch('/page/index/index')
  
  let prevTime
  for (const action of actions) {
    const { type, query, value, time } = action
    if (prevTime) {
      // Calculate the wait time between two operations
  		await page.waitFor(time - prevTime)
    }
    // Reset the last operation time
    prevTime = time
    
    // Get the current page instance
    page = await miniProgram.currentPage()
    switch (type) {
      case 'tap':
  			const element = await page.$(query)
        await element.tap()
        break;
      case 'input':
  			const element = await page.$(query)
        await element.input(value)
        break;
      case 'confirm':
  			const element = await page.$(query)
 				await element.trigger('confirm', { value });
        break;
      case 'scroll':
        await miniProgram.pageScrollTo(value)
        break;
    }
    // Wait 5 seconds after each operation to avoid missing the page in subsequent operations
    await page.waitFor(5000)}/ / close the IDE
  await miniProgram.close()
})
Copy the code

This is just a simple restoration of the user’s operation behavior. In the actual operation process, it will also involve the network request and the mock of localstorage, which will not be expanded here. At the same time, we can also access the JEST tool, more convenient to write use cases.

conclusion

Seemingly difficult needs, as long as the heart to explore, can always find a corresponding solution. In addition, micro channel small program automation tools really have a lot of pit, encountered problems can first go to the small program community to find, most of the pit has been stepped on by predecessors, there are some temporarily unable to solve the problem can only think of other ways to avoid. Finally wish the world no bug.