Jest 扩展与插件 #

扩展概述 #

Jest 提供了丰富的扩展点,可以根据需求定制测试功能。

text
┌─────────────────────────────────────────────────────────────┐
│                    Jest 扩展点                               │
├─────────────────────────────────────────────────────────────┤
│  1. 自定义匹配器 - 扩展断言功能                              │
│  2. 自定义报告器 - 定制测试报告                              │
│  3. 自定义运行器 - 改变测试执行方式                          │
│  4. 快照序列化器 - 定制快照格式                              │
│  5. 测试环境 - 定制测试环境                                  │
│  6. 监视插件 - 扩展监视功能                                  │
└─────────────────────────────────────────────────────────────┘

自定义匹配器 #

基本匹配器 #

javascript
// matchers.js
expect.extend({
  toBeWithinRange(received, floor, ceiling) {
    const pass = received >= floor && received <= ceiling;
    if (pass) {
      return {
        message: () => `expected ${received} not to be within range ${floor} - ${ceiling}`,
        pass: true,
      };
    } else {
      return {
        message: () => `expected ${received} to be within range ${floor} - ${ceiling}`,
        pass: false,
      };
    }
  },
});

// 使用
test('within range', () => {
  expect(5).toBeWithinRange(1, 10);
});

异步匹配器 #

javascript
expect.extend({
  async toBeResolved(received) {
    const pass = await received
      .then(() => true)
      .catch(() => false);
    
    return {
      pass,
      message: () => pass
        ? 'expected promise to be rejected'
        : 'expected promise to be resolved',
    };
  },
});

// 使用
test('async matcher', async () => {
  await expect(Promise.resolve('value')).toBeResolved();
});

类型匹配器 #

javascript
expect.extend({
  toBeType(received, expected) {
    const actualType = typeof received;
    const pass = actualType === expected;
    
    return {
      pass,
      message: () => pass
        ? `expected ${received} not to be type ${expected}`
        : `expected ${received} to be type ${expected}, got ${actualType}`,
    };
  },
});

// 使用
test('type matcher', () => {
  expect('hello').toBeType('string');
  expect(42).toBeType('number');
});

TypeScript 类型定义 #

typescript
// matchers.d.ts
declare global {
  namespace jest {
    interface Matchers<R> {
      toBeWithinRange(floor: number, ceiling: number): R;
      toBeType(expected: string): R;
      toBeResolved(): Promise<R>;
    }
  }
}

export {};

自定义报告器 #

基本报告器 #

javascript
// customReporter.js
class CustomReporter {
  constructor(globalConfig, options) {
    this._globalConfig = globalConfig;
    this._options = options;
  }

  onRunStart(results, options) {
    console.log('Test run started');
  }

  onTestResult(test, testResult, results) {
    console.log(`Test file: ${test.path}`);
    console.log(`Passed: ${testResult.numPassingTests}`);
    console.log(`Failed: ${testResult.numFailingTests}`);
  }

  onRunComplete(contexts, results) {
    console.log('\nTest run completed');
    console.log(`Total: ${results.numTotalTests}`);
    console.log(`Passed: ${results.numPassedTests}`);
    console.log(`Failed: ${results.numFailedTests}`);
  }
}

module.exports = CustomReporter;

配置报告器 #

javascript
// jest.config.js
module.exports = {
  reporters: [
    'default',
    ['<rootDir>/customReporter.js', { option: 'value' }],
  ],
};

HTML 报告器 #

javascript
// htmlReporter.js
const fs = require('fs');

class HtmlReporter {
  constructor(globalConfig, options) {
    this._outputPath = options.outputPath || 'test-report.html';
  }

  onRunComplete(contexts, results) {
    const html = this._generateHtml(results);
    fs.writeFileSync(this._outputPath, html);
    console.log(`Report generated: ${this._outputPath}`);
  }

  _generateHtml(results) {
    return `
<!DOCTYPE html>
<html>
<head>
  <title>Test Report</title>
</head>
<body>
  <h1>Test Report</h1>
  <p>Total: ${results.numTotalTests}</p>
  <p>Passed: ${results.numPassedTests}</p>
  <p>Failed: ${results.numFailedTests}</p>
</body>
</html>
    `;
  }
}

module.exports = HtmlReporter;

自定义运行器 #

基本运行器 #

javascript
// customRunner.js
const { TestRunner } = require('jest-runner');

class CustomRunner extends TestRunner {
  async runTests(tests, watcher, onStart, onResult, onFailure, options) {
    // 自定义测试执行逻辑
    console.log('Running tests with custom runner');
    
    return await super.runTests(
      tests,
      watcher,
      onStart,
      onResult,
      onFailure,
      options
    );
  }
}

module.exports = CustomRunner;

配置运行器 #

javascript
// jest.config.js
module.exports = {
  runner: './customRunner.js',
};

快照序列化器 #

自定义序列化器 #

javascript
// customSerializer.js
module.exports = {
  test: (val) => val && val.isCustomType,
  serialize: (val, config, indentation, depth, refs) => {
    return `CustomType(${val.value})`;
  },
};

配置序列化器 #

javascript
// jest.config.js
module.exports = {
  snapshotSerializers: ['<rootDir>/customSerializer.js'],
};

Moment.js 序列化器 #

javascript
// momentSerializer.js
module.exports = {
  test: (val) => val && val._isAMomentObject,
  serialize: (val) => `Moment(${val.toISOString()})`,
};

Error 序列化器 #

javascript
// errorSerializer.js
module.exports = {
  test: (val) => val instanceof Error,
  serialize: (val) => `Error: ${val.message}`,
};

自定义测试环境 #

扩展 jsdom 环境 #

javascript
// customEnvironment.js
const JsdomEnvironment = require('jest-environment-jsdom');

class CustomEnvironment extends JsdomEnvironment {
  constructor(config, context) {
    super(config, context);
  }

  async setup() {
    await super.setup();
    
    // 添加自定义全局变量
    this.global.myCustomGlobal = 'value';
    
    // Mock localStorage
    this.global.localStorage = {
      getItem: jest.fn(),
      setItem: jest.fn(),
    };
  }

  async teardown() {
    // 清理
    await super.teardown();
  }
}

module.exports = CustomEnvironment;

Node 环境扩展 #

javascript
// customNodeEnvironment.js
const NodeEnvironment = require('jest-environment-node');

class CustomNodeEnvironment extends NodeEnvironment {
  async setup() {
    await super.setup();
    
    // 设置环境变量
    this.global.process.env.TEST_MODE = 'true';
    
    // 添加全局工具
    this.global.testUtils = {
      createMockData: () => ({ id: 1 }),
    };
  }
}

module.exports = CustomNodeEnvironment;

监视插件 #

自定义监视插件 #

javascript
// customWatchPlugin.js
class CustomWatchPlugin {
  constructor({ stdin, stdout }) {
    this._stdin = stdin;
    this._stdout = stdout;
  }

  apply(jestHooks) {
    jestHooks.onFileChange(({ projects }) => {
      console.log('Files changed');
    });
  }

  getUsageInfo() {
    return {
      key: 'c',
      prompt: 'run custom command',
    };
  }

  run(globalConfig, updateConfigAndRun) {
    console.log('Running custom command');
    return Promise.resolve(true);
  }
}

module.exports = CustomWatchPlugin;

配置监视插件 #

javascript
// jest.config.js
module.exports = {
  watchPlugins: [
    '<rootDir>/customWatchPlugin.js',
    'jest-watch-typeahead/filename',
    'jest-watch-typeahead/testname',
  ],
};

实用扩展示例 #

API 响应匹配器 #

javascript
expect.extend({
  toBeValidApiResponse(received) {
    const pass = received &&
      typeof received === 'object' &&
      'data' in received &&
      'status' in received;
    
    return {
      pass,
      message: () => pass
        ? 'expected response to be invalid'
        : 'expected valid API response with data and status fields',
    };
  },
});

// 使用
test('API response', async () => {
  const response = await fetchApi();
  expect(response).toBeValidApiResponse();
});

组件状态匹配器 #

javascript
expect.extend({
  toHaveState(received, state) {
    const pass = received.state(state.key) === state.value;
    
    return {
      pass,
      message: () => pass
        ? `expected component not to have state ${state.key} = ${state.value}`
        : `expected component to have state ${state.key} = ${state.value}`,
    };
  },
});

时间匹配器 #

javascript
expect.extend({
  toBeAfter(received, date) {
    const pass = new Date(received) > new Date(date);
    
    return {
      pass,
      message: () => pass
        ? `expected ${received} not to be after ${date}`
        : `expected ${received} to be after ${date}`,
    };
  },
  
  toBeBefore(received, date) {
    const pass = new Date(received) < new Date(date);
    
    return {
      pass,
      message: () => pass
        ? `expected ${received} not to be before ${date}`
        : `expected ${received} to be before ${date}`,
    };
  },
});

发布扩展 #

npm 包结构 #

text
my-jest-matchers/
├── src/
│   ├── index.js
│   └── matchers/
│       ├── toBeWithinRange.js
│       └── toBeType.js
├── types/
│   └── index.d.ts
├── package.json
└── README.md

package.json #

json
{
  "name": "my-jest-matchers",
  "version": "1.0.0",
  "main": "src/index.js",
  "types": "types/index.d.ts",
  "peerDependencies": {
    "jest": ">=27.0.0"
  },
  "keywords": [
    "jest",
    "matchers",
    "testing"
  ]
}

使用发布的扩展 #

javascript
// jest.setup.js
import 'my-jest-matchers';

最佳实践 #

1. 提供清晰的错误消息 #

javascript
expect.extend({
  toBeValid(received) {
    return {
      pass: received.valid,
      message: () => received.valid
        ? `expected ${received} to be invalid`
        : `expected ${received} to be valid. Errors: ${received.errors.join(', ')}`,
    };
  },
});

2. 支持链式调用 #

javascript
expect.extend({
  toBeValidEmail(received) {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    const pass = emailRegex.test(received);
    
    return {
      pass,
      message: () => pass
        ? `expected ${received} not to be a valid email`
        : `expected ${received} to be a valid email`,
    };
  },
});

// 支持 .not
expect('invalid').not.toBeValidEmail();

3. 提供类型定义 #

typescript
declare global {
  namespace jest {
    interface Matchers<R> {
      toBeValidEmail(): R;
      toBeValid(): R;
    }
  }
}

下一步 #

现在你已经掌握了 Jest 扩展与插件,接下来学习 React 测试 实战 React 组件测试!

最后更新:2026-03-28