单元测试:从基础到实践的系统性总结

84 阅读5分钟

单元测试(Unit Testing)是软件开发中不可或缺的一环。本文结合《有效的单元测试》一书的理念以及笔者近期在 React 项目中的开发经验,对单元测试的作用、常见误区、个人体会以及实践中的技巧进行系统性总结,希望为开发者提供启发。

1. 为什么要写单元测试?

1. 保证功能的完整性  

单元测试可以确保代码实现符合预期,避免引入不可控的逻辑错误。

2. 提高代码健壮性  

通过各种边界条件的测试,增强代码对异常情况的容错能力。

3. 方便重构  

单元测试的存在为代码重构提供了强有力的安全网,使开发者能更早、更自信地优化代码。

4. 影响代码架构设计  

测试驱动开发(TDD)强调先写测试再写实现,这种方法促使开发者设计出更具可测试性和模块化的代码。

5. 作为代码的参考文档  

单元测试不仅验证了功能,还能够直观展现代码的使用方式,为团队成员提供宝贵的参考。


2. 国内单元测试应用不足的原因

1. 性价比问题  

单元测试往往需要投入比开发更长的时间,但收益在短期内可能不明显,尤其在追求快速交付的环境中。

2. 开发文化差异  

一些团队对测试的重视程度不足,缺乏强制性流程和统一的质量标准。

3. 个人关于单元测试的想法

1. 时间投入与价值  

确实,编写单元测试会耗费大量时间,有时甚至超过开发时间,但它对代码质量的提升是无可替代的。

2. AI 工具助力  

使用如 Copilot 等 AI 工具,可以显著提高单测编写效率。然而,无论工具多么智能,代码的可读性、准确性和可靠性仍需人工审查。

3. 单元测试的最佳时机  

  • 理想情况:在开发之前编写测试(TDD)。  

  • 现实情况下:在模块完成后及时补充,而不是等项目完成再补测试。

4. 模块化与测试驱动  

高质量的测试不仅证明实现正确,还能引导开发者优化模块化设计。

4. 单元测试的核心理念(基于《有效的单元测试》)

1. 可读性优先  

清晰的断言  

比如使用 .includes 代替 .indexOf,避免魔法值 -1,通过直观的匹配器(如 Jest 的内置断言)提升可读性。

职责单一  

每个测试只验证一个功能点,避免测试因内部微小变化频繁失败。

2. 结构化代码  

合理的代码组织能帮助开发者快速定位实现高层概念的代码。

3. 准确性  

测试应关注行为而非实现。以 React 组件为例:  

  • 确保 props 参数配置正确。  

  • 验证用户交互行为的准确性。

4. 可靠性  

避免以下不可靠的测试:  

  • 永不失败的测试  

  • 总是失败的测试  

  • 与功能无关的失败  

  • 表面成功但存在潜在缺陷的测试

5. React 项目中的单测实践

1. 复杂组件的依赖测试  

React 组件设计的时候,会将负责的模块拆分成不同的组件,最后在某个组件中组装,模拟代码如下

import A from './A'
import {B} from './B'

const Container = () => {
  return <div>
    <A />
    <B />
  </div>
}

单测意为模块测试,是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。而在 React中,组件应该是最小的程序模块,所以测试对象应该是各个组件。而这种组装组件,我们并不需要再次测试这些被引入的组件的能力,只需要专注当前组件能力即可。

这个就是《有效的单元测试》书中所说的测试替身。细分的话,应该是书中所说的测试桩。测试桩是用最简单的可能实现来替换真实实现。通过测试桩,我们可以隔离被测试代码,这样我们的测试更加有针对性,也更容易被理解,也更容易建立测试。

测试代码:  

jest.mock('./A', () => ({
    default: () => <div>A</div>
}));
jest.mock('./B', () => ({
    B: () => <div>B</div>
}));
it("Container renders correctly", () => {
    render(<Container />);
    expect(screen.getByText("A")).toBeInTheDocument();
    expect(screen.getByText("B")).toBeInTheDocument();
});

2. localStorage 以及其他原生方法应用的测试

class TestStorage {  
  // snip
  
  set(cities: City[]) {
    if (!cities?.length) {
      return;
    }  
    const newListItems = uniqBy(cities, 'id').slice(0, this.max);
    storage.setItem(KEY_YOU_COULD_IGNORE, JSON.stringify(newListItems));
  }
}

巧妙使用 jest.spyOn 拦截原生方法,可以实现对原生方式的能力测试。例如对上述代码的测试:

const mockSetItem = jest.spyOn(Storage.prototype, 'setItem');
it("sets cities correctly", () => {
    mockSetItem.mockImplementationOnce(() => '');
    testStorage.set([hangzhou, chengdu]);
    expect(mockSetItem).toBeCalledWith(KEY, JSON.stringify([hangzhou, chengdu]));
});

3. Hooks 的测试

下面代码创建了 LocaleContext ,并通过它实现全局的 i18n 能力。

export const LocaleContext = createContext<LocaleContextOptions>({
  locale: 'zh-CN',
  localeMap,
});

export const useLocaleGetter = () => {
  const options = useContext(LocaleContext);
  return function get(key: keyof TreeLocale) {
    return options.localeMap[options.locale][key];
  };
};

使用 @testing-library/react-hooksrenderHook 解决 Context 以及 hooks 的测试问题:

const wrapper = ({ children }: { children: React.ReactNode }) => (
    <LocaleContext.Provider value={mockValue}>
        {children}
    </LocaleContext.Provider>
);
const { result } = renderHook(() => useLocaleGetter(), { wrapper });
expect(result.current('searchPlaceholder')).toBe('搜索...');

6. 结语

单元测试可能在短期内减缓开发速度,但它是软件质量的基石。通过精心设计和持续实践,测试不仅能让你的代码更可靠,还会帮助你走得更快、更远。投资单元测试,就是投资未来的开发效率与代码维护性。