TodoApp 切り替え すべて選択
すべてのタスク アイテムのステータスを変更するには、[すべて選択] ボタンをクリックします。ボタンのスタイルは、すべてのタスク アイテムの選択されたステータスに応じて変わります。
テストケースを書く
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()
})
コンポーネントの機能向上
// 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 の未処理のタスクの数
テストケースを書く
// 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)
})
})
コンポーネントの機能向上
<!-- 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>
必須の属性が TodoApp コンポーネントの TodoFooter コンポーネントに渡されることに注意してください。
<TodoFooter :todos="todos" />
TodoFooterは完了タスクボタンの表示状態をクリアします
テストケースを書く
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()
})
コンポーネントの機能向上
// 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)
},
},
完了したタスクをクリアする
Todoフッター
テストケースを書く
test('Clear Completed', async () => {
const button = wrapper.findComponent('[data-testid="clear-completed"]')
await button.trigger('click')
expect(wrapper.emitted()['clear-completed']).toBeTruthy()
})
コンポーネントの機能向上
// template
<button
v-if="isClearCompletedShow"
data-testid="clear-completed"
class="clear-completed"
@click="$emit('clear-completed')"
>
Clear completed
</button>
TodoApp
テストケースを書く
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 },
])
})
コンポーネントの機能向上
// template
<TodoFooter
:todos="todos"
@clear-completed="handleClearCompleted"
/>
// js
methods: {
...
handleClearCompleted() {
// 清除所有已完成的任务项
this.todos = this.todos.filter(t => !t.done)
},
},
TodoApp データのフィルタリング ($route)
ルーティング パスに基づいてタスク項目をフィルターするには、Vue Router とともに使用する必要があります。
- ローカル Vue (localVue) を作成し、その Vue-router を登録し、このローカル Vue に対してのみ有効にすることができます。
- ただし、これにより外部依存関係 (vue-router インスタンス) が増加し、各テストで vue-router をロードする必要があり、パフォーマンスが低下します。
$route
フェイク(モック)することをお勧めします$router
コンポーネントをルートとして構成する
<!-- 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
テストケースを書く
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 },
])
})
コンポーネントの機能向上
// 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 はナビゲーション リンク (ルーターリンク) を強調表示します。
ナビゲーションのハイライト機能を改善
ルーティング ナビゲーションを使用したいため、最初に関数を実装してからテストを作成できます。
<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>
ルートの強調表示スタイルを設定します。
// src\router\index.js
...
const router = new VueRouter({
routes,
linkActiveClass: 'selected',
})
export default router
テストケースを書く
TodoFooter コンポーネントが<router-link>
コンポーネントを使用するようになったので、Vue Router を導入する必要があります。導入しないと、テストの実行時にコンポーネントが登録されていないというエラーが報告されます。
テストの動作では、強調表示する必要があるナビゲーション リンクのみがselected
クラス名を持つようにすることができます。
// 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')
}
}
})
})
スナップショットテスト
このアプリケーションのビジネス機能は基本的に開発されたので、これらのコンポーネントの開発が比較的安定したら (スタイルと構造を大幅に変更する必要がない)、スナップショット テストをコンポーネントに追加して、 UI 構造は適時にテストされます。
テストケースを書く
// 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__
デフォルトでは、スナップショット テストが書き込まれるテスト ファイルが保存されるディレクトリの下に、スナップショット ファイルを保存するフォルダーが作成されます (例: ) src\components\TodoApp\__tests__\__snapshots__\TodoHeader.js.snap
。
以下の他のコンポーネントにスナップショット テストを追加します。
// src\components\TodoApp\__tests__\TodoApp.js
// src\components\TodoApp\__tests__\TodoFooter.js
// src\components\TodoApp\__tests__\TodoItem.js
// 添加
test('snapshot', () => {
expect(wrapper.html()).toMatchSnapshot()
})
スナップショットを更新する
モニター モードでは、スナップショット テストが失敗し、変更が意図的である場合、u
コマンドを使用してすべてのスナップショット ファイルを更新できます。
また、i
このコマンドを使用して対話型スナップショット モードに入ることができます。これにより、テスト ケースが再実行され、各テストの失敗に対処する方法が尋ねられ、次のテストが実行されます。
u
現在のテストのスナップショット ファイルを更新しますs
現在のテストをスキップするq
インタラクティブスナップショットモードを終了するEnter
現在のテストを再実行するr
インタラクティブスナップショットモードを再実行します
コードカバレッジ統計の構成
テストされていないコードが存在する可能性があり、実行プロセス中にバグが発生する可能性があるため、テスト カバレッジ率を 80% 以上に維持することが最善です。
開発中のリアルタイムの統計消費を避けるために、Jest テストを実行する新しいスクリプトを追加し、コマンド パラメーターを使用して統計コード カバレッジを指定することをお勧めします。
"scripts": {
...
"coverage": "vue-cli-service test:unit --coverage"
},
npm run coverage
スクリプトを実行すると、統計結果がコマンド ラインに出力されます。
そして、プロジェクトのルート ディレクトリにカバレッジ レポートを保存するcoverage
フォルダーを生成します。このフォルダーはcoverage\lcov-report\index.html
ページ上で開いて表示できます。
if
TodoHeader コンポーネントにはテストされていないブランチがあり、テスト ケースを補足していることがわかります。
// 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 coverage
統計テスト カバレッジを再統計し、レポート ページを更新して TodoHeader のカバレッジを再度表示します。
テストカバレッジを codecov にアップロードする
一般に、テスト カバレッジ レポートをプロジェクト リポジトリに保存することは推奨されません。.gitignore
無視するcoverage
ディレクトリを次の場所に追加します。
# .gitignore
coverage
レポートは、 Codecovなどの専門的なレポート分析プラットフォームにアップロードできます。
Codecov公式 Web サイトを開き、Github アカウントをバインドしてログインし、テスト カバレッジを表示するウェアハウスを選択します。
注: レポートをアップロードするためのプロジェクト git は、選択された git ウェアハウスである必要があります。そうでない場合、アップロード コマンドはエラーを報告しませんが、表示のために codecov プラットフォームにアップロードされません。
Codecov トークンをコピーします (レポートをアップロードしていないウェアハウスにはデフォルトでスタート ガイドが表示され、ステップ 2 にトークンがあります。レポートをアップロードしたウェアハウスは [設定] パネルからトークンをコピーできます)。
次に、Codecov をインストールします。
npm i -D codecov
# 或者安装到全局
# npm i -g codecov
テストカバレッジレポートを生成します。
# coverage 是运行 `jest -- coverrage` 的脚本
npm run coverage
テスト カバレッジ レポートを codecov にアップロードします。
# 运行项目安装的 codecov 上传报告
npx codecov --token=xxx
# 使用全局安装的 codecov
codecov --token=xxx
Codecov に再度アクセスして、各コンポーネントのテスト カバレッジを含むレポート分析を確認します。
[設定] パネルのバッジ リンクをコピーするREADME.md
と、codecov バッジが表示され、テスト カバレッジが表示され、アプリケーションが安全で信頼できるかどうかを他の開発者に知らせることができます。
効果は以下の通りです
自動テストと継続的統合
このプロジェクトでは、継続的統合のために Github Actions を使用します。
Github アクションの構成
プロジェクトのルート ディレクトリに新しいディレクトリとファイルを作成します.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 }}
テストが失敗すると、Github Pages のデプロイやカバレッジ レポートのアップロードが行われずに、自動ビルドが中断されます。
Github が codecov トークンを保存するための環境変数を追加
パッケージ化パスを変更する
Github Pages のアクセス アドレスには、デフォルトで第 2 レベルのドメイン名 (ウェアハウス名) が付けられます。たとえばhttp://xxx.github.io/vue-testing-demo/
、パッケージ化パスを変更する必要があります。
// vue.config.js
const {
defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
transpileDependencies: true,
// 添加
publicPath: '/vue-testing-demo'
})
コードを送信する
プッシュコード、トリガーアクション
正常に実行されたら、Codecov にアクセスしてカバレッジ レポートを表示できます。
Github ページをホストするフォークを指定します
変更が完了したら、再プッシュして Github Action の自動構築をトリガーし、Github Pages を公開する必要があります。
ワークフローステータスバッジを追加する
Github Actions ステータス バッジを追加し、README.md へのリンクを追加します。![example workflow](https://github.com/<OWNER>/<REPOSITORY>/actions/workflows/<WORKFLOW_FILE>/badge.svg)
<WORKFLOW_FILE>
:.github/workflows/
ディレクトリ内の.yml
ワークフロー ファイル名。<OWNER>
:github
組織名<REPOSITORY>
:倉庫名
現在のプロジェクトに対応するものは次のとおりです。![](https://github.com/<你的 github 用户名>/vue-testing-demo/actions/workflows/main.yml/badge.svg)
効果: