基于 TDD 模式編寫 Vue 評(píng)論組件(中):父子組件之間的通信測(cè)試

一、拆分評(píng)論列表組件
為了測(cè)試 Vue 父子組件之間的通信,我們需要將之前編寫的評(píng)論組件拆分成兩部分 —— 將評(píng)論列表拆分成獨(dú)立的子組件 CommentList,然后在 CommentComponent 中引入它。
在 resources/js/components 目錄下新建 ComponentList.vue,并初始化組件代碼如下:
<template>
<div>
<h3>所有評(píng)論</h3>
<ul class="comments" v-show="comments.length > 0">
<li v-for="(content, index) in comments" :key="index" v-text="content"></li>
</ul>
</div>
</template>
<script>
export default {
props: ['comments']
}
</script>
然后在父組件 CommentComponent.vue 中引入評(píng)論列表子組件:
<template>
<div>
<form @submit.prevent="addNewComment">
...
</form>
<ComponentList :comments="comments"></ComponentList>
</div>
</template>
<script>
import ComponentList from "./ComponentList";
export default {
components: {ComponentList},
...
}
</script>
運(yùn)行 npm run test 進(jìn)行回歸測(cè)試:

測(cè)試通過,說明此次代碼重構(gòu)沒有引入 bug,評(píng)論組件依然可以正常工作。
二、通過 props 屬性傳遞數(shù)據(jù)到子組件
接下來,我們?yōu)樽咏M件 CommentList 編寫單獨(dú)的測(cè)試用例用于測(cè)試父子組件之間的通信。
前面在介紹 Vue 組件通信原理時(shí),我們已經(jīng)知曉父子組件之間的通信機(jī)制:父組件通過 props 屬性傳遞數(shù)據(jù)給子組件,子組件通過 $emit 以事件觸發(fā)的方式將消息變更上報(bào)給父組件。
先來看通過 props 屬性實(shí)現(xiàn)從父組件與子組件的單向通信。
在 tests/JavaScript 新建一個(gè)測(cè)試文件 comment-list.spec.js,編寫第一個(gè)測(cè)試用例代碼如下:
import {mount} from "@vue/test-utils";
import CommentList from "../../resources/js/components/CommentList.vue";
describe('CommentList.vue', () => {
let wrapper;
beforeEach(() => {
wrapper = mount(CommentList, {
propsData: {
comments: ['測(cè)試評(píng)論']
}
})
})
it('should display comments passed from parent scope', function () {
expect(wrapper.find('ul.comments').isVisible()).toBe(true);
expect(wrapper.find('ul.comments').html()).toContain('測(cè)試評(píng)論');
});
})
在這個(gè)測(cè)試用例中,我們只是簡(jiǎn)單斷言如果父組件通過 props 屬性傳遞數(shù)據(jù)到子組件后,是否可以正常渲染評(píng)論列表和評(píng)論數(shù)據(jù),這里我們?cè)?beforeEach 中初始化組件掛載時(shí)就設(shè)置了默認(rèn)的 props 屬性模擬數(shù)據(jù)。
運(yùn)行 npm run watch-test 進(jìn)行自動(dòng)化測(cè)試,測(cè)試通過,表明通過 props 屬性從父組件傳遞數(shù)據(jù)到子組件后是可以正常工作的:

三、通過 $emit 事件觸發(fā)上報(bào)子組件消息
再來看通過 $emit 事件觸發(fā)實(shí)現(xiàn)從子組件到父組件的單向通信。
為了演示這個(gè)事件觸發(fā)通信的測(cè)試用例編寫,我們?yōu)樵u(píng)論列表子組件中的評(píng)論添加點(diǎn)贊功能,假設(shè)每條評(píng)論后面都有一個(gè)點(diǎn)贊/取消點(diǎn)贊操作按鈕,初始化情況下是點(diǎn)贊操作,點(diǎn)擊該按鈕會(huì)觸發(fā)父組件中定義的監(jiān)聽事件切換對(duì)應(yīng)評(píng)論的點(diǎn)贊狀態(tài),再經(jīng)由上面的 props 屬性鏈路將狀態(tài)變更通知給子組件,進(jìn)而在子組件中將該評(píng)論的點(diǎn)贊按鈕切換成取消點(diǎn)贊按鈕。
基于這樣的業(yè)務(wù)實(shí)現(xiàn)邏輯,我們?cè)?comment-list.spec.js 中編寫第二個(gè)測(cè)試用例代碼如下:
it('默認(rèn)可以點(diǎn)贊,點(diǎn)擊點(diǎn)贊按鈕觸發(fā)父級(jí)事件', () => {
// Given
let button = wrapper.find('button.vote-up'); // 默認(rèn)是點(diǎn)贊按鈕
expect(button.isVisible()).toBe(true);
expect(button.text()).toBe('點(diǎn)贊');
// When
button.trigger('click'); // 點(diǎn)擊點(diǎn)贊按鈕
// Then
expect(wrapper.emitted().voteToggle).toBeTruthy(); // 觸發(fā)父級(jí)點(diǎn)贊切換事件函數(shù)
expect(wrapper.emitted().voteToggle[0]).toEqual([0]); // 斷言傳遞參數(shù)
});
默認(rèn)情況下是點(diǎn)贊按鈕,點(diǎn)擊該按鈕,會(huì)觸發(fā)父級(jí)事件函數(shù) voteToggle,并且我們?cè)谕ㄟ^ $emit 觸發(fā)該事件函數(shù)時(shí)還傳遞了當(dāng)前評(píng)論的索引,以便父組件可以對(duì)號(hào)操作,以上事件觸發(fā)相關(guān)邏輯都可以通過斷言來實(shí)現(xiàn),代碼如上所示。
這個(gè)時(shí)候測(cè)試肯定不會(huì)通過:

因?yàn)樵u(píng)論列表子組件中還沒有相應(yīng)的按鈕元素,另外,由于每條評(píng)論都新增了點(diǎn)贊狀態(tài),所以相應(yīng)的評(píng)論模型數(shù)據(jù)結(jié)構(gòu)也要做調(diào)整,先在子組件中 CommentList 中添加點(diǎn)贊和對(duì)應(yīng)的點(diǎn)贊狀態(tài)屬性:
<template>
<div>
<h3>所有評(píng)論</h3>
<ul class="comments" v-show="comments.length > 0">
<li v-for="(comment, index) in comments" :key="index">
{{ comment.content }}
<button class="vote-up" v-if="!comment.voted">點(diǎn)贊</button>
<button class="vote-down" v-if="comment.voted">取消點(diǎn)贊</button>
</li>
</ul>
</div>
</template>
<script>
export default {
props: ['comments']
}
</script>
然后在父組件 CommentComponent 中為評(píng)論模型添加 voted 屬性表示點(diǎn)贊狀態(tài):
<template>
<div>
<form @submit.prevent="addNewComment">
<div class="form-group">
<textarea v-model="content" class="form-control" name="content" rows="3" placeholder="請(qǐng)輸入評(píng)論內(nèi)容..."></textarea>
</div>
...
</form>
...
</div>
</template>
<script>
...
export default {
...
data() {
return {
content: '',
comments: []
}
},
methods: {
addNewComment() {
let comment = {
content: this.content,
voted: false
}
this.comments.push(comment);
this.content = '';
}
}
}
</script>
保存代碼,此時(shí)原來的測(cè)試用例會(huì)不通過:

因?yàn)樵u(píng)論模型數(shù)據(jù)結(jié)構(gòu)調(diào)整,所以需要修改 comment.spec.js 對(duì)應(yīng)的測(cè)試用例代碼:
beforeEach(() => {
wrapper = mount(CommentComponent, {
comment: {
content: '',
voted: false
},
comments: []
})
})
...
it('typed comment will sync with model data ', function () {
// Given
let comment = '大家好,我是學(xué)院君。';
wrapper.find('textarea[name=content]').element.value = comment;
// When
wrapper.find('textarea[name=content]').trigger('input');
// Then
expect(wrapper.vm.content).toBe(comment);
});
it('click submit button will render comment in comments list', function () {
// Given
expect(wrapper.find('ul.comments').isVisible()).toBe(false);
let comment = '大家好,我是學(xué)院君。';
wrapper.find('textarea[name=content]').element.value = comment;
wrapper.find('textarea[name=content]').trigger('input');
// When
wrapper.find('button[type=submit]').trigger('submit');
// Then
expect(wrapper.vm.comments.length).toBe(1);
expect(wrapper.vm.comments[0].content).toBe(comment);
wrapper.vm.$nextTick(() => {
// 需要將這兩個(gè)斷言放到 Vue.nextTick 中執(zhí)行,因?yàn)樗鼈冃枰?nbsp;DOM 刷新之后才會(huì)生效
expect(wrapper.find('ul.comments').isVisible()).toBe(true);
expect(wrapper.find('ul.comments').html()).toContain(comment);
})
});
這樣一來,父組件之前的測(cè)試用例都能正常通過了,但是子組件測(cè)試用例還是沒有通過:

因?yàn)楦缸咏M件之間也還沒有實(shí)現(xiàn)對(duì)應(yīng)的事件觸發(fā)和處理代碼,我們?cè)谧咏M件中編寫點(diǎn)贊/取消點(diǎn)贊按鈕的事件觸發(fā)邏輯如下:
<button class="vote-up" v-if="!comment.voted" @click="$emit('voteToggle', index)">點(diǎn)贊</button>
<button class="vote-down" v-if="comment.voted" @click="$emit('voteToggle', index)">取消點(diǎn)贊</button>
然后在父組件中編寫對(duì)應(yīng)的事件監(jiān)聽和處理代碼如下:
<template>
<div>
...
<comment-list ref="comments" :comments="comments" @voteToggle="voteToggle"></comment-list>
</div>
</template>
<script>
import CommentList from "./CommentList.vue";
export default {
...
methods: {
...
voteToggle(index) {
this.comments[index].voted = !this.comments[index].voted;
}
}
}
</script>
此時(shí),子組件通過 $emit 事件觸發(fā)機(jī)制與父組件通信的測(cè)試用例就通過了,當(dāng)然這個(gè)只限于子組件已經(jīng)將消息通知給父組件,

至于父組件是否處理成功,則需要在父組件測(cè)試用例中編寫相應(yīng)的測(cè)試代碼:
it('監(jiān)聽子組件觸發(fā)的 voteToggle 事件函數(shù)并進(jìn)行處理', function () {
// Given
wrapper.setData({
comments: [
{
content: '測(cè)試評(píng)論',
voted: false
}
]
})
expect(wrapper.vm.comments[0].voted).toBe(false);
// When
wrapper.vm.$refs.comments.$emit('voteToggle', 0);
// Then
expect(wrapper.vm.comments[0].voted).toBe(true);
});
測(cè)試通過,表明父組件可以正常處理子組件通過 $emit 觸發(fā)的事件函數(shù):

最后,我們?cè)俚阶咏M件測(cè)試文件 comment-list.spec.js 中編寫第三個(gè)測(cè)試用例,測(cè)試如果評(píng)論的 voted 屬性為 true,對(duì)應(yīng)的按鈕渲染邏輯是否正常:
it('如果評(píng)論狀態(tài)是已點(diǎn)贊,則按鈕狀態(tài)為取消點(diǎn)贊', () => {
wrapper.setProps({
comments: [
{
content: '測(cè)試評(píng)論',
voted: true
}
]
});
expect(wrapper.props('comments')[0].voted).toBe(true);
wrapper.vm.$nextTick(() => {
let button = wrapper.find('button.vote-down');
expect(button.isVisible()).toBe(true);
expect(button.text()).toBe('取消點(diǎn)贊');
})
});
測(cè)試通過,表明父子組件之間的通信可以順暢進(jìn)行,父子組件都可以正常工作:

實(shí)際項(xiàng)目中,無論是新增評(píng)論還是評(píng)論點(diǎn)贊,都需要對(duì)數(shù)據(jù)和狀態(tài)進(jìn)行持久化,這就涉及到后端接口的調(diào)用,那么如何在 Vue 組件中測(cè)試接口調(diào)用呢,限于篇幅,學(xué)院君將在下篇教程給大家演示。
本系列教程首發(fā)在Laravel學(xué)院(laravelacademy.org),你可以點(diǎn)擊頁面左下角閱讀原文鏈接查看最新更新的教程。
