入门级Vitest

981 阅读11分钟

1 什么是vitest?

Vitest 是由 Vite 驱动的下一代测试框架。 相比传统的Jest框架,Vitest有以下四大优点:

  1. 配置简单:Vitest复用Vite的配置和插件;
  2. 和Jest兼容:expect, snapshot, coverage这些熟悉的特性和Jest一样,方便从Jest迁移;
  3. HMR: 只编译运行修改的部分,速度更快;
  4. 开箱即用的ESModule、Typescript、JSX。

注意:

Vitest 1.0 requires Vite >=v5.0.0 and Node >=v18.0.0

2 安装

npm create vue vitest-demo

安装过程中根据项目需要选择对应的功能或插件。

image.png

安装完成后,查看项目根目录有2个配置文件,vitest.config.ts的配置优先级要高于vite.config.ts。

image.png

vitest.config.ts

import { fileURLToPath } from 'node:url'
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
import viteConfig from './vite.config'

export default mergeConfig(
  viteConfig,
  defineConfig({
    test: {
      environment: 'jsdom',
      exclude: [...configDefaults.exclude, 'e2e/**'],
      root: fileURLToPath(new URL('./', import.meta.url))
    }
  })
)

先找到运行测试用例的命令,然后跑一下试试。

package.json image.png

npm run test:unit

image.png

可以看到显示1个测试用例成功了,耗时1.11s。

3 第一个Test

测试Math.sqrt()函数

image.png

squareroot.test.ts

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

describe('square root', () => {
  it('finds squareroot', () => {
    expect(Math.sqrt(49)).toBe(7)
  })
})

test/it: 测试验证或断言代码块,也就是单个测试用例。第一个参数是一个字符串,读起来就像带有“it”或“test”的句子,第二个参数是包含断言的箭头函数。

expect: 这是实际测试发生的地方。Expect 接受有效的 javascript 操作作为参数,并返回一个表示不同形式断言的对象。可以在此处找到完整的断言列表。

describe: 描述了测试套件,它是完全可选的,还可以使用它将相关测试分组。

保存后,可以看到测试用例运行了。

image.png

4 Component Testing

组件测试是基础测试,这里示例一个显示通知的简单组件的测试。

image.png

4.1 创建通知组件

src/components/NotificationToast.vue

<template>
    <div role="alert" :class="[
        'notification',
        status === 'error' ? 'notification--error' : null,
        status === 'success' ? 'notification--success' : null,
        status === 'info' ? 'notification--info' : null,
        message && message.length > 0 ? 'notification--slide' : null,
    ]">
        <p class="notification__text">
            {{ message }}
        </p>
        <button title="close" ref="closeButton" class="notification__button" @click="$emit('clear-notification')">
            &#10005;
        </button>
    </div>
</template>

<script setup>
defineProps({
    status: {
        type: String,
        default: null
    },
    message: {
        type: String,
        default: null
    }
})
</script>

<style>
.notification {
    transition: all 900ms ease-out;
    opacity: 0;
    z-index: 300001;
    transform: translateY(-100vh);
    box-sizing: border-box;
    padding: 10px 15px;
    width: 100%;
    max-width: 730px;
    display: flex;
    position: fixed;
    top: 20px;
    right: 15px;
    justify-content: flex-start;
    align-items: center;
    border-radius: 8px;
    min-height: 48px;
    box-sizing: border-box;
    color: #fff;
}

.notification--slide {
    transform: translateY(0px);
    opacity: 1;
}

.notification__text {
    margin: 0;
    margin-left: 17px;
    margin-right: auto;
}

.notification--error {
    background-color: #fdecec;
}

.notification--error .notification__text {
    color: #f03d3e;
}

.notification--success {
    background-color: #e1f9f2;
}

.notification--success .notification__text {
    color: #146354;
}

.notification--info {
    background-color: #9AC7F5;
}

.notification__button {
    border: 0;
    background-color: transparent;
    cursor: pointer;
}
</style>

src/App.vue

<script setup>
import { ref } from "vue";
import NotificationToast from "./components/NotificationToast.vue";

const message = ref("Image uploaded successfully")

const clearNotification = () => {
  message.value = ""
}

</script>

<template>
  <NotificationToast status="success" :message=message @clear-notification="clearNotification"/>
</template>

4.2 安装测试依赖

Vue Test Utils 提供了一组实用函数和方法来以隔离的方式安装 Vue 组件并与之交互。

这个虚拟的组件称为stub,要在测试中使用stub,需要访问 Vue Test Utils(Vue.js 的官方测试实用程序库)中的挂载(mount)方法。通过vue的脚手架创建的项目会自动安装@vue/test-utils,手动安装需要执行以下命令:

npm install --save-dev @vue/test-utils@next

还需要能够在测试时模拟浏览器环境,能够访问一些浏览器 API,并允许在不受实际环境(即实际浏览器)干扰的情况下测试组件。 Vitest 目前支持两个包可以实现这一目标:happy-dom 和 jsdom。打开 package.json,可以看到在安装过程中自动安装了 jsdom。

image.png

4.3 测试什么?

组件如果有属性,需要覆盖每一个属性的每一种情况,特别是边界情况,如空。如果有事件,需要覆盖到事件。

  • 该组件根据通知状态呈现正确的样式。
  • 当消息为空时,通知会向上滑动。
  • 单击关闭按钮时,该组件会发出一个事件

src/components/tests/Notification.test.ts

describe("Notification Component", () => { });

4.3.1 第一个功能测试

error, success, info的状态时组件包含对应的样式class

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

describe('Notification component', () => {
  test('renders the correct style for error', () => {
    const status = 'error'
    const wrapper = mount(NotificationToast, {
      props: { status }
    })
    expect(wrapper.classes()).toEqual(
        expect.arrayContaining(['notification--error']))
  })
  test('renders correct style for success', () => {
    const status = 'success'
    const wrapper = mount(NotificationToast, {
      props: { status }
    })
    expect(wrapper.classes()).toEqual(
        expect.arrayContaining(['notification--success']))
  })

  test('renders correct style for info', () => {
    const status = 'info'
    const wrapper = mount(NotificationToast, {
      props: { status }
    })
    expect(wrapper.classes()).toEqual(
        expect.arrayContaining(['notification--info']))
  })
})

还需要测试组件是否向查看器呈现正确的消息。

  test('renders correct message to viewer', () => {
    const message = 'Something happened, try again'
    const wrapper = mount(NotificationToast, {
      props: { message }
    })
    expect(wrapper.find('p').text()).toBe(message)
  })

4.3.2 第二个功能测试

当消息为空时,样式的class不包含‘notification--slide’

test('slides up when message is empty', () => {
    const message = ''
    const wrapper = mount(NotificationToast, {
      props: { message }
    })
    expect(wrapper.classes('notification--slide')).toBe(false)
  })

4.3.3 第三个功能测试

点击按钮,发出事件'clear-notification'

  test('emits event when close button is clicked', async () => {
    const wrapper = mount(NotificationToast, {
      data() {
        return {
          clicked: false
        }
      }
    })
    const closeButton = wrapper.find('button')
    await closeButton.trigger('click')
    expect(wrapper.emitted()).toHaveProperty('clear-notification')
  })

这里使用了trigger函数触发点击事件,该函数接受一个事件(点击、聚焦、模糊、按下按键等),执行该事件并返回一个promise。因此,等待此操作,以确保在根据此事件做出断言之前已对 DOM 进行更改。之后,使用emitted函数检查组件已发射的事件列表,该方法返回事件数组。

完整的测试

describe('Notification component', () => {
 
  test('renders the correct style for error', () => {
    const status = 'error'
    const wrapper = mount(NotificationToast, {
      props: { status }
    })
    expect(wrapper.classes()).toEqual(
        expect.arrayContaining(['notification--error']))
  })

  test('renders correct style for success', () => {
    const status = 'success'
    const wrapper = mount(NotificationToast, {
      props: { status }
    })
    expect(wrapper.classes()).toEqual(expect.arrayContaining(['notification--success']))
  })

  test('renders correct style for info', () => {
    const status = 'info'
    const wrapper = mount(NotificationToast, {
      props: { status }
    })
    expect(wrapper.classes()).toEqual(expect.arrayContaining(['notification--info']))
  })

  test('slides up when message is empty', () => {
    const message = ''
    const wrapper = mount(NotificationToast, {
      props: { message }
    })
    expect(wrapper.classes('notification--slide')).toBe(false)
  })

  test('emits event when close button is clicked', async () => {
    const wrapper = mount(NotificationToast, {
      data() {
        return {
          clicked: false
        }
      }
    })
    const closeButton = wrapper.find('button')
    await closeButton.trigger('click')
    expect(wrapper.emitted()).toHaveProperty('clear-notification')
  })

  test('renders correct message to viewer', () => {
    const message = 'Something happened, try again'
    const wrapper = mount(NotificationToast, {
      props: { message }
    })
    expect(wrapper.find('p').text()).toBe(message)
  })
})

5 API Mock Testing

模拟REST API进行接口测试

5.1 创建卡片组件

使用axios请求接口,JSONPlaceholder模拟接口返回

image.png

src/components/PostCard.vue

<template>
    <div>
        <div v-if="post">
            <h1 data-testid="post-title">{{ post.title }}</h1>
            <p data-testid="post-body">{{ post.body }}</p>
        </div>
        <p v-if="loading" data-testid="loader">Loading...</p>
        <p v-if="error" data-testid="error-message">{{ error }}</p>
    </div>
</template>

<script setup>

import { ref, onMounted } from "vue";
import axios from "axios";

const post = ref(null);
const loading = ref(true);
const error = ref(null);

const fetchPost = async () => {
    try {
        const { data } = await axios.get(
            "https://jsonplaceholder.typicode.com/posts/1"
        );
        post.value = data;
    } catch (err) {
        error.value = err.message;
    } finally {
        loading.value = false;
    }
};

onMounted(() => {
    fetchPost();
});

</script>

5.2 测试什么?

  • 确保程序成功发出 API 请求,并在请求成功时显示正确的数据;
  • 通过在 API 请求失败时显示错误消息来测试应用程序是否正确处理错误。

在mount PostCard 之前,需要能够拦截使用 Axios 发出的 GET 请求并返回模拟值。这样做是因为不想向真正的 API 服务器发出请求。请记住,模拟允许单独进行测试。 为此,可以使用 Vitest 中的spy功能。 Vitest 提供了实用程序函数,可通过其 vi 帮助完成此操作。可以从“vitest”导入 { vi } 或在启用 Vitest 全局配置时全局访问它。 这里全局配置它,这样就可以使用 Vitest 中的函数(例如describe、expect和test),而不必每次都导入它们。 Vitest 配置文件中将全局设置为 true:

import { defineConfig } from 'vitest/config' 
export default defineConfig({ 
    test: { 
        globals: true, 
   }, 
})

5.2.1 第一个功能测试

先展示loading, 请求mock数据后正确展示title, body内容

使用spyOn函数,它有两个参数:第一个是一个对象,第二个是该对象的键。 spyOn 监视该对象以及 getter 或 setter。因此,当它被调用时,它会执行它所要求的操作。例子中,它使用mockResolvedValueOnce函数返回之前创建的模拟对象mockPost来模拟响应。

flushPromises,这是 Vue test utils 中的一个实用方法,可以刷新所有待处理的已解决的 Promise 处理程序。可以等待flushPromises 的调用来刷新待处理的promise 并提高测试的可读性。

src/components/tests/PostCard.test.ts

import axios from 'axios'
import PostCard from '../PostCard.vue'
import { mount, flushPromises } from '@vue/test-utils'

const mockPost = {
  userId: 1,
  id: 1,
  title: 'sunt aut facere repellat provident occaecati excepturi optio reprehenderit',
  body: 'quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto'
}

describe('Post Card Component', () => {
  test('can fetch and display a post', async () => {
    vi.spyOn(axios, 'get').mockResolvedValueOnce({ data: mockPost })

    const wrapper = mount(PostCard)

    expect(wrapper.html()).toContain('Loading...')

    await flushPromises()

    // new
    expect(wrapper.find('[data-testid="post-title"]').text()).toBe(mockPost.title)

    expect(wrapper.find('[data-testid="post-body"]').text()).toBe(mockPost.body)
  })
})

5.2.2 第二个功能测试

和mockResolvedValueOnce类似,mockRejectedValueOnce 接收错误对象作为其参数。

test('can display an error message if fetching a post fails', async () => {
    vi.spyOn(axios, 'get').mockRejectedValueOnce(new Error('Error occurred'))

    const wrapper = mount(PostCard)

    expect(wrapper.html()).toContain('Loading...')

    await flushPromises()

    expect(wrapper.find('[data-testid="error-message"]').text()).toBe('Error occurred')
  })

6 Page Testing(E2E Testing)

编写 E2E 测试可以检测组件相互交互时发生的错误和问题,而这些错误和问题在单元或集成测试中可能未被注意到。

被测试应用的交互:用户在输入框中输入title、body,然后点击按钮提交

输入页面:

image.png

提交成功页面 image.png

非法提交操作

image.png

6.1 创建应用

src/App.vue

<template>
  <div class="post-container">
    <PostCard v-if="createdPost" :title="createdPost.title" :body="createdPost.body" />
    <form v-else @submit="handleSubmit" data-testid="post-form" class="create-post-form">
      <div class="post-title">
        <label for="title">Title</label>
        <input data-testid="title-input" type="text" name="title" v-model="formData.title" />
      </div>
      <div class="post-body">
        <label for="body">Content</label>
        <textarea data-testid="body-input" name="body" v-model="formData.body"
        rows="5" />
      </div>
      <button type="submit" :disabled="loading" class="create-post-btn">
        {{ loading ? "Creating..." : "Create Post" }}
      </button>
    </form>
    <NotificationToast v-if="error" status="error" :message="error" @clear-notification="clearErrorNotification" />
  </div>
</template>

<script setup>
import { ref } from "vue";
import axios from "axios";

import PostCard from "./components/PostCard.vue";
import NotificationToast from "./components/NotificationToast.vue";

function getUserId(max = 10) {
  return Math.floor(Math.random() * max);
}

const formData = ref({
  title: "",
  body: "",
});
const createdPost = ref(null);
const loading = ref(false);
const error = ref(null);

const handleSubmit = async (e) => {
  e.preventDefault();

  if (!formData.value.title) {
    error.value = "Please input post title";
    return;
  }

  if (!formData.value.body) {
    error.value = "Please input post body";
    return;
  }

  loading.value = true;
  createdPost.value = null;
  try {
    const { data } = await axios.post(
      "https://jsonplaceholder.typicode.com/posts",
      {
        userId: getUserId(),
        title: formData.value.title,
        body: formData.value.body,
      }
    );
    createdPost.value = data;
  } catch (err) {
    error.value = err.message;
  } finally {
    loading.value = false;
  }
};

const clearErrorNotification = () => {
  error.value = null;
};
</script>

<style scoped>
.post-container {
  max-width: 560px;
  margin: 5rem auto;
}
.create-post-form label {
  color: #fff;
}

.create-post-form input,
.create-post-form textarea {
  margin-top: 8px;
  padding: 12px;
}

.post-title {
  display: flex;
  flex-direction: column;
}

.post-body {
  display: flex;
  flex-direction: column;
  margin-top: 14px;
}

.create-post-btn {
  display: block;
  margin: 16px auto;
  background-color: #fff;
  padding: 8px;
  cursor: pointer;
}
</style>

和前面不同,这里,PostCard是一个无状态组件,它有2个属性title、body。因此,需要重新编写一个简单的快照测试来断言组件是否正确渲染。

src/components/PostCard.vue

<template>
    <div>
        <h1>{{ title }}</h1>
        <p>{{ body }}</p>
    </div>
</template>

<script setup>
defineProps({
    title: {
        type: String,
        required: true,
    },
    body: {
        type: String,
        required: true,
    },
});
</script>

<style>
h1 {
    font-size: 32px;
    font-weight: 700;
    color: #fff;
}
</style>

src/components/tests/PostCard.test.js

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

describe('Post Card Component', () => {
  test('created posts render correctly', () => {
    const title = 'Test Post';
    const body = 'test post body...';
    const wrapper = mount(PostCard, {
      props: {
        title,
        body
      }
    });
    expect(wrapper.html()).toMatchSnapshot();
  })
})

运行test后,__test__目录下会生成__snapshots__目录包含PostCard.test.snap,每次运行该用例,都会对比新生成的snapshot和之前的是否一致,如果不一致,则失败。

image.png

6.2 测试什么?

  • 测试用户是否可以成功创建post;
  • 应用程序是否根据用户输入和 API 响应显示正确的通知;

6.2.1 第一个功能测试

需要注意输入框输入内容使用test utils的setValue()函数,该函数也是返回一个promise, 另外,flushPromises()也需要使用await

src/App.test.vue

import { mount, flushPromises } from '@vue/test-utils';
import axios from 'axios';
import App from './App.vue';

const mockPost =  {
  userId: 1,
  id: 1,
  title: 'Test Post',
  body: 'test body'
};

describe('Posts App', () => {
  test('user can create a new post', async () => {
    vi.spyOn(axios, 'post').mockResolvedValueOnce({ data: mockPost });
    const wrapper = mount(App);

    // fill in the input fields
    await wrapper.find('[data-testid="title-input"]').setValue(mockPost.title);
    await wrapper.find('[data-testid="body-input"]').setValue(mockPost.body);

    // submit the form
    await wrapper.find('[data-testid="post-form"]').trigger('submit');

    expect(wrapper.find('[type="submit"]').html()).toContain('Creating...');

    await flushPromises();

    // assert that the created post is displayed on screen
    expect(wrapper.html()).toContain(mockPost.title);
    expect(wrapper.html()).toContain(mockPost.body);
  })
})

6.2.2 第二个功能测试

在不输入所需数据的情况下提交表单,这应该会触发错误并显示通知组件以及适当的消息,以及可能由于某些网络或 API 问题而失败,通知组件是否会显示正确的消息。

// second group of tests
  describe('user gets notified', () => {
    test('when attempting to create a post with incomplete fields', async () => {
      const wrapper = mount(App);

      // try to submit the form with empty fields
      await wrapper.find('[data-testid="post-form"]').trigger('submit');

      // assert that the notification is displayed
      expect(wrapper.html()).toContain('Please input post title');
      
      // click the close button
      await wrapper.find('[data-testid="close-notification" ]').trigger('click');

      // assert that error message is no longer on screen
      expect(wrapper.html()).not.toContain('Please input post title');

      // fill in the title input field
      await wrapper.find('[data-testid="title-input"]').setValue(mockPost.title);

      // try to submit the form with empty body field
      await wrapper.find('[data-testid="post-form"]').trigger('submit');

      // assert that a new error prompting user for body is displayed
      expect(wrapper.html()).toContain('Please input post body');
    });

    test('when creating a new post fails', async () => {
      vi.spyOn(axios, 'post').mockRejectedValueOnce(new Error('Error Occurred'))

      const wrapper = mount(App);

      // fill in the input fields
      await wrapper.find('[data-testid="title-input"]').setValue(mockPost.title);
      await wrapper.find('[data-testid="body-input"]').setValue(mockPost.body);

      // try to submit the form
      await wrapper.find('[data-testid="post-form"]').trigger('submit');

      // assert 
      await flushPromises();
      expect(wrapper.html()).toContain('Error Occurred');
    });

  });

7 Coverage

Vitest 支持 v8Istanbul 等提供商的代码覆盖率。

v8 内置于 Javascript 的原生 V8 引擎中,因此使用起来非常快速和高效。 v8 还提供准确的代码覆盖率信息,无需任何特殊配置或设置。 另一方面,Istanbul 是一个功能更丰富的工具,它不依赖于任何 Javascript 引擎,但支持广泛的 Javascript 测试框架和工具。由于 Istanbul 不是基于 Javascript 构建的,因此它有时可能会在语言功能上落后,并且可能比 v8 更慢且更消耗资源。 需要在配置文件中指定一个提供程序,否则默认情况下将使用 v8。

示例中使用的c8(由于vitest的版本较低, 如果vitest的版本>=0.32.0, 这里就改成'v8')

vitest.config.js

export default mergeConfig(
  viteConfig,
  defineConfig({
    test: {
      coverage: {
        provider: 'c8'
      },
      globals: true,
      environment: 'jsdom',
      exclude: [...configDefaults.exclude, 'e2e/*'],
      root: fileURLToPath(new URL('./', import.meta.url))
    }
  })
)

image.png

运行命令:

pnpm run test --coverage

image.png

8 参考:

文中代码来自 github.com/Code-Pop/qu…