Vue测试驱动开发(TDD)的应用-vitest

267 阅读4分钟

介绍

TDD

TDD 测试驱动开发,基于代码测试用例进行开发,能够确保代码质量,并能够反向驱动代码的开发,一步步推进。可以大大减少开发中,反复修复bug的时候。

vitest

vitest很好的集成到vue3中,用法与jest类似。

搭建

安装

npm create vue@latest
# 选择 ts(是) -> jsx(是) -> vue router(是) ->  pinia(是) -> vitest (是) -> -> e2e (否) -> eslint(否) -> Vue DevTools(否)
✔ 请输入项目名称: … myvitest
✔ 是否使用 TypeScript 语法? … 否 / 是(是)
✔ 是否启用 JSX 支持? … 否 / 是(是)
✔ 是否引入 Vue Router 进行单页面应用开发? … 否 / 是(是)
✔ 是否引入 Pinia 用于状态管理? … 否 / 是(是)
✔ 是否引入 Vitest 用于单元测试? … 否 / 是(是)
✔ 是否要引入一款端到端(End to End)测试工具? › 不需要(否)
✔ 是否引入 ESLint 用于代码质量检测? … 否 / 是(否)
✔ 是否引入 Vue DevTools 7 扩展用于调试? (试验阶段) … 否 / 是(否)

# 安装
npm i

代码结构

image.png

配置介绍

vitest有自己专门的config vitest.config.ts

这里定义了支持vue语法和dom的一些库

import { defineConfig } from 'vitest/config';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
  plugins: [vue()],
  test: {
    environment: 'jsdom',
    globals: true,
  },
});

用例一般用__test__文件夹命名

生成的用例代码

image.png

src/components/HelloWorld.vue

<script setup lang="ts">
defineProps<{
  msg: string
}>()
</script>

<template>
  <div class="greetings">
    <h1 class="green">{{ msg }}</h1>
    <h3>
      You’ve successfully created a project with
      <a href="https://vitejs.dev/" target="_blank" rel="noopener">Vite</a> +
      <a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>. What's next?
    </h3>
  </div>
</template>

<style scoped> 
  ...
</style>

src/components/tests/HelloWorld.spec.ts

import { describe, it, expect } from 'vitest'

import { mount } from '@vue/test-utils'
import HelloWorld from '../HelloWorld.vue'

describe('HelloWorld', () => {
  it('renders properly', () => {
    const wrapper = mount(HelloWorld, { props: { msg: 'Hello Vitest' } })
    expect(wrapper.text()).toContain('Hello Vitest')
  })
})

该用例测试测试传入的msg,是否在挂载后渲染出来

测试用例用describe包裹,用it包裹测试用例,用expect包裹期望值,用toBe包裹期望值

运行

npm run test:unit

注意

用create vue@latest生成的代码, vitest的版本使用的是^1.6.0,,在运行npm run test:unit会有bug提示 image.png

修复办法1

  1. 在node执行前先执行unset NODE_OPTIONS 清空变量,再执行npm run test:unit
  2. 或者在package.json里面的script "test:unit": "unset NODE_OPTIONS && vitest"

修复办法2

修改vitest版本和配置

修改package.json,把 "vitest": "^1.6.0" 改成 => "vitest": "latest" 使用最新版 并且修改

tsconfig.app.json 删除 "extends": "@vue/tsconfig/tsconfig.dom.json",

tsconfig.node.json 删除 "extends": "@tsconfig/node20/tsconfig.json",

记得删除package-lock.json和node_modules再重新执行。npm i安装

然后再运行

image.png

nice~

进阶

介绍纯js和vue组件相关的测试逻辑。新增的代码结构如下 image.png

常用方法

  1. describe:用于分组测试,可以描述一个测试组的行为。
  2. it / test:定义单个测试用例。
  3. expect:用于断言测试结果。
  4. beforeEach / afterEach:在每个测试之前/之后运行的钩子。
  5. beforeAll / afterAll:在所有测试之前/之后运行的钩子。

纯js的用例

常用法

import { describe, it, expect } from 'vitest';

describe('Array', () => {
  it('should start empty', () => {
    const arr = [];
    expect(arr).toHaveLength(0);
  });

  it('should add elements correctly', () => {
    const arr = [];
    arr.push(1);
    expect(arr).toHaveLength(1);
    expect(arr[0]).toBe(1);
  });
});

异步使用

 import { describe, it, expect } from 'vitest';

describe('Async function', () => {
  it('should resolve', async () => {
    const asyncFunc = () => new Promise((resolve) => setTimeout(() => resolve('done'), 100));
    const result = await asyncFunc();
    expect(result).toBe('done');
  });

  it('should reject', async () => {
    const asyncFunc = () => new Promise((_, reject) => setTimeout(() => reject(new Error('error')), 100));
    await expect(asyncFunc()).rejects.toThrow('error');
  });
});

各种钩子逻辑

 import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';

let globalCounter = 0;

beforeAll(() => {
  // 在所有测试之前运行
  console.log('beforeAll');
});

afterAll(() => {
  // 在所有测试之后运行
  console.log('afterAll');
});

describe('Counter', () => {
  beforeEach(() => {
    // 在每个测试之前运行
    globalCounter = 0;
  });

  afterEach(() => {
    // 在每个测试之后运行
    console.log('afterEach');
  });

  it('should be 0 initially', () => {
    expect(globalCounter).toBe(0);
  });

  it('should increment', () => {
    globalCounter++;
    expect(globalCounter).toBe(1);
  });
});

vue组件例子

计数组件 src/components/Counter.vue

<template>
  <div>
    <p>{{ count }}</p>
    <button @click="increment">Increment</button>
    <button @click="decrement">Decrement</button>
  </div>
</template>

<script lang="ts">
import { defineComponent, ref } from 'vue';

export default defineComponent({
  name: 'Counter',
  setup() {
    const count = ref(0);

    const increment = () => {
      count.value++;
    };

    const decrement = () => {
      count.value--;
    };

    return {
      count,
      increment,
      decrement
    };
  }
});
</script>

Counter 对应用例 src/components/Counter.test.ts

import { mount } from '@vue/test-utils';
import { describe, it, expect } from 'vitest';
import Counter from '../Counter.vue';

describe('Counter.vue', () => {
  it('renders initial count', () => {
    const wrapper = mount(Counter);
    expect(wrapper.text()).toContain('0');
  });

  it('increments count when Increment button is clicked', async () => {
    const wrapper = mount(Counter);
    const button = wrapper.find('button:first-of-type');
    await button.trigger('click');
    expect(wrapper.text()).toContain('1');
  });

  it('decrements count when Decrement button is clicked', async () => {
    const wrapper = mount(Counter);
    const incrementButton = wrapper.find('button:first-of-type');
    const decrementButton = wrapper.find('button:last-of-type');
    await incrementButton.trigger('click');
    await decrementButton.trigger('click');
    expect(wrapper.text()).toContain('0');
  });
  
   it('increments and decrements count', async () => {
    const wrapper = mount(Counter);
    const vm = wrapper.vm as any;

    // 检查初始状态
    expect(vm.count).toBe(0);

    // 调用方法并检查状态变化
    vm.increment();
    await wrapper.vm.$nextTick();
    expect(vm.count).toBe(1);

    vm.decrement();
    await wrapper.vm.$nextTick();
    expect(vm.count).toBe(0);
  });
}); 

SlotComponent插槽测试src/components/SlotComponent.vue

<template>
  <div>
    <slot></slot>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue';

export default defineComponent({
  name: 'SlotComponent'
});
</script>

SlotComponent用例 src/components/SlotComponent.test.ts

import { mount } from '@vue/test-utils';
import { describe, it, expect } from 'vitest';
import SlotComponent from '../SlotComponent.vue';

describe('SlotComponent.vue', () => {
  it('renders slot content', () => {
    const wrapper = mount(SlotComponent, {
      slots: {
        default: '<p>Hello, Slot!</p>'
      }
    });
    expect(wrapper.html()).toContain('<p>Hello, Slot!</p>');
  });
});

AsyncComponent.vue测试异步组件 src/components/AsyncComponent.vue

 <!--  -->
<template>
  <div>
    <p v-if="loading">Loading...</p>
    <p v-else>{{ data }}</p>
  </div>
</template>

<script lang="ts">
import { defineComponent, ref, onMounted } from 'vue';

export default defineComponent({
  name: 'AsyncComponent',
  setup() {
    const loading = ref(true);
    const data = ref('');

    onMounted(async () => {
      const response = await fetch('https://api.example.com/data');
      const result = await response.json();
      data.value = result.data;
      loading.value = false;
    });

    return {
      loading,
      data
    };
  }
});
</script>

AsyncComponent.vue测试用例 src/components/AsyncComponent.test.ts

import { mount } from '@vue/test-utils';
import { describe, it, expect, vi } from 'vitest';
import AsyncComponent from '../AsyncComponent.vue';

// 模拟 fetch API
global.fetch = vi.fn(() =>
  Promise.resolve({
    json: () => Promise.resolve({ data: 'Hello, Async!' }),
  })
);

describe('AsyncComponent.vue', () => {
  it('renders loading state initially', () => {
    const wrapper = mount(AsyncComponent);
    expect(wrapper.text()).toContain('Loading...');
  });

  it('renders data after fetch', async () => {
    const wrapper = mount(AsyncComponent);
    await new Promise(setImmediate); // 等待异步任务完成
    await wrapper.vm.$nextTick(); // 等待 Vue 组件更新
    expect(wrapper.text()).toContain('Hello, Async!');
  });
});

参考代码

mjsong07/myvitest: vitest的demo例子 (github.com)