一起来看看响应式会话存储的实现原理(补充单元测试篇)

181 阅读9分钟

前言

紧接上文,我们只介绍了源码实现,漏掉了单元测试的实现,接下来,我们一起来看看如何编写单元测试。

选择单元测试框架

由于我们使用的是vite构建工具,vite天生集成了vitest单元测试框架,因此我选择使用vitest来编写单元测试,首先安装vitest依赖。命令代码如下:

pnpm add vitest -D

注意这里的-D,这个就涉及到了一个概念,即dependenciesdevDependencies的区别。接下来我们来详细看下它们之间的区别。

package.json 文件中,dependenciesdevDependencies 都是用于定义项目所需的依赖项,但它们的使用场景和作用有所不同。以下是它们的具体区别:

1. dependencies

dependencies 包含了生产环境(production environment)中需要的依赖包。也就是说,这些依赖项是项目运行时必需的,无论是应用的正常运行,还是在生产环境中部署时都会用到的库。

常见场景:

  • 在生产环境中运行应用时需要的所有库和框架。
  • 例如,React、Vue、Express、Axios 等前端或后端框架、库通常会放在 dependencies 中。

示例:

{
  "dependencies": {
    "react": "^18.0.0",
    "axios": "^0.21.1"
  }
}

在这个示例中,reactaxios 是生产环境中需要的依赖。

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

在这个示例中,webpackbabel-loadereslint 是用于开发环境中的工具和库,而在生产环境中它们并不需要。

3. 安装依赖时的差异

当你运行 npm installyarn install 时,dependencies 中的库会被安装到 node_modules 目录中,并且它们会被添加到最终的生产环境包中。

  • dependencies:这些依赖会始终被安装。
  • devDependencies:这些依赖仅会在开发环境中安装,或者在使用 --dev(如 npm install --dev)或 NODE_ENV=development 环境时安装。如果在生产环境下部署应用(例如,使用 npm install --production),devDependencies 中的依赖不会被安装。

4. 总结对比

特性dependenciesdevDependencies
用途生产环境所需的依赖包仅在开发环境中使用的依赖包
安装时是否包含总是安装,生产和开发环境都需要只在开发环境中安装,生产环境中通常不需要
例子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 项目中,ReactReactDOM 会放在 dependencies 中,因为它们是应用在生产环境中运行所需要的核心库。

开发环境:

  • webpackbabel 之类的构建工具会放在 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)在不同情境下的行为,模拟了浏览器的 localStoragesessionStorage,并对 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
};

这段代码模拟了浏览器的 localStoragesessionStoragevi.fn() 是 Vitest 提供的 mock 函数,意味着你可以跟踪函数的调用情况。这些模拟对象提供了与真实 localStoragesessionStorage 一样的方法,如 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 值。这里模拟了 parseStrisStorageEnabledisValidJSON 三个函数的行为。
    • 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 时,是否不会立即写入存储。

总结

这段代码通过模拟 localStoragesessionStorage,为 useStorage 钩子编写了多个测试用例,确保其在不同情况下的正确性。包括:

  • 从存储读取数据并初始化。
  • 当存储为空时使用默认值。
  • 值变化时更新存储。
  • 支持不同存储选项(如 sessionStoragelocalStorage)。
  • 支持深度监听(deep)和即时效果(immediate)。

总结

ew-responsive-store 是一个简单但功能强大的库,它通过封装 localStoragesessionStorage 数据存储,使得这些数据变得响应式,从而简化了开发中的数据管理和状态同步。其简洁的 API 和小巧的体积,使其可以轻松集成到各种前端框架中,甚至是原生 JavaScript 项目。通过配置不同的参数,你可以灵活控制存储类型、监听机制等,极大提高了开发效率。

源码地址:GitHub - ew-responsive-store

如果你觉得这个包对你有帮助,请不吝点赞并分享给更多开发者!