中文

掌握高级Jest测试模式,构建更可靠、更易于维护的软件。为全球开发团队探索模拟(mocking)、快照测试、自定义匹配器等技术。

Jest:构建健壮软件的高级测试模式

在当今快节奏的软件开发领域,确保代码库的可靠性和稳定性至关重要。虽然Jest已成为JavaScript测试事实上的标准,但超越基础的单元测试将为您的应用程序带来更高层次的信心。本文深入探讨了构建健壮软件所必需的高级Jest测试模式,旨在服务于全球的开发者。

为什么要超越基础单元测试?

基础单元测试在隔离环境中验证单个组件。然而,现实世界的应用程序是组件相互作用的复杂系统。高级测试模式通过使我们能够做到以下几点来解决这些复杂性:

精通模拟(Mocking)与侦测(Spies)

模拟(Mocking)对于将被测单元与其依赖项隔离开来至关重要,它通过用受控的替代品替换依赖项来实现。Jest为此提供了强大的工具:

jest.fn():模拟(Mocks)与侦测(Spies)的基础

jest.fn() 创建一个模拟函数。您可以跟踪它的调用、参数和返回值。这是构建更复杂模拟策略的基础。

示例:跟踪函数调用

// component.js
export const fetchData = () => {
  // 模拟一次API调用
  return Promise.resolve({ data: 'some data' });
};

export const processData = async (fetcher) => {
  const result = await fetcher();
  return `Processed: ${result.data}`;
};

// component.test.js
import { processData } from './component';

test('should process data correctly', async () => {
  const mockFetcher = jest.fn().mockResolvedValue({ data: 'mocked data' });
  const result = await processData(mockFetcher);
  expect(result).toBe('Processed: mocked data');
  expect(mockFetcher).toHaveBeenCalledTimes(1);
  expect(mockFetcher).toHaveBeenCalledWith();
});

jest.spyOn():在不替换的情况下进行观察

jest.spyOn() 允许您观察现有对象上方法的调用,而不必替换其实现。如果需要,您也可以模拟其实现。

示例:侦测模块方法

// logger.js
export const logInfo = (message) => {
  console.log(`INFO: ${message}`);
};

// service.js
import { logInfo } from './logger';

export const performTask = (taskName) => {
  logInfo(`Starting task: ${taskName}`);
  // ... task logic ...
  logInfo(`Task ${taskName} completed.`);
};

// service.test.js
import { performTask } from './service';
import * as logger from './logger';

test('should log task start and completion', () => {
  const logSpy = jest.spyOn(logger, 'logInfo');

  performTask('backup');

  expect(logSpy).toHaveBeenCalledTimes(2);
  expect(logSpy).toHaveBeenCalledWith('Starting task: backup');
  expect(logSpy).toHaveBeenCalledWith('Task backup completed.');

  logSpy.mockRestore(); // 恢复原始实现非常重要
});

模拟模块导入

Jest的模块模拟功能非常强大。您可以模拟整个模块或特定的导出项。

示例:模拟外部API客户端

// api.js
import axios from 'axios';

export const getUser = async (userId) => {
  const response = await axios.get(`/api/users/${userId}`);
  return response.data;
};

// user-service.js
import { getUser } from './api';

export const getUserFullName = async (userId) => {
  const user = await getUser(userId);
  return `${user.firstName} ${user.lastName}`;
};

// user-service.test.js
import { getUserFullName } from './user-service';
import * as api from './api';

// 模拟整个 api 模块
jest.mock('./api');

test('should get full name using mocked API', async () => {
  // 从被模拟的模块中模拟特定函数
  api.getUser.mockResolvedValue({ id: 1, firstName: 'Ada', lastName: 'Lovelace' });

  const fullName = await getUserFullName(1);

  expect(fullName).toBe('Ada Lovelace');
  expect(api.getUser).toHaveBeenCalledTimes(1);
  expect(api.getUser).toHaveBeenCalledWith(1);
});

自动模拟与手动模拟

Jest会自动模拟Node.js模块。对于ES模块或自定义模块,您可能需要使用 jest.mock()。为了获得更多控制,您可以创建 __mocks__ 目录。

模拟实现

您可以为您的模拟提供自定义实现。

示例:使用自定义实现进行模拟

// math.js
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;

// calculator.js
import { add, subtract } from './math';

export const calculate = (operation, a, b) => {
  if (operation === 'add') {
    return add(a, b);
  } else if (operation === 'subtract') {
    return subtract(a, b);
  }
  return null;
};

// calculator.test.js
import { calculate } from './calculator';
import * as math from './math';

// 模拟整个 math 模块
jest.mock('./math');

test('should perform addition using mocked math add', () => {
  // 为 'add' 函数提供一个模拟实现
  math.add.mockImplementation((a, b) => a + b + 10); // 将结果加 10
  math.subtract.mockReturnValue(5); // 同时也模拟 subtract

  const result = calculate('add', 5, 3);

  expect(math.add).toHaveBeenCalledWith(5, 3);
  expect(result).toBe(18); // 5 + 3 + 10

  const subResult = calculate('subtract', 10, 2);
  expect(math.subtract).toHaveBeenCalledWith(10, 2);
  expect(subResult).toBe(5);
});

快照测试:保留UI和配置

快照测试是一项强大的功能,用于捕获组件或配置的输出。它们对于UI测试或验证复杂数据结构特别有用。

快照测试的工作原理

快照测试首次运行时,Jest会创建一个 .snap 文件,其中包含被测值的序列化表示。在后续运行中,Jest会将当前输出与存储的快照进行比较。如果它们不同,测试将失败,从而提醒您发生了意外的更改。这对于检测不同地区或语言环境下的UI组件回归非常有价值。

示例:为React组件创建快照

假设您有一个React组件:

// UserProfile.js
import React from 'react';

const UserProfile = ({ name, email, isActive }) => (
  <div>
    <h2>{name}</h2>
    <p><strong>Email:</strong> {email}</p>
    <p><strong>Status:</strong> {isActive ? 'Active' : 'Inactive'}</p>
  </div>
);

export default UserProfile;

// UserProfile.test.js
import React from 'react';
import renderer from 'react-test-renderer'; // 用于React组件快照
import UserProfile from './UserProfile';

test('renders UserProfile correctly', () => {
  const user = {
    name: 'Jane Doe',
    email: 'jane.doe@example.com',
    isActive: true,
  };
  const component = renderer.create(
    <UserProfile {...user} />
  );
  const tree = component.toJSON();
  expect(tree).toMatchSnapshot();
});

test('renders inactive UserProfile correctly', () => {
  const user = {
    name: 'John Smith',
    email: 'john.smith@example.com',
    isActive: false,
  };
  const component = renderer.create(
    <UserProfile {...user} />
  );
  const tree = component.toJSON();
  expect(tree).toMatchSnapshot('inactive user profile'); // 命名快照
});

运行测试后,Jest将创建一个 UserProfile.test.js.snap 文件。当您更新组件时,您需要审查更改,并可能通过使用 --updateSnapshot-u 标志运行Jest来更新快照。

快照测试的最佳实践

自定义匹配器:增强测试可读性

Jest的内置匹配器非常丰富,但有时您需要断言一些未被覆盖的特定条件。自定义匹配器允许您创建自己的断言逻辑,使您的测试更具表现力和可读性。

创建自定义匹配器

您可以使用自己的匹配器来扩展Jest的 expect 对象。

示例:检查有效的电子邮件格式

在您的Jest配置文件中(例如,在 jest.config.js 中配置的 jest.setup.js):

// jest.setup.js

expect.extend({
  toBeValidEmail(received) {
    const emailRegex = /^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/;
    const pass = emailRegex.test(received);

    if (pass) {
      return {
        message: () => `expected ${received} not to be a valid email`,
        pass: true,
      };
    } else {
      return {
        message: () => `expected ${received} to be a valid email`,
        pass: false,
      };
    }
  },
});

// In your jest.config.js
// module.exports = { setupFilesAfterEnv: ['/jest.setup.js'] };

在您的测试文件中:

// validation.test.js

test('should validate email formats', () => {
  expect('test@example.com').toBeValidEmail();
  expect('invalid-email').not.toBeValidEmail();
  expect('another.test@sub.domain.co.uk').toBeValidEmail();
});

自定义匹配器的好处

测试异步操作

JavaScript是重度异步的。Jest为测试Promise和async/await提供了出色的支持。

使用 async/await

这是测试异步代码的现代化且最易读的方式。

示例:测试异步函数

// dataService.js
export const fetchUserData = async (userId) => {
  // 模拟延迟后获取数据
  await new Promise(resolve => setTimeout(resolve, 50));
  if (userId === 1) {
    return { id: 1, name: 'Alice' };
  } else {
    throw new Error('User not found');
  }
};

// dataService.test.js
import { fetchUserData } from './dataService';

test('fetches user data correctly', async () => {
  const user = await fetchUserData(1);
  expect(user).toEqual({ id: 1, name: 'Alice' });
});

test('throws error for non-existent user', async () => {
  await expect(fetchUserData(2)).rejects.toThrow('User not found');
});

使用 .resolves.rejects

这些匹配器简化了对Promise成功和失败的测试。

示例:使用 .resolves/.rejects

// dataService.test.js (continued)

test('fetches user data with .resolves', () => {
  return expect(fetchUserData(1)).resolves.toEqual({ id: 1, name: 'Alice' });
});

test('throws error for non-existent user with .rejects', () => {
  return expect(fetchUserData(2)).rejects.toThrow('User not found');
});

处理定时器

对于使用 setTimeoutsetInterval 的函数,Jest提供了定时器控制功能。

示例:控制定时器

// delayedGreeter.js
export const greetAfterDelay = (name, callback) => {
  setTimeout(() => {
    callback(`Hello, ${name}!`);
  }, 1000);
};

// delayedGreeter.test.js
import { greetAfterDelay } from './delayedGreeter';

jest.useFakeTimers(); // 启用模拟定时器

test('greets after delay', () => {
  const mockCallback = jest.fn();
  greetAfterDelay('World', mockCallback);

  // 将定时器快进1000毫秒
  jest.advanceTimersByTime(1000);

  expect(mockCallback).toHaveBeenCalledTimes(1);
  expect(mockCallback).toHaveBeenCalledWith('Hello, World!');
});

// 如果在其他地方需要,则恢复真实定时器
jest.useRealTimers();

测试组织与结构

随着测试套件的增长,组织结构对于可维护性变得至关重要。

Describe块和It块

使用 describe 对相关测试进行分组,使用 it (或 test) 表示单个测试用例。这种结构反映了应用程序的模块化特性。

示例:结构化测试

describe('User Authentication Service', () => {
  let authService;

  beforeEach(() => {
    // 在每个测试前设置模拟或服务实例
    authService = require('./authService');
    jest.spyOn(authService, 'login').mockImplementation(() => Promise.resolve({ token: 'fake_token' }));
  });

  afterEach(() => {
    // 清理模拟
    jest.restoreAllMocks();
  });

  describe('login functionality', () => {
    it('should successfully log in a user with valid credentials', async () => {
      const result = await authService.login('user@example.com', 'password123');
      expect(result.token).toBeDefined();
      // ... more assertions ...
    });

    it('should fail login with invalid credentials', async () => {
      jest.spyOn(authService, 'login').mockRejectedValue(new Error('Invalid credentials'));
      await expect(authService.login('user@example.com', 'wrong_password')).rejects.toThrow('Invalid credentials');
    });
  });

  describe('logout functionality', () => {
    it('should clear user session', async () => {
      // Test logout logic...
    });
  });
});

设置与拆卸钩子

这些钩子对于在测试之间设置模拟数据、数据库连接或清理资源至关重要。

为全球受众进行测试

在为全球受众开发应用程序时,测试的考虑范围会扩大:

国际化 (i18n) 和本地化 (l10n)

确保您的UI和消息能正确适应不同的语言和地区格式。

示例:测试本地化日期格式

// dateUtils.js
export const formatLocalizedDate = (date, locale) => {
  return new Intl.DateTimeFormat(locale, { year: 'numeric', month: 'numeric', day: 'numeric' }).format(date);
};

// dateUtils.test.js
import { formatLocalizedDate } from './dateUtils';

test('formats date correctly for US locale', () => {
  const date = new Date(2023, 10, 15); // November 15, 2023
  expect(formatLocalizedDate(date, 'en-US')).toBe('11/15/2023');
});

test('formats date correctly for German locale', () => {
  const date = new Date(2023, 10, 15);
  expect(formatLocalizedDate(date, 'de-DE')).toBe('15.11.2023');
});

时区感知

测试您的应用程序如何处理不同的时区,特别是对于调度或实时更新等功能。模拟系统时钟或使用抽象时区的库会很有帮助。

数据中的文化差异

考虑数字、货币和其他数据表示在不同文化中可能被如何感知或期望。自定义匹配器在这里特别有用。

高级技术与策略

测试驱动开发 (TDD) 与行为驱动开发 (BDD)

Jest与TDD(红-绿-重构)和BDD(Given-When-Then)方法论非常契合。在编写实现代码之前,先编写描述期望行为的测试。这确保了代码从一开始就考虑到了可测试性。

使用Jest进行集成测试

虽然Jest在单元测试方面表现出色,但它也可以用于集成测试。减少依赖项的模拟或使用像Jest的 runInBand 这样的选项会有所帮助。

示例:测试API交互(简化版)

// apiService.js
import axios from 'axios';

const API_BASE_URL = 'https://api.example.com';

export const createProduct = async (productData) => {
  const response = await axios.post(`${API_BASE_URL}/products`, productData);
  return response.data;
};

// apiService.test.js (Integration test)
import axios from 'axios';
import { createProduct } from './apiService';

// 为集成测试模拟axios以控制网络层
jest.mock('axios');

test('creates a product via API', async () => {
  const mockProduct = { id: 1, name: 'Gadget' };
  const responseData = { success: true, product: mockProduct };

  axios.post.mockResolvedValue({
    data: responseData,
    status: 201,
    headers: { 'content-type': 'application/json' },
  });

  const newProductData = { name: 'Gadget', price: 99.99 };
  const result = await createProduct(newProductData);

  expect(axios.post).toHaveBeenCalledWith(`${process.env.API_BASE_URL || 'https://api.example.com'}/products`, newProductData);
  expect(result).toEqual(responseData);
});

并行化与配置

Jest可以并行运行测试以加快执行速度。您可以在 jest.config.js 中进行配置。例如,设置 maxWorkers 可以控制并行进程的数量。

覆盖率报告

使用Jest内置的覆盖率报告功能,以识别代码库中未被测试的部分。使用 --coverage 运行测试以生成详细报告。

jest --coverage

审查覆盖率报告有助于确保您的高级测试模式有效地覆盖了关键逻辑,包括国际化和本地化代码路径。

结论

精通高级Jest测试模式是为全球受众构建可靠、可维护和高质量软件的重要一步。通过有效利用模拟、快照测试、自定义匹配器和异步测试技术,您可以增强测试套件的健壮性,并对应用程序在不同场景和地区的行为获得更大的信心。采纳这些模式能使全球的开发团队能够提供卓越的用户体验。

从今天开始,将这些高级技术融入您的工作流程,以提升您的JavaScript测试实践水平。

Jest:构建健壮软件的高级测试模式 | MLOG