发布于 2026-01-06 1 阅读
0

在 Go 语言中演示 TDD(测试驱动开发)

在 Go 语言中演示 TDD(测试驱动开发)

测试驱动开发(TDD)是一种在编写代码之前先编写测试的实践方法,它能够降低软件的故障率和缺陷率。
在这篇博文中,我将演示它是如何运作的。

起点

我正在用 Go 语言编写一个应用程序,用于在比克拉姆历 (Bikram Sambat,简称 BS,也称 Vikram Samvat) 和公历日期之间进行转换。Vikram Samvat历主要在尼泊尔和印度使用。即使您不使用它,这个演示也可能有助于您理解 TDD(测试驱动开发)。

目前我已经完成了一些工作,可以创建 BS(比克拉姆历)日期实例,获取其详细信息并将其转换为公历日期。参见:https ://github.com/JankariTech/GoBikramSambat/blob/b99c510b22faf8395becda9a6dec1d0239504bb1/bsdate.go

这些函数也经过测试:https://github.com/JankariTech/GoBikramSambat/blob/b99c510b22faf8395becda9a6dec1d0239504bb1/bsdate_test.go

现在我想添加将公历日期转换为比克拉姆历日期的功能。为此,我希望能够使用公历日期创建一个比克拉姆历日期实例,然后我只需获取比克拉姆历日期的详细信息即可完成转换。

如果能有类似的功能nepaliDate, err := NewFromGregorian(gregorianDay, gregorianMonth, gregorianYear)就太好了,然后就可以直接使用现有的nepaliDate.GetDay() nepaliDate.GetMonth()功能了。nepaliDate.GetYear()

1. 创建测试

根据测试驱动开发(TDD)的原则,我首先需要创建一个测试。因此,
在文件中创建了一个名为 `test_version` 的新函数 由于我已经有一个用于尼泊尔历到公历转换的测试日期表,我将使用这些数据来测试反向转换。bsdate_test.goTestCreateFromGregorian()

以下是测试数据和测试函数:

type TestDateConversionStruc struct {
    bsDate        string
    gregorianDate string
}

var convertedDates = []TestDateConversionStruc{
    {"2068-04-01", "2011-07-17"}, //a random date
    {"2068-01-01", "2011-04-14"}, //1st Baisakh
    {"2037-11-28", "1981-03-11"},
    {"2038-09-17", "1982-01-01"}, //1st Jan
    {"2040-09-17", "1984-01-01"}, //1st Jan in a leap year
...
}

func TestCreateFromGregorian(t *testing.T) {
    for _, testCase := range convertedDates {
        t.Run(testCase.bsDate, func(t *testing.T) {
            var splitedBSDate = strings.Split(testCase.bsDate, "-")
            var expectedBsDay, _ = strconv.Atoi(splitedBSDate[2])
            var expectedBsMonth, _ = strconv.Atoi(splitedBSDate[1])
            var expectedBsYear, _ = strconv.Atoi(splitedBSDate[0])

            var splitedGregorianDate = strings.Split(testCase.gregorianDate, "-")
            var gregorianDay, _ = strconv.Atoi(splitedGregorianDate[2])
            var gregorianMonth, _ = strconv.Atoi(splitedGregorianDate[1])
            var gregorianYear, _ = strconv.Atoi(splitedGregorianDate[0])

            nepaliDate, err := NewFromGregorian(gregorianDay, gregorianMonth, gregorianYear)
            assert.Equal(t, err, nil)
            assert.Equal(t, nepaliDate.GetDay(), expectedBsDay)
            assert.Equal(t, nepaliDate.GetMonth(), expectedBsMonth)
            assert.Equal(t, nepaliDate.GetYear(), expectedBsYear)
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

该函数从列表中获取条目convertedDates,将其拆分,尝试根据特定的公历测试用例创建 BS 日期,然后断言 BS 日期(日、月、年)是否符合预期。

2. 运行测试

测试已经完成,根据TDD(测试驱动开发)的要求,我必须运行它。

go test -v

结果如下:

# NepaliCalendar/bsdate [NepaliCalendar/bsdate.test]
./bsdate_test.go:171:23: undefined: NewFromGregorian
FAIL    NepaliCalendar/bsdate [build failed]
Enter fullscreen mode Exit fullscreen mode

这不出所料,这个函数不存在,难怪我的测试失败了。接下来怎么办?你猜怎么着:实现这个函数。
这就是 TDD 的精髓所在,你只需要按照测试结果修复问题即可。

3. 修复它

这很简单,添加到bsdate.go新函数中:

func NewFromGregorian(gregorianDay, gregorianMonth, gregorianYear int) (Date, error) {

}
Enter fullscreen mode Exit fullscreen mode

4. 重复

再次运行测试后,我得到:

./bsdate.go:195:1: missing return at end of function

没错,我们得返回点什么,但返回什么呢?嘿,不如就用公历日期创建一个假的日期吧。

 func NewFromGregorian(gregorianDay, gregorianMonth, gregorianYear int) (Date, error) {
-
+       return New(gregorianDay, gregorianMonth, gregorianYear)
 }
Enter fullscreen mode Exit fullscreen mode

你是说那样行不通?我不在乎,我做TDD,测试要求我返回一些东西,我就返回,而且我返回的是正确的值类型。

让我们再运行一​​次测试:

=== RUN   TestCreateFromGregorian/2068-04-01
    assert.go:24: got '17' want '1'

    assert.go:24: got '7' want '4'

    assert.go:24: got '2011' want '2068'

=== RUN   TestCreateFromGregorian/2068-01-01
    assert.go:24: got '14' want '1'

    assert.go:24: got '4' want '1'

    assert.go:24: got '2011' want '2068'

....
Enter fullscreen mode Exit fullscreen mode

失败了很多次,你猜对了,转换不起作用。所以我们来实现一些部分。

我们知道公历比格里历早56年多。所以,在格里历年加上56应该会有帮助:

 func NewFromGregorian(gregorianDay, gregorianMonth, gregorianYear int) (Date, error) {
-       return New(gregorianDay, gregorianMonth, gregorianYear)
+       var bsYear = gregorianYear + 56
+       return New(gregorianDay, gregorianMonth, bsYear)
 }
Enter fullscreen mode Exit fullscreen mode

测试结果看起来更好,而不是

....
=== RUN   TestCreateFromGregorian/2037-11-28
    assert.go:24: got '11' want '28'

    assert.go:24: got '3' want '11'

    assert.go:24: got '1981' want '2037'

=== RUN   TestCreateFromGregorian/2038-09-17
    assert.go:24: got '1' want '17'

    assert.go:24: got '1' want '9'

    assert.go:24: got '1982' want '2038'
....
Enter fullscreen mode Exit fullscreen mode

我得到:

....
=== RUN   TestCreateFromGregorian/2037-11-28
    assert.go:24: got '11' want '28'

    assert.go:24: got '3' want '11'

=== RUN   TestCreateFromGregorian/2038-09-17
    assert.go:24: got '1' want '17'

    assert.go:24: got '1' want '9'
....
Enter fullscreen mode Exit fullscreen mode

所以至少有些年份的计算是正确的。让我们通过更精确地计算年份来修正更多测试,并计算出错误月份。

由于BS历法的特殊性,没有算法可以直接从公历转换日期,我们需要一个表格。我们知道1月1日总是落在BS历的第九个月(Paush)。因此,我们有一个BS历年表,其中第一个值是该年1月1日对应的Paush日期,后面是每个BS历月的日期列表。
我们可以轻松地从公历日期中获取对应的日期。从Paush开始,我们计算每个BS历月的日期,当日期超过公历日期时,我们就找到了正确的BS历月。

2074: [13]int{17, 31, 31, 31, 32, 31, 31, 30, 29, 30, 29, 30, 30},
2075: [13]int{17, 31, 31, 32, 31, 31, 31, 30, 29, 30, 29, 30, 30},
2076: [13]int{16, 31, 32, 31, 32, 31, 30, 30, 30, 29, 29, 30, 30},
2077: [13]int{17, 31, 32, 31, 32, 31, 30, 30, 30, 29, 30, 29, 31},
2078: [13]int{17, 31, 31, 31, 32, 31, 31, 30, 29, 30, 29, 30, 30},
Enter fullscreen mode Exit fullscreen mode

这些细节与 TDD 无关,但有助于你理解接下来的算法。

让我们把它写成代码:

 func NewFromGregorian(gregorianDay, gregorianMonth, gregorianYear int) (Date, error) {
        var bsYear = gregorianYear + 56
-       return New(gregorianDay, gregorianMonth, bsYear)
+       var bsMonth = 9                         //Jan 1 always fall in BS month Paush which is the 9th month
+       var daysSinceJanFirstToEndOfBsMonth int //days calculated from 1st Jan till the end of the actual BS month,
+                                               // we use this value to check if the gregorian Date is in the actual BS month
+
+       year := time.Date(gregorianYear, time.Month(gregorianMonth), gregorianDay, 0, 0, 0, 0, time.UTC)
+       var gregorianDayOfYear = year.YearDay()
+
+       //get the BS day in Paush (month 9) of 1st January
+       var dayOfFirstJanInPaush = calendardata[bsYear][0]
+
+       //check how many days are left of Paush
+       daysSinceJanFirstToEndOfBsMonth = calendardata[bsYear][bsMonth] - dayOfFirstJanInPaush + 1
+
+       //If the gregorian day-of-year is smaller or equal to the sum of days between the 1st January and
+       //the end of the actual BS month we found the correct nepali month.
+       //Example:
+       //The 4th February 2011 is the gregorianDayOfYear 35 (31 days of January + 4)
+       //1st January 2011 is in the BS year 2067 and its the 17th day of Paush (9th month)
+       //In 2067 Paush had 30days, This means (30-17+1=14) there are 14days between 1st January and end of Paush
+       //(including 17th January)
+       //The gregorianDayOfYear (35) is bigger than 14, so we check the next month
+       //The next BS month (Mangh) has 29 days
+       //29+14=43, this is bigger than gregorianDayOfYear(35) so, we found the correct nepali month
+       for ; gregorianDayOfYear > daysSinceJanFirstToEndOfBsMonth; {
+               bsMonth++
+               if bsMonth > 12 {
+                       bsMonth = 1
+                       bsYear++
+               }
+               daysSinceJanFirstToEndOfBsMonth += calendardata[bsYear][bsMonth]
+       }
+
+       return New(gregorianDay, bsMonth, bsYear)
 }
Enter fullscreen mode Exit fullscreen mode

接下来呢?你猜对了!运行测试:

=== RUN   TestCreateFromGregorian
=== RUN   TestCreateFromGregorian/2068-04-01
    assert.go:24: got '17' want '1'

=== RUN   TestCreateFromGregorian/2068-01-01
    assert.go:24: got '14' want '1'

=== RUN   TestCreateFromGregorian/2037-11-28
    assert.go:24: got '11' want '28'

....
Enter fullscreen mode Exit fullscreen mode

实际上,在实现算法的过程中,我多次运行测试,发现了一些变量名混淆和其他错误。这很好,测试帮助我立即找到了问题所在。

但测试仍然失败,我最好把日期计算正确。
我们知道正确的公历月份,也知道从1月1日到该月月底的天数。从1月1日到正确公历月份月底的天数中减去公历的日期,就能得到要查找的日期与公历月份月底之间的天数。用公历月份的天数减去这个差值,就能得到正确的日期。

可以用很多词来描述它,但用代码实现它却毫不费力:

-       return New(gregorianDay, bsMonth, bsYear)
+       var bsDay = calendardata[bsYear][bsMonth] - (daysSinceJanFirstToEndOfBsMonth - gregorianDayOfYear)
+
+       return New(bsDay, bsMonth, bsYear)
Enter fullscreen mode Exit fullscreen mode

我听到你们在喊:“运行测试,运行测试!”别担心,我会的:

=== RUN   TestCreateFromGregorian
=== RUN   TestCreateFromGregorian/2068-04-01
=== RUN   TestCreateFromGregorian/2068-01-01
=== RUN   TestCreateFromGregorian/2037-11-28
=== RUN   TestCreateFromGregorian/2038-09-17
=== RUN   TestCreateFromGregorian/2040-09-17
=== RUN   TestCreateFromGregorian/2040-09-18
=== RUN   TestCreateFromGregorian/2041-09-17
=== RUN   TestCreateFromGregorian/2041-09-18
=== RUN   TestCreateFromGregorian/2068-09-01
=== RUN   TestCreateFromGregorian/2068-08-29
=== RUN   TestCreateFromGregorian/2068-09-20
=== RUN   TestCreateFromGregorian/2077-08-30
=== RUN   TestCreateFromGregorian/2077-09-16
=== RUN   TestCreateFromGregorian/2074-09-16
=== RUN   TestCreateFromGregorian/2077-09-17
=== RUN   TestCreateFromGregorian/2077-09-01
=== RUN   TestCreateFromGregorian/2076-11-17
=== RUN   TestCreateFromGregorian/2076-11-18
=== RUN   TestCreateFromGregorian/2075-11-16
=== RUN   TestCreateFromGregorian/2076-02-01
=== RUN   TestCreateFromGregorian/2076-02-32
=== RUN   TestCreateFromGregorian/2076-03-01
--- PASS: TestCreateFromGregorian (0.00s)
    --- PASS: TestCreateFromGregorian/2068-04-01 (0.00s)
    --- PASS: TestCreateFromGregorian/2068-01-01 (0.00s)
    --- PASS: TestCreateFromGregorian/2037-11-28 (0.00s)
    --- PASS: TestCreateFromGregorian/2038-09-17 (0.00s)
    --- PASS: TestCreateFromGregorian/2040-09-17 (0.00s)
    --- PASS: TestCreateFromGregorian/2040-09-18 (0.00s)
    --- PASS: TestCreateFromGregorian/2041-09-17 (0.00s)
    --- PASS: TestCreateFromGregorian/2041-09-18 (0.00s)
    --- PASS: TestCreateFromGregorian/2068-09-01 (0.00s)
    --- PASS: TestCreateFromGregorian/2068-08-29 (0.00s)
    --- PASS: TestCreateFromGregorian/2068-09-20 (0.00s)
    --- PASS: TestCreateFromGregorian/2077-08-30 (0.00s)
    --- PASS: TestCreateFromGregorian/2077-09-16 (0.00s)
    --- PASS: TestCreateFromGregorian/2074-09-16 (0.00s)
    --- PASS: TestCreateFromGregorian/2077-09-17 (0.00s)
    --- PASS: TestCreateFromGregorian/2077-09-01 (0.00s)
    --- PASS: TestCreateFromGregorian/2076-11-17 (0.00s)
    --- PASS: TestCreateFromGregorian/2076-11-18 (0.00s)
    --- PASS: TestCreateFromGregorian/2075-11-16 (0.00s)
    --- PASS: TestCreateFromGregorian/2076-02-01 (0.00s)
    --- PASS: TestCreateFromGregorian/2076-02-32 (0.00s)
    --- PASS: TestCreateFromGregorian/2076-03-01 (0.00s)
PASS
ok      NepaliCalendar/bsdate   0.002s
Enter fullscreen mode Exit fullscreen mode

测试通过,任务完成!全力以赴,争取早日实现目标

您可以在这里找到此帖子的所有更改:https://github.com/JankariTech/GoBikramSambat/pull/4/

结论

TDD 很简单:想想你想实现什么目标,为它编写测试,然后疯狂地修改代码,直到测试通过为止。

另一个很大的优势是:我可以随意重构代码,并且仍然确信它运行良好。也许我想优化算法的速度,也许我完全不喜欢它并想出一个更好的算法,或者我只是想更改变量名。我可以这样做,而不用担心会破坏功能,只要我的测试通过,我就确信代码的运行结果与预期一致。

或许下一步

软件开发中另一个有用的原则是行为驱动开发 (BDD)。它源于测试驱动开发 (TDD),并沿用了 TDD 的一般原则,但其重点不在于定义和测试单个单元(功能),而在于描述系统的行为,从而改善项目不同利益相关者之间的沟通。我曾撰写过一篇关于 BDD 的文章,其中使用了同一个项目并进行了更深入的探讨:https://dev.to/jankaritech/demonstrating-bdd-behavior-driven-development-in-go-1eci

文章来源:https://dev.to/jankaritech/demonstrating-tdd-test-driven-development-in-go-27b0