前言
紧接上文,我们只介绍了源码实现,漏掉了单元测试的实现,接下来,我们一起来看看如何编写单元测试。
选择单元测试框架
由于我们使用的是vite构建工具,vite天生集成了vitest单元测试框架,因此我选择使用vitest来编写单元测试,首先安装vitest依赖。命令代码如下:
pnpm add vitest -D
注意这里的-D,这个就涉及到了一个概念,即dependencies和devDependencies的区别。接下来我们来详细看下它们之间的区别。
在 package.json 文件中,dependencies 和 devDependencies 都是用于定义项目所需的依赖项,但它们的使用场景和作用有所不同。以下是它们的具体区别:
1. dependencies
dependencies 包含了生产环境(production environment)中需要的依赖包。也就是说,这些依赖项是项目运行时必需的,无论是应用的正常运行,还是在生产环境中部署时都会用到的库。
常见场景:
- 在生产环境中运行应用时需要的所有库和框架。
- 例如,React、Vue、Express、Axios 等前端或后端框架、库通常会放在
dependencies中。
示例:
{
"dependencies": {
"react": "^18.0.0",
"axios": "^0.21.1"
}
}
在这个示例中,react 和 axios 是生产环境中需要的依赖。
2. devDependencies
devDependencies 包含了开发环境(development environment)中需要的依赖包。这些依赖项仅在开发阶段使用,通常是一些工具、构建工具、测试框架等,它们不会在生产环境中被使用。
常见场景:
- 构建工具(如
webpack,rollup,parcel) - 测试工具(如
jest,mocha) - 代码检查工具(如
eslint,prettier) - 开发服务器(如
webpack-dev-server)
示例:
{
"devDependencies": {
"webpack": "^5.0.0",
"babel-loader": "^8.2.2",
"eslint": "^7.0.0"
}
}
在这个示例中,webpack、babel-loader 和 eslint 是用于开发环境中的工具和库,而在生产环境中它们并不需要。
3. 安装依赖时的差异
当你运行 npm install 或 yarn install 时,dependencies 中的库会被安装到 node_modules 目录中,并且它们会被添加到最终的生产环境包中。
dependencies:这些依赖会始终被安装。devDependencies:这些依赖仅会在开发环境中安装,或者在使用--dev(如npm install --dev)或NODE_ENV=development环境时安装。如果在生产环境下部署应用(例如,使用npm install --production),devDependencies中的依赖不会被安装。
4. 总结对比
| 特性 | dependencies | devDependencies |
|---|---|---|
| 用途 | 生产环境所需的依赖包 | 仅在开发环境中使用的依赖包 |
| 安装时是否包含 | 总是安装,生产和开发环境都需要 | 只在开发环境中安装,生产环境中通常不需要 |
| 例子 | React、Vue、Axios、Express、Lodash 等 | Webpack、Babel、ESLint、Jest、Prettier、Mocha 等开发工具 |
| 在生产环境中的行为 | 会被包含在生产构建中 | 不会被包含在生产环境中 |
5. 如何安装依赖
-
安装生产依赖:
npm install <package> --save # 或者直接运行 npm install <package>这样会将依赖添加到
dependencies中。 -
安装开发依赖:
npm install <package> --save-dev这会将依赖添加到
devDependencies中。
6. 示例场景
生产环境:
- 在一个
React项目中,React和ReactDOM会放在dependencies中,因为它们是应用在生产环境中运行所需要的核心库。
开发环境:
webpack和babel之类的构建工具会放在devDependencies中,因为它们仅在开发时用于打包和转换代码,在生产环境中并不需要。
总结:
dependencies:用于生产环境的必需依赖,应用运行时需要的包。devDependencies:仅用于开发阶段的工具、库和框架,生产环境中不需要的依赖。
根据以上的描述,vitest属于我们只需要在开发环境用来做测试的工具,而不需要在生产环境中使用,因此我们需要指定-D将依赖安装到devDependencies配置中。
修改配置
接下来,我们需要修改一下package.json的配置,在scripts中增加一行命令,如下所示:
{
"test": "vitest", // 用于执行单元测试的命令
}
新建一个vitest.config.ts,里面写上如下代码:
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
},
});
这个配置用于指定单元测试的一些框架,例如如下配置:
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
browser: {
enabled: true,
name: "chromium",
provider: "playwright",
},
globals: true,
environment: "jsdom",
},
});
其中browser配置可以使用playwright在浏览器环境段运行一个可视化执行单元测试的网站,当然其实这里由于我们的单元测试比较简单,我们不需要这么多的配置,这里只是说明一下我们可以添加很多配置来完善单元测试的配置。
编写单元测试
在根目录下新建tests目录,并新建一个basic.test.ts文件,写上如下代码:
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useStorage } from '../src/core/core';
import { StoreType } from '../src/core/enum';
globalThis.localStorage = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
key: () => 'test_local',
length: 1
};
globalThis.sessionStorage = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
key: () => 'test_session',
length: 1
};
vi.mock('./utils', () => ({
parseStr: vi.fn().mockReturnValue((val: string) => JSON.parse(val)),
isStorageEnabled: vi.fn().mockReturnValue(true),
isValidJSON: vi.fn().mockReturnValue(true)
}));
describe('useStorage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should read from localStorage and initialize correctly', () => {
const mockData = '{"name": "夕水"}';
(localStorage.getItem as any).mockReturnValue(mockData);
const result = useStorage('user', { name: 'eveningwater' });
expect(result.value).toEqual({ name: '夕水' });
expect(localStorage.getItem).toHaveBeenCalledWith('user');
});
it('should use initial value if nothing is stored in localStorage', () => {
(localStorage.getItem as any).mockReturnValue(null);
const result = useStorage('user', { name: 'eveningwater' });
expect(result.value).toEqual({ name: 'eveningwater' });
expect(localStorage.getItem).toHaveBeenCalledWith('user');
});
it('should write to localStorage when value changes', () => {
const result = useStorage('user', { name: 'eveningwater' });
result.value = { name: '夕水' };
expect(localStorage.setItem).toHaveBeenCalledWith('user', '{"name":"夕水"}');
});
it('should use sessionStorage if specified in options', () => {
const mockData = '{"name": "夕水"}';
(sessionStorage.getItem as any).mockReturnValue(mockData);
const result = useStorage('user', { name: 'eveningwater' }, { storage: StoreType.SESSION });
expect(result.value).toEqual({ name: '夕水' });
expect(sessionStorage.getItem).toHaveBeenCalledWith('user');
});
it('should write to sessionStorage when value changes', () => {
const result = useStorage('user', { name: 'eveningwater' }, { storage: StoreType.SESSION });
result.value = { name: '夕水' };
expect(sessionStorage.setItem).toHaveBeenCalledWith('user', '{"name":"夕水"}');
});
it('should respect deep option for deep watching', () => {
const result = useStorage('user', { name: 'eveningwater', details: { age: 25 } }, { deep: true });
result.value.details.age = 28;
expect(localStorage.setItem).toHaveBeenCalledWith('user', '{"name":"eveningwater","details":{"age":28}}');
});
it('should respect deep option for deep watching', () => {
const storedValue = '{"name":"eveningwater","details":{"age":25}}';
(sessionStorage.getItem as any).mockReturnValue(storedValue);
const result = useStorage('user', { name: 'eveningwater', details: { age: 25 } }, { storage: StoreType.SESSION });
result.value.details.age = 28;
expect(sessionStorage.setItem).toHaveBeenCalledWith('user', '{"name":"eveningwater","details":{"age":28}}');
});
it('should trigger immediate effect with immediate: true', () => {
const result = useStorage('user', { name: 'eveningwater' }, { immediate: true });
expect(result.value).toEqual({ name: 'eveningwater' });
});
it('should trigger immediate effect with immediate: false', () => {
(sessionStorage.getItem as any).mockReturnValue({ name:'eveningwater'});
useStorage('user', { name: 'eveningwater' }, { immediate: false });
// 判断是否支持storage调用了一次
expect(localStorage.setItem).toHaveBeenCalledTimes(1);
});
});
下面我们来一步步分析如上所有单元测试代码的实现原理。
这段代码是一个使用 Vitest 进行单元测试的测试套件。它主要测试 useStorage 这个自定义钩子(hook)在不同情境下的行为,模拟了浏览器的 localStorage 和 sessionStorage,并对 useStorage 进行了多种测试,确保它的功能正常。
1. 测试环境设置
globalThis.localStorage = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
key: () => 'test_local',
length: 1
};
globalThis.sessionStorage = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
key: () => 'test_session',
length: 1
};
这段代码模拟了浏览器的 localStorage 和 sessionStorage。vi.fn() 是 Vitest 提供的 mock 函数,意味着你可以跟踪函数的调用情况。这些模拟对象提供了与真实 localStorage 和 sessionStorage 一样的方法,如 getItem, setItem, removeItem, clear, key, length 等。
2. 模拟的外部依赖
vi.mock('./utils', () => ({
parseStr: vi.fn().mockReturnValue((val: string) => JSON.parse(val)),
isStorageEnabled: vi.fn().mockReturnValue(true),
isValidJSON: vi.fn().mockReturnValue(true)
}));
vi.mock('./utils')是对./utils文件的模拟,使得该文件中的函数可以返回指定的 mock 值。这里模拟了parseStr、isStorageEnabled和isValidJSON三个函数的行为。parseStr: 返回解析 JSON 字符串的函数。isStorageEnabled: 模拟true,意味着存储可用。isValidJSON: 模拟true,意味着存储的数据总是有效的 JSON。
3. 测试套件 describe('useStorage', ...)
describe 是测试套件,用于组织多个相关的测试用例。每个 it 是一个独立的测试用例。
beforeEach
beforeEach(() => {
vi.clearAllMocks();
});
beforeEach 是在每个测试用例运行之前调用的函数。这里用它来清除所有的 mock 函数,确保每个测试用例之间不会互相影响。
4. 各个测试用例解读
测试用例 1: 从 localStorage 读取并正确初始化
it('should read from localStorage and initialize correctly', () => {
const mockData = '{"name": "夕水"}';
(localStorage.getItem as any).mockReturnValue(mockData);
const result = useStorage('user', { name: 'eveningwater' });
expect(result.value).toEqual({ name: '夕水' });
expect(localStorage.getItem).toHaveBeenCalledWith('user');
});
- 模拟从
localStorage获取数据{"name": "夕水"},并通过useStorage钩子初始化数据。 - 测试
useStorage是否成功解析存储的值并赋给result.value,并且确保localStorage.getItem被正确调用。
测试用例 2: 如果 localStorage 中没有数据,使用初始值
it('should use initial value if nothing is stored in localStorage', () => {
(localStorage.getItem as any).mockReturnValue(null);
const result = useStorage('user', { name: 'eveningwater' });
expect(result.value).toEqual({ name: 'eveningwater' });
expect(localStorage.getItem).toHaveBeenCalledWith('user');
});
- 测试当
localStorage中没有值时(即返回null),useStorage应该使用传入的初始值{"name": "eveningwater"}。
测试用例 3: 当值变化时,写入 localStorage
it('should write to localStorage when value changes', () => {
const result = useStorage('user', { name: 'eveningwater' });
result.value = { name: '夕水' };
expect(localStorage.setItem).toHaveBeenCalledWith('user', '{"name":"夕水"}');
});
- 测试当
useStorage的值发生变化时,是否会调用localStorage.setItem来存储更新后的值。
测试用例 4: 使用 sessionStorage 替代 localStorage
it('should use sessionStorage if specified in options', () => {
const mockData = '{"name": "夕水"}';
(sessionStorage.getItem as any).mockReturnValue(mockData);
const result = useStorage('user', { name: 'eveningwater' }, { storage: StoreType.SESSION });
expect(result.value).toEqual({ name: '夕水' });
expect(sessionStorage.getItem).toHaveBeenCalledWith('user');
});
- 测试当传入
storage: StoreType.SESSION时,useStorage是否会使用sessionStorage而不是localStorage。
测试用例 5: 当值变化时,写入 sessionStorage
it('should write to sessionStorage when value changes', () => {
const result = useStorage('user', { name: 'eveningwater' }, { storage: StoreType.SESSION });
result.value = { name: '夕水' };
expect(sessionStorage.setItem).toHaveBeenCalledWith('user', '{"name":"夕水"}');
});
- 测试当值发生变化时,是否会将新的值写入
sessionStorage。
测试用例 6: 支持深度监听(deep 选项)
it('should respect deep option for deep watching', () => {
const result = useStorage('user', { name: 'eveningwater', details: { age: 25 } }, { deep: true });
result.value.details.age = 28;
expect(localStorage.setItem).toHaveBeenCalledWith('user', '{"name":"eveningwater","details":{"age":28}}');
});
- 测试
deep: true是否启用了对嵌套对象(如details)的深度监听,当嵌套对象的值发生变化时,是否会更新到存储。
测试用例 7: 支持 immediate 选项
it('should trigger immediate effect with immediate: true', () => {
const result = useStorage('user', { name: 'eveningwater' }, { immediate: true });
expect(result.value).toEqual({ name: 'eveningwater' });
});
- 测试当
immediate: true时,是否立即触发存储读取和初始化。
测试用例 8: immediate: false 时的行为
it('should trigger immediate effect with immediate: false', () => {
(sessionStorage.getItem as any).mockReturnValue({ name:'eveningwater'});
useStorage('user', { name: 'eveningwater' }, { immediate: false });
expect(localStorage.setItem).toHaveBeenCalledTimes(1);
});
- 测试当
immediate: false时,是否不会立即写入存储。
总结
这段代码通过模拟 localStorage 和 sessionStorage,为 useStorage 钩子编写了多个测试用例,确保其在不同情况下的正确性。包括:
- 从存储读取数据并初始化。
- 当存储为空时使用默认值。
- 值变化时更新存储。
- 支持不同存储选项(如
sessionStorage和localStorage)。 - 支持深度监听(
deep)和即时效果(immediate)。
总结
ew-responsive-store 是一个简单但功能强大的库,它通过封装 localStorage 或 sessionStorage 数据存储,使得这些数据变得响应式,从而简化了开发中的数据管理和状态同步。其简洁的 API 和小巧的体积,使其可以轻松集成到各种前端框架中,甚至是原生 JavaScript 项目。通过配置不同的参数,你可以灵活控制存储类型、监听机制等,极大提高了开发效率。
源码地址:GitHub - ew-responsive-store
如果你觉得这个包对你有帮助,请不吝点赞并分享给更多开发者!