在 Go 语言中演示 TDD(测试驱动开发)
测试驱动开发(TDD)是一种在编写代码之前先编写测试的实践方法,它能够降低软件的故障率和缺陷率。
在这篇博文中,我将演示它是如何运作的。
起点
我正在用 Go 语言编写一个应用程序,用于在比克拉姆历 (Bikram Sambat,简称 BS,也称 Vikram Samvat) 和公历日期之间进行转换。Vikram Samvat历主要在尼泊尔和印度使用。即使您不使用它,这个演示也可能有助于您理解 TDD(测试驱动开发)。
目前我已经完成了一些工作,可以创建 BS(比克拉姆历)日期实例,获取其详细信息并将其转换为公历日期。参见:https ://github.com/JankariTech/GoBikramSambat/blob/b99c510b22faf8395becda9a6dec1d0239504bb1/bsdate.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)
})
}
}
该函数从列表中获取条目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]
这不出所料,这个函数不存在,难怪我的测试失败了。接下来怎么办?你猜怎么着:实现这个函数。
这就是 TDD 的精髓所在,你只需要按照测试结果修复问题即可。
3. 修复它
这很简单,添加到bsdate.go新函数中:
func NewFromGregorian(gregorianDay, gregorianMonth, gregorianYear int) (Date, error) {
}
4. 重复
再次运行测试后,我得到:
./bsdate.go:195:1: missing return at end of function
没错,我们得返回点什么,但返回什么呢?嘿,不如就用公历日期创建一个假的日期吧。
func NewFromGregorian(gregorianDay, gregorianMonth, gregorianYear int) (Date, error) {
-
+ return New(gregorianDay, gregorianMonth, gregorianYear)
}
你是说那样行不通?我不在乎,我做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'
....
失败了很多次,你猜对了,转换不起作用。所以我们来实现一些部分。
我们知道公历比格里历早56年多。所以,在格里历年加上56应该会有帮助:
func NewFromGregorian(gregorianDay, gregorianMonth, gregorianYear int) (Date, error) {
- return New(gregorianDay, gregorianMonth, gregorianYear)
+ var bsYear = gregorianYear + 56
+ return New(gregorianDay, gregorianMonth, bsYear)
}
测试结果看起来更好,而不是
....
=== 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'
....
我得到:
....
=== 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'
....
所以至少有些年份的计算是正确的。让我们通过更精确地计算年份来修正更多测试,并计算出错误月份。
由于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},
这些细节与 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)
}
接下来呢?你猜对了!运行测试:
=== 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'
....
实际上,在实现算法的过程中,我多次运行测试,发现了一些变量名混淆和其他错误。这很好,测试帮助我立即找到了问题所在。
但测试仍然失败,我最好把日期计算正确。
我们知道正确的公历月份,也知道从1月1日到该月月底的天数。从1月1日到正确公历月份月底的天数中减去公历的日期,就能得到要查找的日期与公历月份月底之间的天数。用公历月份的天数减去这个差值,就能得到正确的日期。
可以用很多词来描述它,但用代码实现它却毫不费力:
- return New(gregorianDay, bsMonth, bsYear)
+ var bsDay = calendardata[bsYear][bsMonth] - (daysSinceJanFirstToEndOfBsMonth - gregorianDayOfYear)
+
+ return New(bsDay, bsMonth, bsYear)
我听到你们在喊:“运行测试,运行测试!”别担心,我会的:
=== 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
测试通过,任务完成!全力以赴,争取早日实现目标!
您可以在这里找到此帖子的所有更改: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