基于 TDD 模式编写 Vue 评论组件(中):父子组件之间的通信测试


拆分评论列表组件

为了测试 Vue 父子组件之间的通信,我们需要将之前编写的评论组件拆分成两部分 —— 将评论列表拆分成独立的子组件 CommentList,然后在 CommentComponent 中引入它。

resources/js/components 目录下新建 ComponentList.vue,并初始化组件代码如下:

<template>
    <div>
        <h3>所有评论</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 中引入评论列表子组件:

<template>
    <div>
        <form @submit.prevent="addNewComment">
            ...
        </form>
        <ComponentList :comments="comments"></ComponentList>
    </div>
</template>

<script>
import ComponentList from "./ComponentList";
export default {
    components: {ComponentList},
    ...
}
</script>

运行 npm run test 进行回归测试:

-w755

测试通过,说明此次代码重构没有引入 bug,评论组件依然可以正常工作。

通过 props 属性传递数据到子组件

接下来,我们为子组件 CommentList 编写单独的测试用例用于测试父子组件之间的通信。

前面在介绍 Vue 组件通信原理时,我们已经知晓父子组件之间的通信机制:父组件通过 props 属性传递数据给子组件,子组件通过 $emit 以事件触发的方式将消息变更上报给父组件。

先来看通过 props 属性实现从父组件与子组件的单向通信。

tests/JavaScript 新建一个测试文件 comment-list.spec.js,编写第一个测试用例代码如下:

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: ['测试评论']
            }
        })
    })

    it('should display comments passed from parent scope', function () {
        expect(wrapper.find('ul.comments').isVisible()).toBe(true);
        expect(wrapper.find('ul.comments').html()).toContain('测试评论');
    });
})

在这个测试用例中,我们只是简单断言如果父组件通过 props 属性传递数据到子组件后,是否可以正常渲染评论列表和评论数据,这里我们在 beforeEach 中初始化组件挂载时就设置了默认的 props 属性模拟数据。

运行 npm run watch-test 进行自动化测试,测试通过,表明通过 props 属性从父组件传递数据到子组件后是可以正常工作的:

-w768

通过 $emit 事件触发上报子组件消息

再来看通过 $emit 事件触发实现从子组件到父组件的单向通信。

为了演示这个事件触发通信的测试用例编写,我们为评论列表子组件中的评论添加点赞功能,假设每条评论后面都有一个点赞/取消点赞操作按钮,初始化情况下是点赞操作,点击该按钮会触发父组件中定义的监听事件切换对应评论的点赞状态,再经由上面的 props 属性链路将状态变更通知给子组件,进而在子组件中将该评论的点赞按钮切换成取消点赞按钮。

基于这样的业务实现逻辑,我们在 comment-list.spec.js 中编写第二个测试用例代码如下:

it('默认可以点赞,点击点赞按钮触发父级事件', () => {
    // Given
    let button = wrapper.find('button.vote-up');  // 默认是点赞按钮
    expect(button.isVisible()).toBe(true);
    expect(button.text()).toBe('点赞');

    // When
    button.trigger('click');  // 点击点赞按钮

    // Then
    expect(wrapper.emitted().voteToggle).toBeTruthy();   // 触发父级点赞切换事件函数
    expect(wrapper.emitted().voteToggle[0]).toEqual([0]);  // 断言传递参数
});

默认情况下是点赞按钮,点击该按钮,会触发父级事件函数 voteToggle,并且我们在通过 $emit 触发该事件函数时还传递了当前评论的索引,以便父组件可以对号操作,以上事件触发相关逻辑都可以通过断言来实现,代码如上所示。

这个时候测试肯定不会通过:

-w1054

因为评论列表子组件中还没有相应的按钮元素,另外,由于每条评论都新增了点赞状态,所以相应的评论模型数据结构也要做调整,先在子组件中 CommentList 中添加点赞和对应的点赞状态属性:

<template>
    <div>
        <h3>所有评论</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">点赞</button>
                <button class="vote-down" v-if="comment.voted">取消点赞</button>
            </li>
        </ul>
    </div>
</template>

<script>
export default {
    props: ['comments']
}
</script>

然后在父组件 CommentComponent 中为评论模型添加 voted 属性表示点赞状态:

<template>
    <div>
        <form @submit.prevent="addNewComment">
            <div class="form-group">
                <textarea v-model="content" class="form-control" name="content" rows="3" placeholder="请输入评论内容..."></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>

保存代码,此时原来的测试用例会不通过:

-w775

因为评论模型数据结构调整,所以需要修改 comment.spec.js 对应的测试用例代码:

beforeEach(() => {
    wrapper = mount(CommentComponent, {
        comment: {
            content: '',
            voted: false
        },
        comments: []
    })
})

...

it('typed comment will sync with model data ', function () {
    // Given
    let comment = '大家好,我是学院君。';
    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 = '大家好,我是学院君。';
    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(() => {
        // 需要将这两个断言放到 Vue.nextTick 中执行,因为它们需要在 DOM 刷新之后才会生效
        expect(wrapper.find('ul.comments').isVisible()).toBe(true);
        expect(wrapper.find('ul.comments').html()).toContain(comment);
    })
});

这样一来,父组件之前的测试用例都能正常通过了,但是子组件测试用例还是没有通过:

-w845

因为父子组件之间也还没有实现对应的事件触发和处理代码,我们在子组件中编写点赞/取消点赞按钮的事件触发逻辑如下:

<button class="vote-up" v-if="!comment.voted" @click="$emit('voteToggle', index)">点赞</button>
<button class="vote-down" v-if="comment.voted" @click="$emit('voteToggle', index)">取消点赞</button>

然后在父组件中编写对应的事件监听和处理代码如下:

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

此时,子组件通过 $emit 事件触发机制与父组件通信的测试用例就通过了,当然这个只限于子组件已经将消息通知给父组件,

-w754

至于父组件是否处理成功,则需要在父组件测试用例中编写相应的测试代码:

it('监听子组件触发的 voteToggle 事件函数并进行处理', function () {
    // Given
    wrapper.setData({
        comments: [
            {
                content: '测试评论',
                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);
});

测试通过,表明父组件可以正常处理子组件通过 $emit 触发的事件函数:

-w817

最后,我们再到子组件测试文件 comment-list.spec.js 中编写第三个测试用例,测试如果评论的 voted 属性为 true,对应的按钮渲染逻辑是否正常:

it('如果评论状态是已点赞,则按钮状态为取消点赞', () => {
    wrapper.setProps({
        comments: [
            {
                content: '测试评论',
                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('取消点赞');
    })
});

测试通过,表明父子组件之间的通信可以顺畅进行,父子组件都可以正常工作:

-w705

实际项目中,无论是新增评论还是评论点赞,都需要对数据和状态进行持久化,这就涉及到后端接口的调用,那么如何在 Vue 组件中测试接口调用呢,限于篇幅,学院君将在下篇教程给大家演示。


Vote Vote Cancel Collect Collect Cancel

<< 上一篇: 基于 TDD 模式在 Laravel 项目中开发后端评论接口

>> 下一篇: 基于 TDD 模式编写 Vue 评论组件(下):Axios 请求后端接口测试