返回介绍

7.3.3 单元测试实现原理

发布于 2024-10-13 01:27:19 字数 20583 浏览 0 评论 0 收藏 0

简介

在了解过testing.common后,我们进一步了解testing.T数据结构,以便了解更多单元测试执行的更多细节。

数据结构

源码包src/testing/testing.go:T定义了其数据结构:

type T struct {
    common
    isParallel bool
    context    *testContext // For running tests and subtests.
}

其成员简单介绍如下:

  • common: 即前面绍的testing.common
  • isParallel: 表示当前测试是否需要并发,如果测试中执行了t.Parallel(),则此值为true
  • context: 控制测试的并发调度

因为context直接决定了单元测试的调度,在介绍testing.T支持的方法前,有必要先了解一下context。

testContext

源码包src/testing/testing.go:testContext定义了其数据结构:

type testContext struct {
    match *matcher

    mu sync.Mutex

    // Channel used to signal tests that are ready to be run in parallel.
    startParallel chan bool

    // running is the number of tests currently running in parallel.
    // This does not include tests that are waiting for subtests to complete.
    running int

    // numWaiting is the number tests waiting to be run in parallel.
    numWaiting int

    // maxParallel is a copy of the parallel flag.
    maxParallel int
}

testContext成员简单介绍如下:

  • match:匹配器,用于管理测试名称匹配、过滤等。
  • mu:互斥锁,用于控制testContext成员的互斥访问;
  • startParallel: 用于通知测试可以并发执行的控制管道,测试并发达到最大限制时,需要阻塞等待该管道的通知事件;
  • running: 当前并发执行的测试个数;
  • numWaiting:等待并发执行的测试个数,所有等待执行的测试都阻塞在startParallel管道处;
  • maxParallel:最大并发数,默认为系统CPU数,可以通过参数-parallel n指定。

testContext实现了两个方法用于控制测试发调度。

等待并发执行:testContext.waitParallel()

如果一个测试使用t.Parallel()启动并发,这个测试并不是立即被并发执行,需要检查当前并发执行的测试数量是否达到最大值,这个检查工作统一放在testContext.waitParallel()实现的。

testContext.waitParallel()函数的源码如下:

func (c *testContext) waitParallel() {
    c.mu.Lock()
    if c.running < c.maxParallel {  // 如果当前运行的测试数未达到最大值,直接返回
        c.running++
        c.mu.Unlock()
        return
    }
    c.numWaiting++                  // 如果当前运行的测试数已达最大值,需要阻塞等待
    c.mu.Unlock()
    <-c.startParallel
}

函数实现比较简单,如果当前运行的测试数未达最大值,将c.running++后直接返回即可,否则将c.numWaiting++并阻塞等待其他并发测试结束。

这里有个小细节,阻塞等待后面并没有累加c.running,因为其他并发的测试结束后也不会递减c.running,所以这里阻塞返回时也不用累加,一个测试结束,随即另一个测试开始,c.running个数没有变化。

并发测试结束:testContext.release()

当并发测试结束后,会通过release()方法释放一个信号,用于启动其他等待并发测试的函数。

testContext.release()函数的源码如下:

func (c *testContext) release() {
    c.mu.Lock()
    if c.numWaiting == 0 {         // 如果没有函数在等待,直接返回
        c.running--
        c.mu.Unlock()
        return
    }
    c.numWaiting--                 // 如果有函数在等待,释放一个信号
    c.mu.Unlock()
    c.startParallel <- true // Pick a waiting test to be run.
}

测试执行:tRunner()

函数tRunner用于执行一个测试,在不考虑并发测试、子测试场景下,其处理逻辑如下:

func tRunner(t *T, fn func(t *T)) {
    defer func() {
        t.duration += time.Since(t.start)
        signal := true

        t.report() // 测试执行结束后向父测试报告日志

        t.done = true
        t.signal <- signal // 向调度者发送结束信号
    }()

    t.start = time.Now()
    fn(t)

    t.finished = true
}

tRunner传一个经调度者设置过的testing.T参数和一个测试函数,执行时记录开始时间,然后将testing.T参数传入测试函数并同步等待其结束。

tRunner在defer语句中记录测试执行耗时,并上报日志,最后发送结束信号。

为了避免困惑,上述代码屏蔽了一些子测试和并发测试的细节,比如,defer语句中,如果当前测试包含子测试,则需要等所有子测试结束,如果当前测试为并发测试,则需要唤醒其他等待并发的测试。更多细节,等我们分析Parallel()和Run()时再讨论。

启动子测试:Run()

Run()函数用于启动一个子测试,这个子测试可以是用户的测试函数中主动调用Run()方法启动的,如果用户没有主动调用Run()方法,那么用户的测试函数也是被调度程序以Run()方法启动的。可以说,所有的测试都是Run()方法启动的。

按照惯例,隐去部分代码后的Run()方法如下所示:

func (t *T) Run(name string, f func(t *T)) bool {
    t = &T{ // 创建一个新的testing.T用于执行子测试
        common: common{
            barrier: make(chan bool),
            signal:  make(chan bool),
            name:    testName,
            parent:  &t.common,
            level:   t.level + 1, // 子测试层次+1
            chatty:  t.chatty,
        },
        context: t.context, // 子测试的context与父测试相同
    }
    go tRunner(t, f) // 启动协程执行子测试
    if !<-t.signal { // 阻塞等待子测试结束信号,子测试要么执行结束,要么以Parallel()执行。如果信号为'false',说明出现异常退出
        runtime.Goexit()
    }
    return !t.failed // 返回子测试的执行结果
}

每启动一个子测试都会创建一个testing.T变量,该变量继承当前测试的部分属性,然后以新协程去执行,当前测试会在子测试结束后返回子测试的结果。

子测试退出条件要么是子测试执行结束,要么是子测试设置了Paraller(),否则是异常退出。

启动并发测试:Parallel()

Parallel()方法将当前测试加入到并发队列中,其实现方法如下所示:

func (t *T) Parallel() {
    t.isParallel = true

    t.duration += time.Since(t.start) // 启动并发测试有可能要等待,等待期间耗时需要剔除,此处相当于先记录当前耗时,并发执行开始后再累加

    t.parent.sub = append(t.parent.sub, t) // 将当前测试加入到父测试的列表中,由父测试调度

    t.signal <- true   // Release calling test. 当前测试即将进入并发模式,标记测试结束,以便父测试不必等待并退出Run()
    <-t.parent.barrier // Wait for the parent test to complete.  等待父测试发送子测试启动信号
    t.context.waitParallel() // 阻塞等待并发调度

    t.start = time.Now() // 开始并发执行,重新标记启动时间,这是第二段耗时
}

关于测试耗时统计,看过前面的testContext实现我们知道,启动一个并发测试时,当并发数达到最大时,新的并发测试需要等待,那么等待期间的时间消耗不能统计到测试的耗时中,所以需要先计算当前耗时,在真正被并发调度后才清空t.start以跳过等待时间。

看过前面的Run()方法实现机制后,我们知道一旦子测试以并发模式执行时,需要通知父测试,其通知机制便是向t.signal管道中写入一个信号,父测试便从Run()方法中唤醒,继续执行。

看过前面的tRunner()方法实现机制后,不难理解,父测试唤醒后继续执行,结束后进入defer流程中,在defer中将启动所有子测试并等待子测试执行结束。

完整的测试执行:tRunner()

与简单版的测试执行所不同的是,defer语句中增加了子测试、并发测试的处理逻辑,相对完整的tRunner()代码如下所示:


func tRunner(t *T, fn func(t *T)) {
    t.runner = callerName(0)

    defer func() {
        t.duration += time.Since(t.start) // 进入defer后立即记录测试执行时间,后续流程所花费的时间不应该统计到本测试执行用时中

        if len(t.sub) > 0 { // 如果存在子测试,则启动并等待其完成
            t.context.release() // 减少运行计数
            close(t.barrier)  // 启动子测试
            for _, sub := range t.sub { // 等待所有子测试结束
                <-sub.signal
            }
            if !t.isParallel { // 如果当前测试非并发模式,则等待并发执行,类似于测试函数中执行t.Parallel()
                t.context.waitParallel()
            }
        } else if t.isParallel { // 如果当前测试是并发模式,则释放信号以启动新的测试
            t.context.release()
        }
        t.report() // 测试执行结束后向父测试报告日志

        t.done = true
        t.signal <- signal // 向父测试发送结束信号,以便结束Run()
    }()

    t.start = time.Now() // 记录测试开始时间
    fn(t)

    // code beyond here will not be executed when FailNow is invoked
    t.finished = true
}

测试执行结束,进入defer后需要启动子测试,启动方法为关闭t.barrier管道,然后等待所有子测试执行结束。

需要注意的是,关闭t.barrier管道,阻塞在t.barrier管道上的协程同样会被唤醒,也是发送信号的一种方式,关于管道的更多实现细节,请参考管道实现原理相关章节。

defer中,如果检测到当前测试本身也处理并发中,那么结束后需要释放一个信号(t.context.release())来启动一个等待的测试。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文