Skip to content

处理外部依赖的 7 种测试策略

测试使用第三方库的代码

核心概念

第三方库测试的关键挑战:

  • 不可控性:第三方库的行为我们无法控制
  • 网络依赖:可能涉及真实的 HTTP 请求
  • 测试隔离:避免测试受外部服务影响
  • 接口变化:第三方库更新可能影响测试
  • 调用第三方模块,比如调用了 axios,应该如何测试呢?

测试策略

调用第三方模块,比如调用了 axios,应该如何测试呢?

js
import axios from 'axios';

interface User {
  name: string;
  age: number;
}

export async function doubleUserAge() {
  // 调用了第三方模块
  // const user: User = await axios("/user/1");
  // 对象  让你直接调用对象上的方法
  const user: User = await axios.get('/user/1');
  return user.age * 2;
}

测试用例:

js
import { test, vi, expect } from 'vitest';
import { doubleUserAge } from './third-party-modules';
import axios from 'axios';
import { config } from './config';

vi.mock('axios');

test('第三方模式的处理 axios', async () => {
  // vi.mocked(axios).mockResolvedValue({ name: "xzx", age: 18 });
  vi.mocked(axios.get).mockResolvedValue({ name: 'xzx', age: 18 });

  const r = await doubleUserAge();

  expect(r).toBe(36);
});
  1. Mocking Axios:
  • 使用 vi.mock('axios') 来告诉 vitest mock Axios 库。
  • 使用 vi.mocked(axios.get) 来指定 Axios 的 get 方法应该返回一个特定的值。
  • 在本例中,我们使用 mockResolvedValueOnce 来确保该 mock 只会在一次调用后生效,这样可以测试多次调用时的行为。
  1. 测试逻辑:
  • 在测试中,我们首先 mock Axios 的 get 方法,使其返回一个特定的 JSON 对象。
  • 然后调用 doubleUserAge 函数,并期望其返回值是用户年龄乘以 2,即 36。

测试使用对象的代码

核心概念

对象依赖测试的特点:

  • 直接访问:可以直接修改对象属性
  • 状态管理:需要注意测试间的状态污染
  • 配置驱动:常用于功能开关和配置管理
  • 运行时修改:可以在运行时动态改变行为

测试策略

当代码依赖配置对象时,我们可以直接修改对象属性进行测试。

例子:

js
import { config } from './config';

export function tellAge() {
  if (config.allowTellAge) {
    return 18;
  }

  return '就不告诉你';
}

测试用例:

js
import { it, expect, describe, vi } from 'vitest';
import { tellAge } from './use-object';
import { config } from './config';

describe('使用对象的形式', () => {
  it('allow ', () => {
    // given
    config.allowTellAge = true;

    // when
    const age = tellAge();

    // then
    expect(age).toBe(18);
  });
});

关键点:​​

  • 直接修改配置对象属性
  • 测试不同条件下的行为
  • 测试完成后最好重置配置状态

测试类(Class)

核心概念

类测试的重点:

  • 实例化测试:验证构造函数正确性
  • 方法测试:测试公共方法的行为
  • 状态测试:验证内部状态变化
  • 边界测试:测试异常情况和边界值

测试策略

假设有一个类 User:

js
// user.js
class User {
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }

  getAge() {
    return this.age;
  }
}

测试用例

js
// user.class.test.js
import { it, expect, describe } from 'vitest';
import { User } from './user';

describe('User类测试', () => {
  it('正确返回用户年龄', () => {
    // 1. 创建测试实例
    const user = new User('John Doe', 30);

    // 2. 测试方法
    expect(user.getAge()).toBe(30);
  });

  it('构造函数正确设置属性', () => {
    const user = new User('Alice', 25);
    expect(user.name).toBe('Alice');
    expect(user.age).toBe(25);
  });
});

关键点:​​

  • 测试公共方法
  • 验证构造函数是否正确初始化
  • 可以测试边界情况和异常处理

测试常量

核心概念

常量测试的场景:

  • 业务逻辑影响:常量直接影响程序行为
  • 模块模拟:使用模块级别的 Mock
  • 部分替换:只替换需要的常量,保留其他导出
  • 配置切换:测试不同配置下的行为

测试策略

虽然常量通常不需要测试,但当它们影响业务逻辑时,我们可以使用模块模拟技术。

示例代码:

假设你有一个常量类 config:

config.js

js
const config = {
  name: 'xzx',
  name: 18,
};

export default config;
js
import { name } from './config';

export function tellName() {
  return name + '-hahaha';
}

测试用例:

js
import { it, expect, describe, vi } from "vitest";
import { tellName } from "./use-variable";
import { name, gold } from "./config";

vi.mock("./config", async (importOriginal) => {
  const obj = await importOriginal() as any // 源文件的对象
  return { ...obj, name: "xiaohong" }; // 只改源文件的name值
});

describe("使用变量的形式", () => {
  it("tell name ", () => {
    console.log(gold);
    // when
    const name = tellName();

    // then
    expect(name).toBe("xiaohong-heiheihei");
  });
});

关键点:​​

  • 使用 vi.mock 部分模拟模块
  • 保留不需要修改的原始导出
  • 可以测试常量在业务逻辑中的使用

环境变量测试详解

核心概念

环境变量是应用配置的核心,包括:

  • 配置管理:API 地址、数据库连接等
  • 环境区分:开发、测试、生产环境
  • 功能开关:控制特定功能启用/禁用
  • 敏感信息:密钥、认证信息等

测试策略

获取方式

js
// Vite 项目
const apiUrl = import.meta.env.VITE_API_URL;
const mode = import.meta.env.MODE;

// Node.js 环境
const dbUrl = process.env.DATABASE_URL;

基础环境变量管理

js
export class EnvironmentConfig {
  static getApiUrl() {
    return import.meta.env.VITE_API_URL || 'http://localhost:3000/api';
  }

  static isProduction() {
    return import.meta.env.PROD === true;
  }

  static getFeatureFlags() {
    return {
      enableAnalytics: import.meta.env.VITE_ENABLE_ANALYTICS === 'true',
      maxRetries: parseInt(import.meta.env.VITE_MAX_RETRIES || '3', 10),
    };
  }
}

测试用例

js
describe('环境变量测试', () => {
  beforeEach(() => {
    vi.unstubAllEnvs(); // 清除环境变量模拟
  });

  it('获取API URL', () => {
    vi.stubEnv('VITE_API_URL', 'https://api.production.com');
    expect(EnvironmentConfig.getApiUrl()).toBe('https://api.production.com');
  });

  it('使用默认值', () => {
    expect(EnvironmentConfig.getApiUrl()).toBe('http://localhost:3000/api');
  });

  it('解析功能开关', () => {
    vi.stubEnv('VITE_ENABLE_ANALYTICS', 'true');
    vi.stubEnv('VITE_MAX_RETRIES', '5');

    const flags = EnvironmentConfig.getFeatureFlags();
    expect(flags.enableAnalytics).toBe(true);
    expect(flags.maxRetries).toBe(5);
  });

  // 多环境配置测试
  it.each([
    ['development', 'http://dev.api.com', false],
    ['production', 'https://prod.api.com', true],
  ])('%s 环境配置', (env, apiUrl, isProd) => {
    vi.stubEnv('MODE', env);
    vi.stubEnv('VITE_API_URL', apiUrl);
    vi.stubEnv('PROD', isProd);

    expect(EnvironmentConfig.getApiUrl()).toBe(apiUrl);
    expect(EnvironmentConfig.isProduction()).toBe(isProd);
  });
});

环境变量验证器

js
export class EnvironmentValidator {
  static parseBoolean(value, defaultValue = false) {
    if (!value) return defaultValue;
    return value.toLowerCase() === 'true';
  }

  static parseInt(value, defaultValue = 0) {
    if (!value) return defaultValue;
    const parsed = parseInt(value, 10);
    return isNaN(parsed) ? defaultValue : parsed;
  }

  static validateEnum(value, validValues, defaultValue) {
    return validValues.includes(value) ? value : defaultValue;
  }
}

// 测试验证器
describe('环境变量验证器', () => {
  it.each([
    ['true', true],
    ['false', false],
    [undefined, false],
  ])('解析布尔值 %s -> %s', (input, expected) => {
    expect(EnvironmentValidator.parseBoolean(input)).toBe(expected);
  });
});

全局变量和 Window 对象测试详解

核心概念

浏览器全局对象包括:

  • Navigator:浏览器信息和功能检测
  • Location:URL 相关操作
  • Storage:localStorage、sessionStorage
  • Console:日志输出

测试策略

js
export class BrowserDetector {
  static isMobile() {
    const userAgent = navigator.userAgent.toLowerCase();
    return /android|iphone|ipad/i.test(userAgent);
  }

  static getLanguage() {
    return navigator.language || 'en-US';
  }

  static isOnline() {
    return navigator.onLine;
  }
}

export class UrlManager {
  static getCurrentUrl() {
    return window.location.href;
  }

  static getQueryParam(name) {
    const params = new URLSearchParams(window.location.search);
    return params.get(name);
  }
}

测试用例

js
describe('全局变量测试 - Vitest 官方API', () => {
  afterEach(() => {
    vi.unstubAllGlobals(); // 清理所有全局变量模拟
  });

  describe('Navigator 对象测试', () => {
    it('检测移动设备', () => {
      vi.stubGlobal('navigator', {
        userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_5_1)',
        language: 'zh-CN',
        onLine: true,
      });

      expect(BrowserDetector.isMobile()).toBe(true);
      expect(BrowserDetector.getLanguage()).toBe('zh-CN');
      expect(BrowserDetector.isOnline()).toBe(true);
    });

    it('检测桌面设备', () => {
      vi.stubGlobal('navigator', {
        userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/91.0',
        language: 'en-US',
      });

      expect(BrowserDetector.isMobile()).toBe(false);
      expect(BrowserDetector.getLanguage()).toBe('en-US');
    });
  });

  describe('Location 对象测试', () => {
    it('获取URL信息', () => {
      vi.stubGlobal('location', {
        href: 'https://example.com/path?param=value#section',
        search: '?param=value',
      });

      expect(UrlManager.getCurrentUrl()).toBe(
        'https://example.com/path?param=value#section'
      );
      expect(UrlManager.getQueryParam('param')).toBe('value');
    });

    it('处理无查询参数的情况', () => {
      vi.stubGlobal('location', {
        href: 'https://example.com/',
        search: '',
      });

      expect(UrlManager.getQueryParam('nonexistent')).toBe(null);
    });
  });
});

间接层和依赖注入测试详解

核心概念

依赖注入解决的问题:

  • 控制反转:对象不自己创建依赖
  • 接口隔离:依赖抽象而非具体实现
  • 可测试性:便于注入 mock 对象,减少模块间直接依赖

测试策略

控制反转:对象不自己创建依赖

❌ 问题代码(紧耦合):

js
// 用户组件自己创建依赖
class UserProfile {
  constructor(userId) {
    this.userId = userId;
    // 组件内部直接创建依赖 - 紧耦合
    this.apiClient = new ApiClient('https://api.example.com');
    this.logger = new ConsoleLogger();
    this.storage = new LocalStorageManager();
  }

  async loadUser() {
    this.logger.log('Loading user...');
    const user = await this.apiClient.get(`/users/${this.userId}`);
    this.storage.set('currentUser', user);
    return user;
  }
}

// 测试困难 - 无法控制依赖
test('用户加载', () => {
  const profile = new UserProfile(1);
  // 😱 实际会发送真实的HTTP请求!
  // 😱 会使用真实的localStorage!
  // 😱 会输出真实的console日志!
});

✅ 解决方案(控制反转):

js
// 依赖从外部注入
class UserProfile {
  constructor(userId, apiClient, logger, storage) {
    this.userId = userId;
    this.apiClient = apiClient; // 外部注入
    this.logger = logger; // 外部注入
    this.storage = storage; // 外部注入
  }

  async loadUser() {
    this.logger.log('Loading user...');
    const user = await this.apiClient.get(`/users/${this.userId}`);
    this.storage.set('currentUser', user);
    return user;
  }
}

// 测试简单 - 完全控制依赖
test('用户加载', async () => {
  const mockApiClient = {
    get: vi.fn().mockResolvedValue({ id: 1, name: 'John' }),
  };
  const mockLogger = { log: vi.fn() };
  const mockStorage = { set: vi.fn() };

  const profile = new UserProfile(1, mockApiClient, mockLogger, mockStorage);
  const user = await profile.loadUser();

  expect(user.name).toBe('John');
  expect(mockLogger.log).toHaveBeenCalledWith('Loading user...');
});

接口隔离:依赖抽象而非具体实现

❌ 问题代码(依赖具体实现):

js
// 购物车直接依赖具体的支付实现
class ShoppingCart {
  constructor() {
    this.items = [];
    // 直接依赖支付宝 - 难以扩展
    this.paymentService = new AlipayService();
  }

  async checkout() {
    const total = this.calculateTotal();
    // 只能用支付宝支付
    return await this.paymentService.pay(total);
  }
}

class AlipayService {
  async pay(amount) {
    // 支付宝支付逻辑
    return { status: 'success', method: 'alipay' };
  }
}

✅ 解决方案(接口隔离):

js
// 定义支付接口
class PaymentInterface {
  async pay(amount) {
    throw new Error('Must implement pay method');
  }
}

// 具体支付实现
class AlipayService extends PaymentInterface {
  async pay(amount) {
    console.log(`支付宝支付: ¥${amount}`);
    return { status: 'success', method: 'alipay', amount };
  }
}

class WechatPayService extends PaymentInterface {
  async pay(amount) {
    console.log(`微信支付: ¥${amount}`);
    return { status: 'success', method: 'wechat', amount };
  }
}

class CreditCardService extends PaymentInterface {
  async pay(amount) {
    console.log(`信用卡支付: ¥${amount}`);
    return { status: 'success', method: 'credit-card', amount };
  }
}

// 购物车依赖抽象接口
class ShoppingCart {
  constructor(paymentService) {
    this.items = [];
    this.paymentService = paymentService; // 依赖抽象接口
  }

  addItem(item) {
    this.items.push(item);
  }

  calculateTotal() {
    return this.items.reduce((sum, item) => sum + item.price, 0);
  }

  async checkout() {
    const total = this.calculateTotal();
    return await this.paymentService.pay(total);
  }
}

// 测试 - 可以注入任何支付方式
describe('购物车支付测试', () => {
  it('支持不同支付方式', async () => {
    const items = [
      { name: '商品1', price: 100 },
      { name: '商品2', price: 200 },
    ];

    // 测试支付宝
    const alipayCart = new ShoppingCart(new AlipayService());
    items.forEach((item) => alipayCart.addItem(item));
    const alipayResult = await alipayCart.checkout();
    expect(alipayResult.method).toBe('alipay');

    // 测试微信支付
    const wechatCart = new ShoppingCart(new WechatPayService());
    items.forEach((item) => wechatCart.addItem(item));
    const wechatResult = await wechatCart.checkout();
    expect(wechatResult.method).toBe('wechat');
  });
});

可测试性:便于注入 mock 对象

❌ 问题代码(难以测试):

js
// 文件上传组件 - 难以测试
class FileUploader {
  constructor() {
    this.uploadUrl = 'https://api.example.com/upload';
  }

  async uploadFile(file) {
    // 直接使用fetch - 测试时会发送真实请求
    const formData = new FormData();
    formData.append('file', file);

    const response = await fetch(this.uploadUrl, {
      method: 'POST',
      body: formData,
    });

    if (!response.ok) {
      throw new Error('Upload failed');
    }

    return await response.json();
  }
}

// 测试困难
test('文件上传', async () => {
  const uploader = new FileUploader();
  const file = new File(['content'], 'test.txt');

  // 😱 这会发送真实的HTTP请求!
  // 😱 需要模拟整个fetch API!
  const result = await uploader.uploadFile(file);
});

✅ 解决方案(可测试):

js
// 可测试的文件上传组件
class FileUploader {
  constructor(httpClient, progressCallback = null) {
    this.httpClient = httpClient;
    this.progressCallback = progressCallback;
  }

  async uploadFile(file) {
    const formData = new FormData();
    formData.append('file', file);

    try {
      this.progressCallback?.(0);

      const result = await this.httpClient.post('/upload', formData, {
        onUploadProgress: (progress) => {
          this.progressCallback?.((progress.loaded / progress.total) * 100);
        },
      });

      this.progressCallback?.(100);
      return result;
    } catch (error) {
      throw new Error(`Upload failed: ${error.message}`);
    }
  }
}

// HTTP客户端接口
class HttpClient {
  async post(url, data, config = {}) {
    throw new Error('Must implement post method');
  }
}

// 真实实现
class AxiosHttpClient extends HttpClient {
  async post(url, data, config = {}) {
    const response = await axios.post(url, data, config);
    return response.data;
  }
}

// 测试简单明了
describe('文件上传测试', () => {
  it('成功上传文件', async () => {
    const mockHttpClient = {
      post: vi.fn().mockImplementation(async (url, data, config) => {
        // 模拟上传进度回调
        if (config?.onUploadProgress) {
          // 模拟渐进式上传进度
          config.onUploadProgress({ loaded: 0, total: 100 });
          config.onUploadProgress({ loaded: 30, total: 100 });
          config.onUploadProgress({ loaded: 70, total: 100 });
          config.onUploadProgress({ loaded: 100, total: 100 });
        }

        // 返回上传结果
        return {
          fileId: '123',
          url: 'https://cdn.example.com/file123.jpg',
        };
      }),
    };

    const progressMock = vi.fn();
    const uploader = new FileUploader(mockHttpClient, progressMock);
    const file = new File(['content'], 'test.jpg', { type: 'image/jpeg' });

    const result = await uploader.uploadFile(file);

    // 验证返回结果
    expect(result.fileId).toBe('123');
    expect(mockHttpClient.post).toHaveBeenCalledWith(
      '/upload',
      expect.any(FormData),
      expect.objectContaining({
        onUploadProgress: expect.any(Function),
      })
    );
    // 验证进度回调被正确调用
    expect(progressMock).toHaveBeenCalledTimes(6); // 0 + 4次模拟进度 + 100
    expect(progressMock).toHaveBeenNthCalledWith(1, 0); // 开始
    expect(progressMock).toHaveBeenNthCalledWith(2, 0); // 0%
    expect(progressMock).toHaveBeenNthCalledWith(3, 30); // 30%
    expect(progressMock).toHaveBeenNthCalledWith(4, 70); // 70%
    expect(progressMock).toHaveBeenNthCalledWith(5, 100); // 100%
    expect(progressMock).toHaveBeenNthCalledWith(6, 100); // 完成
  });

  it('处理上传失败', async () => {
    const mockHttpClient = {
      post: vi.fn().mockRejectedValue(new Error('Network error')),
    };

    const uploader = new FileUploader(mockHttpClient);
    const file = new File(['content'], 'test.txt');

    await expect(uploader.uploadFile(file)).rejects.toThrow(
      'Upload failed: Network error'
    );
  });
});

总结

测试方法对比表

间接输入类型核心技巧主要 API适用场景注意事项
第三方库模拟整个模块vi.mock, vi.mockedaxios、lodash 等外部库验证调用参数和次数
配置对象直接修改属性属性赋值功能开关、配置管理测试后重置状态
类(Class)依赖注入构造函数注入业务服务类、工具类测试公共接口,避免测试私有方法
常量部分模块模拟vi.mock部分模拟配置常量、枚举值保留其他导出不变
环境变量环境变量模拟vi.stubEnv, vi.unstubAllEnvs多环境配置、功能开关测试后清理,验证默认值
全局变量全局变量模拟vi.stubGlobal, vi.unstubAllGlobals浏览器 API、Window 对象优先官方 API,复杂场景用 Object.defineProperty
依赖注入控制反转容器DI 容器模式复杂依赖关系、企业级应用单例 vs 瞬态,接口隔离