Skip to content

单元测试之道

CAUTION

本文源于我在公司内部的一次技术分享,关键信息已做修改、脱敏处理

很多人跟我说,写单测纯纯浪费时间,功能写完,自测一下能跑就行,何必费那么大功夫呢?

恰恰相反,写单元测试是一种长期投资。你每次提交代码触发 CI 自动跑完一轮测试,你就能看到这份投资在持续产生“复利”。复利的力量有多强?爱因斯坦称它为“世界第八大奇迹”

当你未来修改代码时,一定会庆幸自己当初写下的那些单测。至于觉得浪费时间,多半是因为还没掌握正确的方法。有策略地写单测,才能让它真正帮你提升开发效率

业务开发中,没必要为所有代码写单测。优先覆盖核心逻辑、容易出错的部分,以及公共接口。写单测之前,先明确测试对象,是类、函数、库的 API,还是 Web 接口?单测的目的是确保模块行为符合预期。覆盖率固然重要,但不应盲目追求覆盖率,否则南辕北辙。代码修改后,单元测试不该被破坏。(除非需求调整)有些隐藏的问题,Code Review 不好人肉出来,只有通过测试才能暴露出来

本次分享内容:

单测能提高你的编码水平

其实,单元测试不仅是验证功能正确性的工具,更会潜移默化地教你如何写出高质量的代码。通常情况下,“垃圾代码”、“屎山”通常是不可测试的,有如下特点:

  • 缺乏抽象:方法内部直接创建依赖对象(数据库、网络服务等),没有使用依赖注入(DI),无法 Mock
  • 嵌套过深:业务逻辑、控制逻辑层层嵌套,代码路径复杂难以被覆盖
  • 复杂的状态依赖:一改就炸,模块之间耦合严重,难以独立验证
  • 副作用明显:修改全局变量或共享状态,导致模块不可控、测试不幂等,每次执行结果都可能不同
抽象和细节是什么?

抽象前

java
public class List {
    public void add(Object element) {
        if (!readOnly) {
            int newSize = size + 1;
            if (newSize > elements.length) {
                Object[] newElements = new Object[elements.length + 10];
                for (int i = 0; i < size; i++) {
                    newElements[i] = elements[i];
                }
                elements = newElements;
            }
            elements[size++] = element;
        }
    }
}

抽象后

java
public class List {
    public void add(Object element) {
        if (readOnly) {
            return;
        }
        if (atCapacity()) {
            grow();
        }
        addElement(element);
    }

    private addElement(Object Element) {
        elements[size++] = element;
    }
}

写单测的小技巧

那么,如何写出既可测试又高质量的代码呢?这里有几个立竿见影的小技巧:

面向接口编程。多用组合,少用继承。组合之间通过接口抽象依赖,使模块可以被 Mock,从而实现独立测试

抽象外部依赖,设计无状态模块。将数据库、网络服务等外部依赖抽离,通过 DI 的方式传入。尽量让模块保持无状态(无副作用,即 Side Effect),这样测试时不依赖全局环境,易于验证

使用重构技术降低复杂度。利用卫语句、小函数、清晰的条件表达式、DSL、LoD、合理的编程范式等手段,使逻辑简单、可读、可维护

归根结底,能测试的代码更容易维护,而易维护的代码才算高质量。单元测试不仅能配合 CI 自动化检查功能,还能帮你在开发中不断优化代码

LoD(迪米特法则)是什么?

LoD 前

go
func (builder *WelcomeReplyBuilder) BuildReply() (*Result, error) {
	// 1、选择模版
	tpl, err := selectTpl(redis.LiveWelcomeKey)
	if err != nil {
		return nil, err
	}

	// 2、解析模版
	placeholders, formatTpl := resolveTpl(tpl)

	// 3、昵称取前两个字
	shortNickname := ""
	nickname := builder.roomEvent.DyName
	runeCount := utf8.RuneCountInString(nickname)
	if runeCount <= 2 {
		// 如果字符数量小于等于 2,直接返回原始字符串
		shortNickname = nickname
	} else {
		firstTwoRunes := []rune(nickname)[:2]
		shortNickname = string(firstTwoRunes)
	}

	// 4、应用模版 生成欢迎语
	res := applyTpl(formatTpl, placeholders, shortNickname)

	return &Result{Reply: res}, nil
}

LoD 后

go
func (builder *WelcomeReplyBuilder) BuildReply() (*Result, error) {
	// 选择模版
	tpl, err := selectTpl(redis.LiveWelcomeKey)
	if err != nil {
		return nil, err
	}

	// 解析模版
	placeholders, formatTpl := resolveTpl(tpl)

	// 昵称取前两个字
	shortNickname := builder.roomEvent.GetShortNickname()

	// 应用模版 生成欢迎语
	res := applyTpl(formatTpl, placeholders, shortNickname)

	return &Result{Reply: res}, nil
}

// room_event.go
func (e *RoomEvent) GetShortNickname() string {
	nickname := e.DyName
	// 获取字符串的 Unicode 字符数量
	runeCount := utf8.RuneCountInString(nickname)

	// 如果字符数量小于等于 2,直接返回原始字符串
	if runeCount <= 2 {
		return nickname
	}

	// 获取前两个字符的 Unicode 编码
	firstTwoRunes := []rune(nickname)[:2]

	// 将 Unicode 编码转换为字符串
	shortName := string(firstTwoRunes)

	return shortName
}

单测必知必会的概念

先了解几个常用术语和概念,它们是写高质量单元测试的基石:

  1. Mock、Stub、Spy、Faker

它们的作用是隔离外部依赖或状态,保证单元测试只关心自身逻辑。区别在于,Mock 验证方法被调用情况、参数和返回值;Stub 提供固定的返回值,简化依赖;Spy 既能记录调用信息,又可保留原方法行为;Faker 生成伪数据,用于测试不同场景

  1. BDD 与 Given-When-Then

行为驱动测试,以场景为驱动,明确输入、行为、期望结果。以 Go 举例的话可以选择 GoConvey 框架,通常会提供更直观的 BDD 语法

  1. 断言(Assertion)

使用断言库,替代 fmt.Println,让测试可自动化,方便 CI/CD 集成

  1. 覆盖率(Coverage)

衡量测试覆盖了多少代码路径。不要过于极端地追求覆盖率,重点覆盖核心逻辑和边界条件

  1. 无状态、副作用(Side Effect)

避免副作用、保持代码无状态,是编写可测试代码的关键,这能让 Mock 更简单、断言更准确、覆盖率更具意义

Live Coding 环节

案例代码,基于 Golang

  1. 使用断言库,而不是 fmt.Println
  2. 表驱动测试
  3. 在案例的基础上添加功能和单测,展示 Mock 打桩技术
  4. 用猴子补丁(Monkey Patching)隔离不可控依赖,例如 time.Now()
  5. 使用 Copilot 辅助生成测试代码模板

演示 1:断言库,而不是 fmt.Println

  • 库:github.com/stretchr/testify/assert
  • 方便 CI/CD 流水线自动判断测试状态
  • 可读性、维护性更强
go
import (
    "testing"
    "github.com/stretchr/testify/assert"
)

func TestAdd(t *testing.T) {
    result := Add(2, 3)
    assert.Equal(t, 5, result, "2 + 3 应该等于 5")
}

演示 2:表驱动测试

  • 特点:结合表驱动测试,可以覆盖更多逻辑路径,提高覆盖率
go
import "testing"

func TestAdd(t *testing.T) {
    tests := []struct { 
        name     string
        a, b     int
        expected int
    }{ 
        {"both positive", 2, 3, 5}, 
        {"positive + negative", 5, -2, 3}, 
        {"both negative", -3, -7, -10}, 
        {"zero", 0, 5, 5}, 
    } 

    for _, tt := range tests { 
        t.Run(tt.name, func(t *testing.T) { 
			if result := Add(tt.a, tt.b); result != tt.expected { 
                t.Errorf("Add(%d, %d) = %d; want %d", 
					tt.a, tt.b, result, tt.expected) 
            } 
        }) 
    } 
}

func Add(a, b int) int {
    return a + b
}

演示 3:展示 Mock 打桩技术

  • 库:github.com/uber-go/mock
  • 生成 Mock 实现:
bash
mockgen -source=commentfilter.go -destination=mock_commentfilter.go -package=cf
  • 在现有 Mock 基础上覆盖多种输入场景,动态返回结果,提高核心代码覆盖率

演示 4:猴子补丁(Monkey Patching)

  • 库:github.com/bouk/monkey
  • 在运行时动态修改函数、方法或变量,用于替换复杂逻辑或无法修改的依赖
  • 注意:macOS 上需设置 GOARCH=amd64,因为该库不支持 arm64 架构
go
import "github.com/bouk/monkey"

// 示例: 替换函数实现
monkey.Patch(someFunc, func(a int) int { return 42 })

演示 5:使用 Copilot 辅助生成测试代码模板

使用 Copilot 生成测试模板,通过依赖注入让代码更易 Mock 与扩展,人工补充边界与异常场景,完善覆盖率与质量

站在巨人的肩膀:社区实践

案例看点代码传送门
Minikube表驱动测试start_test.go
Nacos依赖注入 MockHealthControllerTest.java
vue-realworld-example-appBDD 框架案例VPagination.spec.js