Skip to content

模拟环境

模拟 DOM API

因为 JS 可以再 nodejs 和 浏览器 环境中运行, 但是测试代码一般都是在 nodejs 中执行,

如果是要调用浏览器环境专有的 API, 比如 document.querySelector 这样的, 再 nodejs 中是没有这个 API 的,

此时就需要模拟浏览器的 DOM 环境, 主要有两个包可以做这个事情, happy-domjs-dom

推荐 happy-dom, 因为 happy-dom 性能更好(可能是 jsdom 的检查机制更加严格的问题)

手动模拟其他 API/变量

因为 happy-dom 和 jsdom 不可能将 window 上的所有 API 全部实现一遍, 因此, 有些 API 就需要手动模拟,

而且, window 是个对象, 有的时候可能会在 window 对象上设置一些属性, 此时如果要测试这样的方法, 就需要模拟环境

typescript
// 被测试的源码
export function getWinSize() {
  return {
    width: window.innerWidth,
    height: window.innerHeight,
  };
}

export function openURL(url: string) {
  window.open(url);
}

export function getBaseURL() {
  const baseUrlMap = {
    development: 'http://localhost:3000',
    production: 'http://api.demo.com',
    test: 'http://test.demo.com',
  };
  const url = baseUrlMap[import.meta.env.NODE_ENV];
  if (!url) {
    throw new Error('unknown env');
  }
  return url;
}
typescript
// 测试代码
import { getWinSize, openURL, getBaseURL } from '@/main';

describe('模拟浏览器运行环境的全局变量和方法', () => {
  afterEach(() => {
    vi.unstubAllGlobals();
    vi.unstubAllEnvs();
    // 每个测试执行完后清除所有全局模拟, 保证每个测试的独立性
  });

  it('模拟全局变量', () => {
    vi.stubGlobal('innerWidth', 100);
    vi.stubGlobal('innerHeight', 50);

    const { width, height } = getWinSize();
    expect(width).toBe(100);
    expect(height).toBe(50);
  });

  it('模拟全局函数', () => {
    // openURL(url);
    // expect(window.open).toBeCalledWith(url);
    // 这样是无法通过测试的, 因为 happy-dom 没有实现 window.open 方法

    const openFn = vi.fn(); // 生成一个间谍函数
    vi.stubGlobal('open', openFn);

    const url = 'https://google.com';
    openURL(url);
    expect(window.open).toBeCalledWith(url); // toBeCalledWith: 判断间谍函数是否调用,及其调用时传入的参数
  });

  it('模拟环境变量', () => {
    vi.stubEnv('NODE_ENV', 'development');
    expect(getBaseURL()).toBe('http://localhost:3000');

    vi.stubEnv('NODE_ENV', 'production');
    expect(getBaseURL()).toBe('http://api.demo.com');

    vi.stubEnv('NODE_ENV', 'test');
    expect(getBaseURL()).toBe('http://test.demo.com');

    vi.stubEnv('NODE_ENV', 'unknown-env');
    expect(() => getBaseURL()).toThrow(); // toThrow: 被测试的函数应该抛出一个异常
  });
});

模拟输入

一般直接输入的函数, 不需要模拟, 直接传入参数后执行, 然后测试输出结果即可

typescript
function sum(nums: number[]) {
  let res = 0;
  for (let i = 0, l = nums.length; i < l; i++) {
    res += nums[i];
  }
  return res;
}

it('测试直接输入的函数', () => {
  const arr = [1, 3, 5, 7, 9];
  const sumValue = sum(arr);
  expect(sumValue).toBe(25);
});

但是在写程序时, 不可避免的会有一下间接输入, 如调用其他方法获取的值, 或者发送请求获取的响应值

typescript
// types.d.ts
interface IUserDto {
  id: number;
  username: string;
  phone: string;
}

// user.ts
function getUserInfo(): Promise<IUserDto> {
  return fetch('http://api.xxx.com/users/1').then((res) => res.json());
}

// main.ts
async function getUserPhone(): string {
  const user: IUserDto = await getUserInfo();
  return String(user.phone).substring(0, 3) + '*'.repeat(8);
}
typescript
import { getUserPhone } from '@/main';
import * as userModule from '@/user';

describe('模拟程序间接输入', () => {
  afterEach(() => {
    vi.resetAllMocks();
  });

  it('使用 mock 替换模块的方式来模拟间接输入', async () => {
    // 直接使用工厂函数来返回一个值
    vi.mock('@/user', () => {
      return {
        getUserInfo: () => Promise.resolve({ phone: '18712345678' }),
      };
    });
    const phone = await getUserPhone();
    expect(phone).toBe('187' + '*'.repeat(8));
    // 也可以使用 vi.mock + vi.mocked(xxx).mockImplementation 的方式来实现
    // 具体参考文档: https://cn.vitest.dev/api/vi.html#vi-mocked
  });

  it('使用 spyOn 的方式来模拟间接输入', async () => {
    vi.spyOn(userModule, 'getUserInfo').mockResolvedValue({
      id: 1,
      username: 'xxx',
      phone: '18912345678',
    });

    const phone = await getUserPhone();
    expect(phone).toBe('189' + '*'.repeat(8));
  });
});

注意 BUG

注:由于 vitest 的 mock 实现原理是通过劫持 import 语句来实现的, 如果是同一个模块直接调用就会 mock 失败

typescript
// foo.ts
export foo = () => Math.random().toString(16).slice(2);
export bar = () => foo() + "-bar";
typescript
// foo.spec.ts
import { foo, bar } from '@/foo';
vi.mock('@/foo', () => {
  return {
    foo: () => 'random-string',
  };
});

it('这个测试用例会失败, 因为 bar 是直接调用的 foo 函数, 没有 import, vitest 无法劫持', () => {
  expect(bar()).toBe('random-string-bar');
});

如何避免这个 bug? 只需要让 foobar 在不同的文件中, 然后 import, 这样 vitest 就可以劫持到

typescript
// foo.ts
export foo = () => Math.random().toString(16).slice(2);

// bar.ts
import {foo} from "./foo"
export bar = () => foo() + "-bar";
typescript
// bar.spec.ts
import { bar } from '@/bar';
vi.mock('@/foo', () => {
  return {
    foo: () => 'random-string',
  };
});
it('这个测试用例会成功', () => {
  expect(bar()).toBe('random-string-bar');
});

保证代码结果的可预测性

有时候我们需要测试随机数/日期相关的内容来做一些事情, 此时就不太好测试,

因为日期你今天写的测试通过来, 明天再来测试可能就无法通过了

typescript
// main.ts
export function randomString(): string {
  // 这个代码, 其实 Math.random 随机数的结果是多少并不重要,
  // 重要的是, 是否能正确的变成一个随机字符串
  // 因为任何随机数后续的处理流程都是一样的
  return Math.random().toString(16).substring(2);
}

export function isFriday() {
  // 如果不使用模拟的时间, 那么会导致, 有的时候能通过,
  // 有的时候不能通过, 这就导致结果不可预测
  const date = new Date();
  return date.getDay() === 5;
}
typescript
// main.spec.ts
import { isFriday, randomString } from '@/main';
describe('保证代码结果的可预测性', () => {
  it('随机数处理', () => {
    vi.spyOn(Math, 'random').mockImplementation(() => 0.1);
    expect(randomString()).toBe('1999999999999a');
  });

  it('日期处理', () => {
    // 使用一个模拟的时间
    vi.useFakeTimers();
    const mockDate = new Date(2023, 8, 15); // 2023-9-15
    vi.setSystemTime(mockDate);
    expect(isFriday()).toBe(true);

    const mockDate2 = new Date(2023, 8, 16); // 2023-9-16
    vi.setSystemTime(mockDate2);
    expect(isFriday()).toBe(false);

    // 使用真实的系统时间, 不要影响其他测试用例
    vi.useRealTimers();
  });
});

时光机:模拟定时器/超时器

因为有的时候,定时器如果比较多的话,需要等待比较长的时间,想要快速验证,就应该使用模拟的时间

typescript
/**
 * 函数防抖
 * @param func - 要防止抖动的函数
 * @param wait - 触发频率(等待时间)
 * @param isImmediate - 第一次是否立即调用
 * @param thisArg - 调用函数的时候 this 指向
 * @returns
 */
export function debounce(
  func: Function,
  wait: number = 100,
  isImmediate: boolean = false,
  thisArg?: object
): (...args: any[]) => void {
  let timer: NodeJS.Timeout;
  let shouldExecute = isImmediate;

  return function (...args: unknown[]): void {
    timer && clearTimeout(timer);
    if (shouldExecute) {
      func.apply(thisArg, ...args);
      shouldExecute = false;
    } else {
      timer = setTimeout(func.bind(thisArg, ...args), wait);
    }
  };
}
typescript
import { debounce } from '@/main';
describe('时光机:跳过定时器快速验证代码', () => {
  afterEach(() => {
    vi.useRealTimers();
  });

  it('加速定时器', () => {
    // given
    vi.useFakeTimers();
    const cb = vi.fn();

    // when
    const wait = 1000;
    debounce(cb, wait)(); // execute returned function
    expect(cb).not.toBeCalled();

    // then
    vi.advanceTimersToNextTimer(); // 快进到下一个定时器(推荐)
    // vi.advanceTimersByTime(wait); // 快进指定毫秒
    expect(cb).toBeCalled();
  });
});

Released under the MIT License.