单元测试之道

概念

原则:

  1. 写单测之前,先弄明白要测试的对象是什么(类、函数、库的 API、还是 web 接口等)
  2. 业务开发,没有必要为所有代码写测试

不可测试的代码:

  1. 复杂的状态依赖
  2. 没做抽象:直接在方法内部创建依赖对象(数据库、网络服务等写死)-> 没有 DI,无法 Mock
  3. 副作用:操作全局变量,模块难以控制其状态 -> 不幂等,每次测试结果都不一样
  4. 嵌套太多:业务逻辑、控制逻辑 复杂度 -> 代码路径难以被覆盖

抽象和细节是啥?

抽象前 💩

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;
        }
    }
}

抽象后 💃

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

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

写可测试的代码:

  1. 面向接口编程 -> 可 Mock
  2. 抽象外部依赖,设计无状态的模块 -> OOP 的依赖注入/DSL
  3. 使用重构技术,降低复杂度 -> 卫语句、小函数、DSL、可读的条件表达式、LoD、编程范式等

LoD 是啥?

LoD 前

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 后

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 -> 隔离外部依赖/状态
  2. BDD、GivenWhenThen 测试场景/Gherkin
    a. 基于 Go 语言特性 t.Run+表驱动测试
    b. BDD 框架(GoConvey
  3. 断言 -> 解决 “print 大法” 无法集成进 CICD,实现自动化测试
  4. 覆盖率

演示

案例背景:

直播间事件处理器

演示内容:

演示 1:断言库(assert)
github.com/stretchr/testify

演示 2:表驱动测试、覆盖率、mockgen 生成 Mock 实现
github.com/uber-go/mock

mockgen -source=commentfilter.go -destination=mock_commentfilter.go -package=cf

演示 3:新增测试用例,演示 Mock 打桩,继续完善覆盖率

演示 4:猴子补丁
在运行时动态修改代码,替换函数、方法或变量的实现,而不需要更改源代码
github.com/bouk/monkey

GOARCH=amd64

演示 5:使用 Copilot 生成 80%测试代码(依赖注入式的构造器)

社区实践: