测试驱动开发示例

2025-06-10

测试驱动开发示例

居家隔离的诸多好处中,拥有更多时间阅读无疑是其中之一。两周前,我开始重读Kent Beck撰写的《测试驱动开发 (TDD) 圣经》 ,他被大多数人视为 TDD 之父。无论你对 TDD 有何看法,这本书都是测试方面的宝库。我强烈推荐。

秉承本书的精神,本文将以实践的方式,讲解如何开发完全由测试驱动的代码;并通过一个示例,从头到尾讲解如何应用 TDD。我将首先简要回顾一下 TDD,然后通过一个示例,讲解如何以 TDD 的方式编写一个油门。最后,我将分享一些可用于实践 TDD 的资源。

这篇文章的目标读者是那些正在考虑在开发过程中使用 TDD 的人。如果你已经深入研究过 TDD,或者正在使用它,那么这篇文章可能不会给你带来任何新的知识。然而,它仍然可以作为参考,与对这个主题感兴趣的人分享。

前言

TDD 是经受住时间考验的软件工程实践之一。21 世纪初,Kent Beck 出版了《测试驱动开发:实例》一书。这本书已有 20 年历史,但 TDD 作为一个概念,其诞生可能更早。Kent Beck 本人曾表示,他并非 TDD 的“发明者”,而是从旧文章和论文中“重新发现”了它。谦逊的程序员 Dijkstra(1972 年)北约软件工程会议报告(1968 年)都描述了在编写代码之前测试规范的过程。虽然 Kent Beck 可能不是 TDD 的发明者,但他绝对是让 TDD 流行起来的人。

20 多年的工程实践在今天是否仍然有意义?

我们所做的一切都建立在几十年前的抽象概念和决策之上。做出这些决策的人生活在不同的环境中,面临着不同的限制和需要解决的问题。他们所做的,就是我们今天所做的:他们想出了当时所能想到的最佳解决方案。
他们的决策至今仍与我们同在。但更多时候,他们的理由却已不复存在。
技术变了,我们需要解决的问题也变了,世界也变了。

作为一名软件工程师,我学到的最宝贵的技能之一就是质疑一切,理解事物发展的原因。探寻这些决策背后的背景,是理解这些决策是否适用于当今世界的关键。

那么,TDD在今天仍然有意义吗?我认为是的,因为:

  • 我们仍然需要编写单元测试来证明我们的代码符合规范
  • 我们仍然希望减少生产过程中出现的 bug 数量
  • 我们仍然希望快速迭代并经常整合变化
  • 我们仍然希望构建高内聚、松散耦合的组件

我相信 TDD 的前提在我们生活的环境中仍然有效。

TDD 存在争议

并非所有人都认为 TDD 有用。我完全同意——并非每个人都必须使用它。多年来,曾进行过一些研究来探究 TDD 在软件开发过程中的有效性,但大多没有定论。我认为这是因为对源代码质量和迭代速度的定量测量过于嘈杂,而且依赖于社会因素——所有这些因素在研究中都很难考虑。

在这篇冗长的序言的最后,我想说,我对 TDD 并不虔诚——我希望你也一样。它就像我们工具箱里的其他工具一样——它让我们从不同的角度看待问题。

测试驱动开发 (TDD)

TDD 是一种可预测的代码开发方式,它依赖于以下三个步骤:

  1. 红色- 编写一个单元测试,运行它并观察它是否失败。单元测试应该简短,并专注于被测系统的单一行为。通过编写失败的测试,您可以确保测试调用了正确的代码,并且代码不是意外运行的。这是一个有意义的失败,并且您预期它会失败。
  2. 绿色- 编写使测试通过所需的最少量代码
  3. 重构- 消除重复(包括测试和代码中的重复,包括测试和代码之间的重复)。更一般地说,这是执行重构的步骤。

TDD 周期

开始使用 TDD 并不需要太多知识。有效地使用它只需要反复练习。一个项目接一个项目,你的技能就会越来越精湛。

为什么选择 TDD?

  • 你总是离功能代码只有一个测试的距离
  • 测试更具表现力;结果通常是覆盖模块行为而不是底层实现的测试
  • 增加了测试覆盖率并减少了测试和生产代码之间的耦合
  • 当你知道要构建什么,但不知道从哪里开始时,它非常有用;当你需要在不熟悉的代码库中添加或更改新功能时,这种情况很常见

节流示例

在本节中,我们将构建一个节流阀。节流的最终目标是限制在给定时间间隔内函数的调用次数。它通常用于避免过多的调用(例如远程服务器)导致接收方过载,或者因为事件样本足以继续执行功能。

总而言之,限制一个函数的执行顺序意味着确保该函数在指定时间段内最多被调用X次(例如,每秒最多三次)。我们要构建的限流器是一个稍微简化的版本,它只允许在指定时间段内最多调用一次。具体规范如下:

throttle returns a function which is called at most once in a specified time period. 
It takes as input the function to throttle and the period. 
If the period is less or equal than zero, then no throttle is applied.
Enter fullscreen mode Exit fullscreen mode

让我们尝试构建它。由于我们使用的是 TDD,这意味着我们首先要编写测试。

第一次测试

    describe("Given the throttle time is 0", () => {
        it("Runs the function when we call it", () => {
            let count = 0;
            const 
                fun = () => count++,
                funT = throttle(fun, 0);
            funT();
            expect(count).toBe(1);
        });
    });
Enter fullscreen mode Exit fullscreen mode

在测试中,我们定义了一个名为fun的简单函数,每次调用该函数时,它都会递增一个变量count 。我们调用了throttle函数,并将刚刚定义的函数作为参数,并将节流周期设置为零。根据规范,如果节流周期为零,则调用该函数时必须调用它。我们将对fun进行节流的结果命名为funT (即 fun Throttled) 。

运行测试,看看它是否失败。现在,我们必须通过编写尽可能少的代码让它通过。所以,让我们创建throttle函数:

function throttle(fun, throttleTime) {
    return () => {
        fun();
    }
};

module.exports = { throttle };
Enter fullscreen mode Exit fullscreen mode

再次运行测试,结果显示一切正常!为了使测试通过,我们只需创建throttle函数并使其调用fun即可。目前没有什么需要重构的,所以我们将进行下一个测试。

第二次测试

根据规范,如果节流周期为零,则该函数每次调用时都必须调用一次,因为没有应用节流。我们来测试一下:

    describe("Given the throttle time is 0", () => {
        it("Runs the function 'every' time we call it", () => {
            let count = 0;
            const 
                fun = () => count++,
                funT = throttle(fun, 0),
                calls = 10;
            for (let i = 0; i < calls; i++) {
                funT();
            }    
            expect(count).toBe(calls);
        });
    });
Enter fullscreen mode Exit fullscreen mode

与上一个测试中调用一次funT不同,现在我们调用它十次,并且我们期望count变量最后为十。

运行测试,结果……一切正常。我们甚至不需要添加任何代码,很好。在进行下一个测试之前,我们将进行重构:第二个测试包含第一个测试,所以我们可以将其移除,这样就剩下以下代码套件:

describe("throttle suite", () => {

    describe("Given the throttle period is 0", () => {
        it("Runs the function 'every' time we call it", () => {
            let count = 0;
            const 
                fun = () => count++,
                funT = throttle(fun, 0);
                calls = 10;
            for (let i = 0; i < calls; i++) {
                funT();
            }    
            expect(count).toBe(calls);
        });
    });
});
Enter fullscreen mode Exit fullscreen mode

第三次测试

当节流周期为负时,我们添加另一个测试:

    describe("Given the throttle period is negative", () => {
        it("Runs the function 'every' time we call it", () => {
            let count = 0;
            let count = 0, calls = 10;
            const
                fun = () => count++,
                funT = throttle(fun, -10);
            for (let i = 0; i < calls; i++) {
                funT();
            }    
            expect(count).toBe(calls);
        });
    });
Enter fullscreen mode Exit fullscreen mode

再次,它通过了,我们无需添加任何代码。我们可以重构,因为对负周期和零周期的测试非常相似:

describe("throttle suite", () => {

    const runFun = (throttlePeriod) => {
        it("Runs the function 'every' time we call it", () => {
            let count = 0, calls = 10;
            const 
                fun = () => count++,
                funT = throttle(fun, throttlePeriod);
            for (let i = 0; i < calls; i++) {
                funT();
            }    
            expect(count).toBe(calls);
        });
    };

    describe("Given the throttle period is 0", () => runFun(0));
    describe("Given the throttle period is negative", () => runFun(-10));
});
Enter fullscreen mode Exit fullscreen mode

第四次测试

describe("Given the throttle period is positive", () => {
        describe("When the throttle period has not passed", () => {
            it("Then `fun` is not called", () => {
                let count = 0;
                const
                    fun = () => count++,
                    funT = throttle(fun, 1* time.Minute);

                funT();
                expect(count).toBe(1);
                funT();
                expect(count).toBe(1);
            });
        });
    });
Enter fullscreen mode Exit fullscreen mode

运行测试并观察其失败:

Failures:
1) throttle suite 

   Given the throttle period is positive 
   When the throttle period has not passed 
   Then `fun` is not called
     Message:
       Expected 2 to be 1.
Enter fullscreen mode Exit fullscreen mode

这里发生了什么?我们期望第一次调用funT能够成功,因为节流机制不适用于第一次调用。因此,在第一个期望中,我们检查变量count是否等于 1。第二次调用funtT时必须进行节流,因为第一次调用和第二次调用之间至少需要间隔一分钟;这就是为什么我们期望在第二个期望中count仍然为 1。但事实并非如此。count变量为 2,因为我们尚未实现任何节流逻辑。

为了让测试通过,最小的步骤是什么?我想到的是:

  • 检查这是否是我们第一次调用该函数
  • 区分正节流周期和小于零的周期
function throttle(fun, throttleTime) {
    let firstInvocation = true;
    return () => {
        if (throttleTime <= 0) {
            fun();
            return;
        }
        if (firstInvocation) {
            firstInvocation = false;
            fun();
        }
    }
};
Enter fullscreen mode Exit fullscreen mode

firstInvocation的引入if statement足以使测试通过。

第五次测试

下一个很有趣。

        describe("When the throttle period has passed", () => {
            it("Then `fun` is called", () => {
                let count = 0;
                const
                    fun = () => count++,
                    funT = throttle(fun, 1* time.Minute);

                funT();
                expect(count).toBe(1);
                // 1 minute later ...
                funT();
                expect(count).toBe(2);
            });
        });
Enter fullscreen mode Exit fullscreen mode

在这个测试中,我们想要验证一分钟后函数不会被限制。但是,我们如何对时间进行建模呢?我们需要一个可以跟踪时间的程序,比如计时器或类似的程序。更重要的是,我们需要在测试中操控计时器的状态。假设我们已经拥有了所需的一切,并相应地修改测试:

        describe("When the throttle period has passed", () => {
            it("Then `fun` is called", () => {
                let count = 0, timer = new MockTimer();
                const
                    fun = () => count++,
                    funT = throttle(fun, 1 * time.Minute, timer);

                funT();
                expect(count).toBe(1);
                // fast forward 1 minute in the future
                timer.tick(1 * time.Minute); 
                funT();
                expect(count).toBe(2);
            });
        });
Enter fullscreen mode Exit fullscreen mode

此版本测试与上一版本的区别在于引入了MockTimer。它在测试开始时就用其余变量进行了初始化。在第一个期望之后,会立即调用计时器tick方法,将计时器向后移动一分钟。由于节流阀超时时间为一分钟,因此我们预期下一次对funT() 的调用将会成功。

让我们运行测试。不出所料,测试失败了,因为 MockTimer 不存在。我们需要创建它。

在此之前,我们先来思考一下如何在 throttle 函数中使用计时器。你可以想出不同的使用方法。就我而言,我决定需要一种方法来启动计时器并检查它是否已过期。考虑到这一点,让我们修改throttle函数,使其使用一个尚不存在的计时器。在实现函数之前使用它似乎很愚蠢,但实际上它非常有用,因为你可以在编写代码之前了解 API 的可用性。

function throttle(fun, throttleTime, timer) {
    let firstInvocation = true;    
    return () => {
        if (throttleTime <= 0) {
            fun();
            return;
        }
        if (firstInvocation) {
            firstInvocation = false;
            fun();
            timer.start(throttleTime);
            return;
        }
        if (timer.isExpired()) {
            fun();
            timer.start(throttleTime);
        }
    }
};
Enter fullscreen mode Exit fullscreen mode

建立了api,让我们为测试实现一个模拟计时器:

class MockTimer {
    constructor() {
        this.ticks = 0;
        this.timeout = 0;
    }

    tick(numberOfTicks) {
        this.ticks += numberOfTicks ? numberOfTicks : 1;
    }

    isExpired() {
        return this.ticks >= this.timeout;
    }

    start(timeout) {
        this.timeout = timeout;
    }
}
Enter fullscreen mode Exit fullscreen mode

再次运行测试,然后,测试通过了!

让我们改变我们的测试并使其更加丰富:

describe("When the throttle period has passed", () => {
    it("Then `fun` is called", () => {
        let count = 0, timer = new MockTimer();
        const
            fun = () => count++,
            funT = throttle(fun, 1 * time.Minute, timer);

        funT();
        expect(count).toBe(1);

        timer.tick(1 * time.Minute);
        funT();
        expect(count).toBe(2);

        timer.tick(59 * time.Second);
        funT();
        expect(count).toBe(2);

        timer.tick(1* time.Second);
        funT();
        expect(count).toBe(3);

        for (let i = 0; i < 59; i++) {
            timer.tick(1 * time.Second);
            funT(); 
            expect(count).toBe(3);
        }

        timer.tick(1* time.Second);
        funT();
        expect(count).toBe(4);
    });
});
Enter fullscreen mode Exit fullscreen mode

此时,我们只需要插入一个实际的计时器,我们可以用类似的过程构建它,例如:

class Timer {
    constructor() {
        this.expired = true;
        this.running = false;
    }

    isExpired() {
        return this.expired; 
    }

    start(timeout) {
        if (this.running) {
            return new Error("timer is already running");
        }
        this.expired = false;
        this.running = true;
        setTimeout(() => {
            this.expired = true;
            this.running = false;
        }, timeout);
    }
}
Enter fullscreen mode Exit fullscreen mode

整理 API

最后还有一件事。我们可以创建一个默认计时器,而不需要调用者将其作为参数传递:

function throttle(fun, throttleTime) {
    return throttleWithTimer(fun, throttleTime, new Timer());
}

function throttleWithTimer(fun, throttleTime, timer) {
// ... same as before
Enter fullscreen mode Exit fullscreen mode

最后我们可以使用我们的节流功能:

throttle(onClickSendEmail, 1 * time.Second);
Enter fullscreen mode Exit fullscreen mode

实践 TDD

如果您喜欢先写测试,那就试试 TDD 吧。本文展示了节流函数,或许您可以自己尝试一下去抖动功能。在构思这篇文章时,我差点就决定以康威的《生命游戏》为例,但很快我就意识到文章篇幅太长了。如果您愿意,用 TDD 进行构建会是一个有趣的练习。

您还可以尝试一些在线提供的编程Katas ,例如:

结论

无论你选择哪种方式来锻炼你的 TDD 能力,我的建议都是给自己一些时间。至少对我来说,TDD 并没有立刻奏效。我第一次尝试的时候,遇到了瓶颈——我搞不清楚如何在写代码之前先写测试。但我不断自学,最终自然而然地就学会了先写测试,再写代码。


在Twitter上关注我,即可在你的动态中获取新文章。
封面图片由GraphicMama 团队提供

鏂囩珷鏉ユ簮锛�https://dev.to/napicella/test-driven-development-by-example-29g8
PREV
作为 Vue 开发人员,您是否犯了这些错误?
NEXT
Golang 模式 - 第一部分