Vue2 application test learning 03 - TDD case (other test cases, snapshot test, configuration code coverage statistics, codecov upload coverage statistics report, Github Actions automated testing and continuous integration)

TodoApp Toggle Select All

Click the Select All button to modify the status of all task items, and the style of the button changes with the selected status of all task items.

Write test cases

test('Toggle All', async () => {
    
    
  const toggleAll = wrapper.findComponent('input[data-testid="toggle-all"]')

  // 选中全选按钮
  await toggleAll.setChecked()

  // 断言所有的任务都被选中
  wrapper.vm.todos.forEach(todo => {
    
    
    expect(todo.done).toBeTruthy()
  })

  // 取消完成状态
  await toggleAll.setChecked(false)
  wrapper.vm.todos.forEach(todo => {
    
    
    expect(todo.done).toBeFalsy()
  })
})

test('Toggle All State', async () => {
    
    
  const toggleAll = wrapper.findComponent('input[data-testid="toggle-all"]')

  // 让所有任务都变成完成状态
  wrapper.vm.todos.forEach(todo => {
    
    
    todo.done = true
  })
  // 等待视图更新
  await wrapper.vm.$nextTick()
  // 断言 toggleAll 选中
  expect(toggleAll.element.checked).toBeTruthy()

  // 取消某个任务未完成,断言 toggleAll 未选中
  wrapper.vm.todos[0].done = false
  await wrapper.vm.$nextTick()
  expect(toggleAll.element.checked).toBeFalsy()

  // 当没有任务的时候,断言 toggleAll 未选中
  await wrapper.setData({
    
    
    todos: [],
  })
  expect(toggleAll.element.checked).toBeFalsy()
})

Improve component functions

// template
<input
  id="toggle-all"
  v-model="toggleAll"
  data-testid="toggle-all"
  class="toggle-all"
  type="checkbox"
/>

// js
computed: {
    
    
  toggleAll: {
    
    
    get() {
    
    
      // 获取 toggleAll 的选中状态
      return this.todos.length && this.todos.every(t => t.done)
    },
    set(checked) {
    
    
      this.todos.forEach(todo => {
    
    
        todo.done = checked
      })
    },
  },
},

TodoFooter number of outstanding tasks

Write test cases

// src\components\TodoApp\__tests__\TodoFooter.js
import {
    
     shallowMount } from '@vue/test-utils'
import TodoFooter from '@/components/TodoApp/TodoFooter'

describe('TodoFooter.js', () => {
    
    
  /** @type {import('@vue/test-utils').Wrapper} */
  let wrapper = null

  beforeEach(async () => {
    
    
    const todos = [
      {
    
     id: 1, text: 'eat', done: false },
      {
    
     id: 2, text: 'play', done: true },
      {
    
     id: 3, text: 'sleep', done: false },
    ]
    wrapper = shallowMount(TodoFooter, {
    
    
      propsData: {
    
    
        todos,
      },
    })
  })

  test('Done Todos Count', () => {
    
    
    const count = wrapper.vm.todos.filter(t => !t.done).length
    const countEl = wrapper.findComponent('[data-testid="done-todos-count"]')

    expect(Number.parseInt(countEl.text())).toBe(count)
  })
})

Improve component functions

<!-- src\components\TodoApp\TodoFooter.vue -->
<template>
  <footer class="footer">
    <!-- This should be `0 items left` by default -->
    <span class="todo-count"><strong data-testid="done-todos-count">{
    
    {
    
     doneTodosCount }}</strong> item left</span>
    <!-- Remove this if you don't implement routing -->
    <ul class="filters">
      <li>
        <a class="selected" href="#/">All</a>
      </li>
      <li>
        <a href="#/active">Active</a>
      </li>
      <li>
        <a href="#/completed">Completed</a>
      </li>
    </ul>
    <!-- Hidden if no completed items are left ↓ -->
    <button class="clear-completed">Clear completed</button>
  </footer>
</template>

<script>
export default {
    
    
  name: 'TodoFooter',
  props: {
    
    
    todos: {
    
    
      type: Array,
      required: true,
    },
  },
  computed: {
    
    
    doneTodosCount() {
    
    
      return this.todos.filter(t => !t.done).length
    },
  },
}
</script>

Note that the required attributes are passed to the TodoFooter component in the TodoApp component:<TodoFooter :todos="todos" />

TodoFooter clears the display status of the completed task button

Write test cases

test('Clear Completed Show', () => {
    
    
  // beforeEach 中初始化的数据是 props
  // 而 props 是不能被子组件直接修改的
  // 所以这里要单独初始化数据
  const todos = [
    {
    
     id: 1, text: 'eat', done: false },
    {
    
     id: 2, text: 'play', done: false },
    {
    
     id: 3, text: 'sleep', done: false },
  ]
  wrapper = shallowMount(TodoFooter, {
    
    
    propsData: {
    
    
      todos,
    },
  })

  const button = wrapper.findComponent('[data-testid="clear-completed"]')

  expect(button.exists()).toBeFalsy()
})

Improve component functions

// template
<button
  v-if="isClearCompletedShow"
  data-testid="clear-completed"
  class="clear-completed"
  @click="$emit('clear-completed')"
>
  Clear completed
</button>

// js
computed: {
    
    
  ...
  isClearCompletedShow() {
    
    
    return this.todos.some(t => t.done)
  },
},

Clear completed tasks

TodoFooter

Write test cases

test('Clear Completed', async () => {
    
    
  const button = wrapper.findComponent('[data-testid="clear-completed"]')

  await button.trigger('click')

  expect(wrapper.emitted()['clear-completed']).toBeTruthy()
})

Improve component functions

// template
<button
  v-if="isClearCompletedShow"
  data-testid="clear-completed"
  class="clear-completed"
  @click="$emit('clear-completed')"
>
  Clear completed
</button>

TodoApp

Write test cases

test('Clear All Completed', async () => {
    
    
  wrapper.vm.handleClearCompleted()
  await wrapper.vm.$nextTick()

  expect(wrapper.vm.todos).toEqual([
    {
    
     id: 1, text: 'eat', done: false },
    {
    
     id: 3, text: 'sleep', done: false },
  ])
})

Improve component functions

// template
<TodoFooter
  :todos="todos"
  @clear-completed="handleClearCompleted"
/>

// js
methods: {
    
    
  ...
  handleClearCompleted() {
    
    
    // 清除所有已完成的任务项
    this.todos = this.todos.filter(t => !t.done)
  },
},

TodoApp data filtering ($route)

To filter task items based on routing path, it needs to be used with Vue Router :

  • You can create a local Vue (localVue), register vue-router for it, and only take effect for this local Vue
    • However, this will increase external dependencies (vue-router instances), and each test must load vue-router and there will be performance loss
  • It is recommended to fake (mock) $routeand$router

Configure components as routes

<!-- src\App.vue -->
<template>
  <div id="app">
    <!-- <TodoApp /> -->
    <<router-view />
  </div>
</template>

<script>
// import TodoApp from '@/components/TodoApp'

export default {
  name: 'App',
  // components: { TodoApp },
}
</script>

// src\router\index.js
import Vue from 'vue'
import VueRouter from 'vue-router'
import TodoApp from '@/components/TodoApp'

Vue.use(VueRouter)

const routes = [
  {
    
    
    path: '/',
    component: TodoApp,
  },
  {
    
    
    path: '/active',
    component: TodoApp,
  },
  {
    
    
    path: '/completed',
    component: TodoApp,
  },
]

const router = new VueRouter({
    
    
  routes,
})

export default router

Write test cases

beforeEach(async () => {
    
    
  const $route = {
    
    
    path: '/',
  }

  wrapper = shallowMount(TodoApp, {
    
    
    mocks: {
    
    
    // 伪造 $route
      $route,
    },
  })

  const todos = [
    {
    
     id: 1, text: 'eat', done: false },
    {
    
     id: 2, text: 'play', done: true },
    {
    
     id: 3, text: 'sleep', done: false },
  ]

  // 初始化默认数据,并等待视图更新
  await wrapper.setData({
    
    
    todos,
  })
})

...

test('Filter Todos', async () => {
    
    
  // 将路由导航到 /
  wrapper.vm.$route.path = '/'
  await wrapper.vm.$nextTick()
  // 断言 filterTodos = 所有的任务
  expect(wrapper.vm.filterTodos).toEqual([
    {
    
     id: 1, text: 'eat', done: false },
    {
    
     id: 2, text: 'play', done: true },
    {
    
     id: 3, text: 'sleep', done: false },
  ])

  // 将路由导航到 /active
  wrapper.vm.$route.path = '/active'
  await wrapper.vm.$nextTick()
  // 断言 filterTodos = 所有的未完成任务
  expect(wrapper.vm.filterTodos).toEqual([
    {
    
     id: 1, text: 'eat', done: false },
    {
    
     id: 3, text: 'sleep', done: false },
  ])

  // 将路由导航到 /completed
  wrapper.vm.$route.path = '/completed'
  await wrapper.vm.$nextTick()
  // 断言 filterTodos = 所有的已完成任务
  expect(wrapper.vm.filterTodos).toEqual([
    {
    
     id: 2, text: 'play', done: true },
  ])
})

Improve component functions

// template
<TodoItem
  v-for="todo in filterTodos"
  :key="todo.id"
  :todo="todo"
  @delete-todo="handleDeleteTodo"
  @edit-todo="handleEditTodo"
/>

// js
computed: {
    
    
  ...
  // 过滤数据
  filterTodos() {
    
    
    // 获取路由路径
    const path = this.$route.path

    // 根据路由路径过滤数据
    switch (path) {
    
    
      // 所有未完成任务
      case '/active':
        return this.todos.filter(t => !t.done)
      // 所有已完成任务
      case '/completed':
        return this.todos.filter(t => t.done)
      // 所有任务列表
      default:
        return this.todos
    }
  },
},

TodoFooter highlights navigation links (router-link)

Improve the navigation highlighting function

Because you want to use routing navigation, you can implement the function first, and then write the test

<ul class="filters">
  <li>
    <router-link to="/" exact>All</router-link>
  </li>
  <li>
    <router-link to="/active">Active</router-link>
  </li>
  <li>
    <router-link to="/completed">Completed</router-link>
  </li>
</ul>

Set the route highlighting style:

// src\router\index.js
...
const router = new VueRouter({
    
    
  routes,
  linkActiveClass: 'selected',
})

export default router

Write test cases

Now that the TodoFooter component uses <router-link>the component, Vue Router needs to be introduced, otherwise the error will be reported when running the test that the component is not registered.

The behavior of the test can be that only the navigation links that need to be highlighted have selectedclassname.

// src\components\TodoApp\__tests__\TodoFooter.js
import {
    
     shallowMount, createLocalVue, mount } from '@vue/test-utils'
import TodoFooter from '@/components/TodoApp/TodoFooter'
import VueRouter from 'vue-router'

// 创建局部 Vue
const localVue = createLocalVue()
// 为局部 Vue 注册 VueRouter,不影响其他 Vue
localVue.use(VueRouter)
const router = new VueRouter({
    
    
  linkActiveClass: 'selected',
})

describe('TodoFooter.js', () => {
    
    
  /** @type {import('@vue/test-utils').Wrapper} */
  let wrapper = null

  beforeEach(async () => {
    
    
    const todos = [
      {
    
     id: 1, text: 'eat', done: false },
      {
    
     id: 2, text: 'play', done: true },
      {
    
     id: 3, text: 'sleep', done: false },
    ]

    // 注意:使用原来的 shallowMount 不会渲染 router-link 子组件
    // 这里需改用 mount
    wrapper = mount(TodoFooter, {
    
    
      propsData: {
    
    
        todos,
      },
      // 挂载局部 Vue 和 router
      localVue,
      router,
    })
  })

  test('Done Todos Count', () => {
    
    
    ...
  })

  test('Clear Completed Show', () => {
    
    
    ...
    wrapper = shallowMount(TodoFooter, {
    
    
      propsData: {
    
    
        todos,
      },
      // 挂载局部 Vue 和 router
      localVue,
      router,
    })

    ...
  })

  test('Clear Completed', async () => {
    
    
    ...
  })

  test('Router Link ActiveClass', async () => {
    
    
    // findAllComponents 返回 WrapperArray,它并不是一个数组类型
    // 需要使用内部方法来访问
    const links = wrapper.findAllComponents({
    
     name: 'RouterLink' })

    // 切换路由
    router.push('/completed')
    await localVue.nextTick()

    for (let i = 0; i < links.length; i++) {
    
    
      const link = links.at(i)
      if (link.vm.to === '/completed') {
    
    
        expect(link.classes()).toContain('selected')
      } else {
    
    
        expect(link.classes()).not.toContain('selected')
      }
    }

    // 切换路由
    router.push('/active')
    await localVue.nextTick()

    for (let i = 0; i < links.length; i++) {
    
    
      const link = links.at(i)
      if (link.vm.to === '/active') {
    
    
        expect(link.classes()).toContain('selected')
      } else {
    
    
        expect(link.classes()).not.toContain('selected')
      }
    }

    // 切换路由
    router.push('/')
    await localVue.nextTick()

    for (let i = 0; i < links.length; i++) {
    
    
      const link = links.at(i)
      if (link.vm.to === '/') {
    
    
        expect(link.classes()).toContain('selected')
      } else {
    
    
        expect(link.classes()).not.toContain('selected')
      }
    }
  })
})

snapshot test

Now that the business functions of this application have basically been developed, I hope that when the development of these components is relatively stable (the style and structure do not need to be changed a lot), snapshot tests can be added to them to ensure that they are not modified inadvertently. The UI structure is tested in time.

Write test cases

// src\components\TodoApp\__tests__\TodoHeader.js
import {
    
     shallowMount } from '@vue/test-utils'
import TodoHeader from '@/components/TodoApp/TodoHeader'

describe('TodoHeader.vue', () => {
    
    
  // 将渲染组件放到 beforeEach
  let wrapper = null

  beforeEach(() => {
    
    
    wrapper = shallowMount(TodoHeader)
  })

  test('New todo', async () => {
    
    
    // 可以给元素添加一个专门用于测试的 `data-testid`,方便测试的时候获取这个元素
    const input = wrapper.findComponent('input[data-testid="new-todo"]')
    const text = 'play'

    ...
  })

  test('snapshot', () => {
    
    
    expect(wrapper.html()).toMatchSnapshot()
  })
})

__snapshots__By default, a folder will be created under the directory where the test file where the snapshot test is written to store the snapshot file, for example src\components\TodoApp\__tests__\__snapshots__\TodoHeader.js.snap.

Add snapshot tests to other components below:

// src\components\TodoApp\__tests__\TodoApp.js
// src\components\TodoApp\__tests__\TodoFooter.js
// src\components\TodoApp\__tests__\TodoItem.js

// 添加
test('snapshot', () => {
    
    
  expect(wrapper.html()).toMatchSnapshot()
})

update snapshot

In monitor mode, if the snapshot test fails and the modification is intentional, you can use uthe command to update all snapshot files.

You can also use ithe command to enter the interactive snapshot mode, which will re-execute the test case, ask how to deal with each test failure, and then execute the next one:

  • uUpdate the snapshot file for the current test
  • sskip current test
  • qexit interactive snapshot mode
  • Enterrerun the current test
  • rRerun interactive snapshot mode

Configure code coverage statistics

There may be some code that has not been tested, and there may be bugs during the running process, so it is best to keep the test coverage rate above 80%.

It is recommended to add a new script to run the Jest test, and use command parameters to specify the statistical code coverage, so as to avoid real-time statistical consumption during development:

"scripts": {
    
    
  ...
  "coverage": "vue-cli-service test:unit --coverage"
},

npm run coverageRunning the script will print the statistical results on the command line

insert image description here

coverageAnd generate a folder in the root directory of the project to store the coverage report, which can be opened coverage\lcov-report\index.htmland viewed on the page:

insert image description here

insert image description here

insert image description here

It can be seen that there is a branch in the TodoHeader component ifthat has not been tested, supplementing the test case:

// src\components\TodoApp\__tests__\TodoHeader.js
test('New todo with empty text', async () => {
    
    
  const input = wrapper.findComponent('input[data-testid="new-todo"]')
  const text = ''
  await input.setValue(text)
  await input.trigger('keyup.enter')
  // 断言不会对外发布自定义事件
  expect(wrapper.emitted()['new-todo']).toBeFalsy()
})

npm run coverageRe-statistic test coverage, refresh the report page to view the coverage of TodoHeader again.

Upload test coverage to codecov

It is generally not recommended to save the test coverage report in the project repository, .gitignoreadd the ignore coveragedirectory in:

# .gitignore
coverage

Reports can be uploaded to professional reporting analysis platforms such as Codecov .

Open the Codecov official website, bind the Github account to log in, and select the warehouse to display the test coverage

Note: The project git for uploading the report must be the selected git warehouse, otherwise, although the upload command will not report an error, it will not be uploaded to the codecov platform for display.

insert image description here

Copy the Codecov token (the warehouse that has not uploaded the report will display the getting started guide by default, and there is a token in Step2; the warehouse that has uploaded the report can copy the token from the Settings panel)

insert image description here

Then install Codecov:

npm i -D codecov
# 或者安装到全局
# npm i -g codecov

Generate a test coverage report:

# coverage 是运行 `jest -- coverrage` 的脚本 
npm run coverage

Upload the test coverage report to codecov:

# 运行项目安装的 codecov 上传报告
npx codecov --token=xxx
# 使用全局安装的 codecov
codecov --token=xxx

Revisit Codecov to see the report analysis, also including test coverage for each component.

insert image description here

Copy the Badge link in the Settings panel README.mdto display the codecov badge, display the test coverage, and let other developers know whether the application is safe and reliable.

insert image description here

The effect is as follows

insert image description here

Automated testing and continuous integration

This project uses Github Actions for continuous integration.

Configure Github Actions

Create new directories and files in the project root directory .github/workflows/main.yml:

# .github\workflows\main.yml
name: Publish And Deploy Demo

on:
  # 当提交 main 分支的代码的时候触发 action
  push:
    branches:
      - main
  # 或对 main 分支进行 pull request 的时候
  pull_request:
    branches:
      - main

jobs:
  build-and-deploy:
    # 运行环境
    runs-on: ubuntu-latest
    steps:
      # 下载仓库源码
      - name: Checkout
        uses: actions/checkout@main

      # 安装依赖 & 运行测试并生成覆盖率报告 & 项目打包
      - name: Install and Build
        run: |
          npm install
          npm run coverage
          npm run build

      # 发布到 GitHub Pages
      - name: Deploy
        uses: JamesIves/github-pages-deploy-[email protected]
        with:
          branch: gh-pages # The branch the action should deploy to.
          folder: dist # The folder the action should deploy.

      # 上传测试覆盖率报告到 codecov
      - name: codecov
        # 使用 codecov 官方提供的 action
        uses: codecov/codecov-action@v1
        with:
          token: ${
    
    {
    
     secrets.CODECOV_TOKEN }}

If the tests fail, the automated build breaks without deploying Github Pages and uploading coverage reports.

Github adds environment variables for storing codecov Token

insert image description here

Modify the packaging path

The Github Pages access address will have a second-level domain name (warehouse name) by default. For example http://xxx.github.io/vue-testing-demo/, the packaging path needs to be modified:

// vue.config.js
const {
    
     defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
    
    
  transpileDependencies: true,
  // 添加
  publicPath: '/vue-testing-demo'
})

submit code

push code, trigger action

insert image description here

insert image description here

After running successfully, you can visit Codecov to view the coverage report.

Specifies the fork hosting Github Pages

insert image description here

After the modification is completed, you need to re-push to trigger the automatic construction of Github Action and publish Github Pages.

Add workflow status badges

Add Github Actions status badge , add link to README.md:![example workflow](https://github.com/<OWNER>/<REPOSITORY>/actions/workflows/<WORKFLOW_FILE>/badge.svg)

  • <WORKFLOW_FILE>: The workflow file name .github/workflows/under the directory ..yml
  • <OWNER>: githubOrganization name
  • <REPOSITORY>: warehouse name

Corresponding to the current project is:![](https://github.com/<你的 github 用户名>/vue-testing-demo/actions/workflows/main.yml/badge.svg)

Effect:

insert image description here

Guess you like

Origin blog.csdn.net/u012961419/article/details/123785966
Recommended