TDD:Go语言中的模拟时钟表盘
我为@quii的精彩著作《Learn Go With Tests》写了另一个章节——这一章完全是关于使用 SVG 和该软件包绘制模拟时钟表盘。math
本章介绍了该math软件包(我们会用到一些三角函数!),并探讨了使用浮点运算时会遇到的一些问题。我们还将研究如何测试 SVG……以及一些(或许)令人惊讶的结果。
这是比较靠后的章节,所以我预计大多数读者都具备一定的测试驱动开发经验,并且对 Go 语言也有一定的了解。但你不需要懂(太多)数学,也不需要了解 SVG 和 XML——我尽量让每个人都能理解。如果有什么不明白的地方,请告诉我!
如果你喜欢这部分内容,或者即使你不喜欢,这本书的其余部分也更好,而且完全免费。
如果您在家也想尝试一下,可以在这里查看我们时钟表盘生成器代码所有迭代版本的源文件。
好了,节目继续……
问题
你想制作一个时钟的 SVG 图形。不是数字时钟——不,那太简单了——而是带指针的模拟时钟。你不需要什么花哨的功能,只需要一个简单的函数,它能读取Time一个timeSVG 文件,然后输出一个带有所有指针(时针、分针和秒针)且指向正确方向的时钟图形。这能有多难?
首先,我们需要一个时钟的 SVG 图形来进行操作。SVG 是一种非常适合以编程方式操作的图像格式,因为它是由一系列用 XML 描述的形状组成。所以,我们需要这个时钟:
描述如下:
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg"
width="100%"
height="100%"
viewBox="0 0 300 300"
version="2.0">
<!-- bezel -->
<circle cx="150" cy="150" r="100" style="fill:#fff;stroke:#000;stroke-width:5px;"/>
<!-- hour hand -->
<line x1="150" y1="150" x2="114.150000" y2="132.260000"
style="fill:none;stroke:#000;stroke-width:7px;"/>
<!-- minute hand -->
<line x1="150" y1="150" x2="101.290000" y2="99.730000"
style="fill:none;stroke:#000;stroke-width:7px;"/>
<!-- second hand -->
<line x1="150" y1="150" x2="77.190000" y2="202.900000"
style="fill:none;stroke:#f00;stroke-width:3px;"/>
</svg>
这是一个圆,圆上有三条线,每条线都从圆心(x=150,y=150)开始,并终止于一定距离之外。
所以我们要做的就是以某种方式重建上述内容,但改变线条,使它们在给定的时间内指向适当的方向。
验收测试
在深入探讨之前,我们先来思考一下验收测试。我们有一个示例时钟,所以让我们想想哪些参数比较重要。
<line x1="150" y1="150" x2="114.150000" y2="132.260000"
style="fill:none;stroke:#000;stroke-width:7px;"/>
时钟的中心点(即本行的属性x1)y1对于每个指针都是相同的。每个指针需要改变的数字——也就是构建 SVG 的参数——是 X和x2Yy2属性。我们需要为每个指针指定 X 和 Y 坐标。
我还可以考虑更多参数——例如表盘圆的半径、SVG 的大小、指针的颜色、形状等等……但最好先用简单具体的解决方案解决一个简单具体的问题,然后再逐步添加参数使其更具通用性。
所以我们会说……
- 每个时钟的中心点都是 (150, 150)
- 时针长50。
- 分针长80。
- 秒针长90。
关于 SVG,需要注意一点:原点(0,0)位于左上角,而不是我们通常认为的左下角。在确定线条中需要填充哪些数值时,记住这一点非常重要。
最后,我还没决定如何构建 SVG——我们可以使用text/template软件包中的模板,或者直接将字节发送到bytes.Buffer写入器。但我们知道我们需要这些数字,所以让我们专注于测试生成这些数字的方法。
先编写测试题。
我的第一个测试结果如下:
package clockface_test
import (
"testing"
"time"
"github.com/gypsydave5/learn-go-with-tests/math/v1/clockface"
)
func TestSecondHandAtMidnight(t *testing.T) {
tm := time.Date(1337, time.January, 1, 0, 0, 0, 0, time.UTC)
want := clockface.Point{X: 150, Y: 150 - 90}
got := clockface.SecondHand(tm)
if got != want {
t.Errorf("Got %v, wanted %v", got, want)
}
}
还记得 SVG 是如何从左上角绘制坐标的吗?要将秒针放在午夜,我们期望它在 X 轴上没有偏离钟面的中心(仍然是 150),而 Y 轴是秒针从中心向上延伸的长度;150 减去 90。
尝试运行测试
这样就能排除因缺少函数和类型而导致的预期故障:
--- FAIL: TestSecondHandAtMidnight (0.00s)
# github.com/gypsydave5/learn-go-with-tests/math/v1/clockface_test [github.com/gypsydave5/learn-go-with-tests/math/v1/clockface.test]
./clockface_test.go:13:10: undefined: clockface.Point
./clockface_test.go:14:9: undefined: clockface.SecondHand
FAIL github.com/gypsydave5/learn-go-with-tests/math/v1/clockface [build failed]
所以,我们Point需要知道秒针尖端应该指向哪里,以及一个获取该位置的函数。
编写最少的代码来运行测试,并检查失败的测试输出。
让我们实现这些类型,以便代码能够编译通过。
package clockface
import "time"
// A Point represents a two dimensional Cartesian coordinate
type Point struct {
X float64
Y float64
}
// SecondHand is the unit vector of the second hand of an analogue clock at time `t`
// represented as a Point.
func SecondHand(t time.Time) Point {
return Point{}
}
现在我们得到了
--- FAIL: TestSecondHandAtMidnight (0.00s)
clockface_test.go:17: Got {0 0}, wanted {150 60}
FAIL
exit status 1
FAIL github.com/gypsydave5/learn-go-with-tests/math/v1/clockface 0.006s
编写足够的代码使其通过测试。
当出现预期失败时,我们可以填写返回值HandsAt:
// SecondHand is the unit vector of the second hand of an analogue clock at time `t`
// represented as a Point.
func SecondHand(t time.Time) Point {
return Point{150, 60}
}
瞧,考试合格了。
PASS
ok github.com/gypsydave5/learn-go-with-tests/math/v1/clockface 0.006s
重构
暂时无需重构——代码量还不够!
针对新要求重复上述步骤
我们可能需要做一些工作,而不仅仅是归还
一个每次都显示午夜的钟……
先编写测试题。
func TestSecondHandAt30Seconds(t *testing.T) {
tm := time.Date(1337, time.January, 1, 0, 0, 30, 0, time.UTC)
want := clockface.Point{X: 150, Y: 150 + 90}
got := clockface.SecondHand(tm)
if got != want {
t.Errorf("Got %v, wanted %v", got, want)
}
}
思路相同,但现在秒针向下,所以我们将长度加到Y
轴上。
这段代码可以编译通过……但是如何才能让它通过审核呢?
思考时间
我们该如何解决这个问题?
每分钟,秒针都会经过相同的 60 个状态,指向 60 个不同的方向。当秒针指向 0 秒时,它指向表盘的顶部;当秒针指向 30 秒时,它指向表盘的底部。很简单吧。
所以,如果我想知道秒针在37秒时指向哪个方向,我就需要计算12点钟位置和圆周上37/60点钟位置之间的角度。用度数表示是(360 / 60 ) * 37 = 222,但记住它是37/60一整圈的角度更容易些。
但角度只是问题的一半;我们还需要知道秒针尖端所指向的 X 和 Y 坐标。我们该如何计算呢?
数学
想象以原点为中心,半径为 1 的圆——即坐标0, 0。
这被称为“单位圆”,因为……嗯,它的半径是 1 个单位!
圆周由网格上的点构成——也就是坐标。每个坐标的 x 和 y 分量构成一个三角形,该三角形的斜边始终等于 1——圆的半径。
现在,如果我们知道三角形中各边与原点的夹角,就可以利用三角函数计算出它们的边长。X 坐标为 cos(a),Y 坐标为 sin(a),其中 a 是该直线与(正)x 轴的夹角。
(如果你不相信,可以去看看维基百科……)
最后还有一个变化——因为我们想从 12 点钟方向而不是从 X 轴(3 点钟方向)测量角度,所以我们需要交换轴;现在 x = sin(a) 且 y = cos(a)。
现在我们知道如何获得秒针的角度(每秒旋转1/60个圆周)以及X和Y坐标。我们需要分别计算这两个坐标的sin函数cos。
math
令人欣喜的是,Gomath套餐同时包含了这两项功能,但有一个小问题我们需要解决;如果我们看一下以下描述math.Cos:
cos 函数返回弧度参数 x 的余弦值。
它要求角度单位为弧度。很好。那么弧度是什么呢?
我们不再将圆周运动定义为 360 度,而是将其定义为 2π 弧度。这样做有其充分的理由,但我们在此不做赘述。
现在我们已经进行了一些阅读、学习和思考,我们可以编写
下一个测试题了。
先编写测试题。
这些数学题真难,也让人困惑。我不太确定自己是否理解其中的原理——所以我们来做个测试吧!我们不需要一次性解决所有问题——先从计算出秒针在特定时刻的正确角度(以弧度为单位)开始。
我打算在包内编写这些测试;它们clockface可能永远不会被导出,而且一旦我对情况有了更好的了解,它们可能会被删除(或移动)。
我还会把之前正在编写的验收测试代码注释掉——我不想在确保这个测试通过的时候被那个测试分散注意力。
package clockface
import (
"math"
"testing"
"time"
)
func TestSecondsInRadians(t *testing.T) {
thirtySeconds := time.Date(312, time.October, 28, 0, 0, 30, 0, time.UTC)
want := math.Pi
got := secondsInRadians(thirtySeconds)
if want != got {
t.Fatalf("Wanted %v radians, but got %v", want, got)
}
}
这里我们测试的是,每分钟过 30 秒时,秒针应该位于时钟的中间位置。这也是我们第一次使用这个math包!如果圆周旋转一周是 2π 弧度,那么我们知道旋转半圈应该是 π 弧度。math.Pi该包会提供 π 的值。
尝试运行测试
# github.com/gypsydave5/learn-go-with-tests/math/v2/clockface [github.com/gypsydave5/learn-go-with-tests/math/v2/clockface.test]
./clockface_test.go:12:9: undefined: secondsInRadians
FAIL github.com/gypsydave5/learn-go-with-tests/math/v2/clockface [build failed]
编写最少的代码来运行测试,并检查失败的测试输出。
func secondsInRadians(t time.Time) float64 {
return 0
}
--- FAIL: TestSecondsInRadians (0.00s)
clockface_test.go:15: Wanted 3.141592653589793 radians, but got 0
FAIL
exit status 1
FAIL github.com/gypsydave5/learn-go-with-tests/math/v2/clockface 0.007s
编写足够的代码使其通过测试。
func secondsInRadians(t time.Time) float64 {
return math.Pi
}
PASS
ok github.com/gypsydave5/learn-go-with-tests/math/v2/clockface 0.011s
重构
目前还不需要重构。
针对新要求重复上述步骤
现在我们可以扩展测试,涵盖更多场景。我将跳过一些步骤,直接展示一些已经重构过的测试代码——这样应该能清楚地说明我是如何达到目标的。
func TestSecondsInRadians(t *testing.T) {
cases := []struct {
time time.Time
angle float64
}{
{simpleTime(0, 0, 30), math.Pi},
{simpleTime(0, 0, 0), 0},
{simpleTime(0, 0, 45), (math.Pi / 2) * 3},
{simpleTime(0, 0, 7), (math.Pi / 30) * 7},
}
for _, c := range cases {
t.Run(testName(c.time), func(t *testing.T) {
got := secondsInRadians(c.time)
if got != c.angle {
t.Fatalf("Wanted %v radians, but got %v", c.angle, got)
}
})
}
}
我添加了一些辅助函数,使编写这个基于表格的测试变得不那么繁琐。其中一个函数testName将时间转换为数字手表格式(HH:MM:SS),并仅使用我们真正关心的部分(即小时、分钟和秒)simpleTime构建一个时间轴。time.Time
func simpleTime(hours, minutes, seconds int) time.Time {
return time.Date(312, time.October, 28, hours, minutes, seconds, 0, time.UTC)
}
func testName(t time.Time) string {
return t.Format("15:04:05")
}
这两个函数应该有助于使这些测试(以及未来的测试)更容易编写和维护。
这样我们就得到了一些不错的测试结果:
--- FAIL: TestSecondsInRadians (0.00s)
--- FAIL: TestSecondsInRadians/00:00:00 (0.00s)
clockface_test.go:24: Wanted 0 radians, but got 3.141592653589793
--- FAIL: TestSecondsInRadians/00:00:45 (0.00s)
clockface_test.go:24: Wanted 4.71238898038469 radians, but got 3.141592653589793
--- FAIL: TestSecondsInRadians/00:00:07 (0.00s)
clockface_test.go:24: Wanted 0.7330382858376184 radians, but got 3.141592653589793
FAIL
exit status 1
FAIL github.com/gypsydave5/learn-go-with-tests/math/v3/clockface 0.007s
是时候把我们上面讨论的所有数学知识付诸实践了:
func secondsInRadians(t time.Time) float64 {
return float64(t.Second()) * (math.Pi / 30)
}
一秒是 (2π / 60) 弧度……消去 2,得到 π/30 弧度。将其乘以秒数(以 a 表示float64),现在所有测试应该都能通过了……
--- FAIL: TestSecondsInRadians (0.00s)
--- FAIL: TestSecondsInRadians/00:00:30 (0.00s)
clockface_test.go:24: Wanted 3.141592653589793 radians, but got 3.1415926535897936
FAIL
exit status 1
FAIL github.com/gypsydave5/learn-go-with-tests/math/v3/clockface 0.006s
等等,什么?
浮标太糟糕了
浮点运算的精度极低是出了名的。计算机实际上只能处理整数,以及一定程度上的有理数。小数的精度开始下降,尤其是在像secondsInRadians函数中那样进行因数分解时。通过除以math.Pi30 再乘以 30,我们最终得到的数与原来的数不再相同math.Pi。
有两种方法可以解决这个问题:
- 接受它
- 通过重构我们的方程来重构我们的函数
现在,(1) 可能看起来不太吸引人,但这通常是实现浮点数相等性的唯一方法。坦白说,对于绘制钟面来说,哪怕只有无穷小的误差也无关紧要,所以我们可以编写一个函数来定义一个“足够精确”的角度等式。但是,我们可以通过一个简单的方法恢复精度:重新排列等式,使其不再先向下除再向上乘。我们只需进行除法运算即可。
所以,与其说是
numberOfSeconds * π / 30
我们可以写
π / (30 / numberOfSeconds)
两者效果相同。
在 Go 语言中:
func secondsinradians(t time.time) float64 {
return (math.Pi / (30 / (float64(t.Second()))))
}
我们被放过了。
PASS
ok github.com/gypsydave5/learn-go-with-tests/math/v2/clockface 0.005s
针对新要求重复上述步骤
所以第一部分我们已经搞定了——我们知道了秒针指向的弧度角。现在我们需要计算出坐标。
再次强调,我们尽量简化问题,只考虑单位圆,也就是半径为 1 的圆。这意味着我们所有人的手的长度都为 1,但好处是,这意味着数学计算对我们来说更容易理解。
先编写测试题。
func TestSecondHandVector(t *testing.T) {
cases := []struct {
time time.Time
point Point
}{
{simpleTime(0, 0, 30), Point{0, -1}},
}
for _, c := range cases {
t.Run(testName(c.time), func(t *testing.T) {
got := secondHandPoint(c.time)
if got != c.point {
t.Fatalf("Wanted %v Point, but got %v", c.point, got)
}
})
}
}
尝试运行测试
# github.com/gypsydave5/learn-go-with-tests/math/v4/clockface [github.com/gypsydave5/learn-go-with-tests/math/v4/clockface.test]
./clockface_test.go:40:11: undefined: secondHandPoint
FAIL github.com/gypsydave5/learn-go-with-tests/math/v4/clockface [build failed]
编写最少的代码来运行测试,并检查失败的测试输出。
func secondHandPoint(t time.Time) Point {
return Point{}
}
--- FAIL: TestSecondHandPoint (0.00s)
--- FAIL: TestSecondHandPoint/00:00:30 (0.00s)
clockface_test.go:42: Wanted {0 -1} Point, but got {0 0}
FAIL
exit status 1
FAIL github.com/gypsydave5/learn-go-with-tests/math/v4/clockface 0.010s
编写足够的代码使其通过测试。
func secondHandPoint(t time.Time) Point {
return Point{0, -1}
}
PASS
ok github.com/gypsydave5/learn-go-with-tests/math/v4/clockface 0.007s
针对新要求重复上述步骤
func TestSecondHandPoint(t *testing.T) {
cases := []struct {
time time.Time
point Point
}{
{simpleTime(0, 0, 30), Point{0, -1}},
{simpleTime(0, 0, 45), Point{-1, 0}},
}
for _, c := range cases {
t.Run(testName(c.time), func(t *testing.T) {
got := secondHandPoint(c.time)
if got != c.point {
t.Fatalf("Wanted %v Point, but got %v", c.point, got)
}
})
}
}
尝试运行测试
--- FAIL: TestSecondHandPoint (0.00s)
--- FAIL: TestSecondHandPoint/00:00:45 (0.00s)
clockface_test.go:43: Wanted {-1 0} Point, but got {0 -1}
FAIL
exit status 1
FAIL github.com/gypsydave5/learn-go-with-tests/math/v4/clockface 0.006s
编写足够的代码使其通过测试。
还记得我们画的单位圆吗?
我们现在想要得到能导出 X 和 Y 的方程。让我们用秒来写出来:
func secondHandPoint(t time.Time) Point {
angle := secondsInRadians(t)
x := math.Sin(angle)
y := math.Cos(angle)
return Point{x, y}
}
现在我们得到
--- FAIL: TestSecondHandPoint (0.00s)
--- FAIL: TestSecondHandPoint/00:00:30 (0.00s)
clockface_test.go:43: Wanted {0 -1} Point, but got {1.2246467991473515e-16 -1}
--- FAIL: TestSecondHandPoint/00:00:45 (0.00s)
clockface_test.go:43: Wanted {-1 0} Point, but got {-1 -1.8369701987210272e-16}
FAIL
exit status 1
FAIL github.com/gypsydave5/learn-go-with-tests/math/v4/clockface 0.007s
等等,什么?(又来了)看来我们又一次被浮点数坑了——这两个意外的数字都是无穷小数,小数点后第16位才出现。所以我们又可以选择尝试提高精度,或者干脆就说它们大致相等,然后继续过我们的日子。
提高这些角度精度的一个方法是使用软件包Rat中的有理数类型math/big。但考虑到我们的目标是绘制 SVG 而不是模拟登月,我认为可以接受一些模糊性。
func TestSecondHandPoint(t *testing.T) {
cases := []struct {
time time.Time
point Point
}{
{simpleTime(0, 0, 30), Point{0, -1}},
{simpleTime(0, 0, 45), Point{-1, 0}},
}
for _, c := range cases {
t.Run(testName(c.time), func(t *testing.T) {
got := secondHandPoint(c.time)
if !roughlyEqualPoint(got, c.point) {
t.Fatalf("Wanted %v Point, but got %v", c.point, got)
}
})
}
}
func roughlyEqualFloat64(a, b float64) bool {
const equalityThreshold = 1e-7
return math.Abs(a-b) < equalityThreshold
}
func roughlyEqualPoint(a, b Point) bool {
return roughlyEqualFloat64(a.X, b.X) &&
roughlyEqualFloat64(a.Y, b.Y)
}
我们定义了两个函数来判断两个值是否近似相等Points——当 X 和 Y 元素之间的差异在 0.0000001 以内时,这两个函数就能正常工作。这仍然相当精确。
现在我们来谈谈……
PASS
ok github.com/gypsydave5/learn-go-with-tests/math/v4/clockface 0.007s
重构
我对此仍然相当满意。
针对新要求重复上述步骤
嗯,说“新的”并不完全准确——我们现在真正能做的就是通过验收测试!让我们回顾一下它的样子:
func TestSecondHandAt30Seconds(t *testing.T) {
tm := time.Date(1337, time.January, 1, 0, 0, 30, 0, time.UTC)
want := clockface.Point{X: 150, Y: 150 + 90}
got := clockface.SecondHand(tm)
if got != want {
t.Errorf("Got %v, wanted %v", got, want)
}
}
尝试运行测试
--- FAIL: TestSecondHandAt30Seconds (0.00s)
clockface_acceptance_test.go:28: Got {150 60}, wanted {150 240}
FAIL
exit status 1
FAIL github.com/gypsydave5/learn-go-with-tests/math/v5/clockface 0.007s
编写足够的代码使其通过测试。
我们需要做三件事才能将单位向量转换为 SVG 上的一个点:
- 将其缩放至手的长度
- 将其沿 X 轴翻转,因为 SVG 的原点位于左上角。
- 将其翻译到正确的位置(使其源自 (150,150))。
欢乐时光!
// SecondHand is the unit vector of the second hand of an analogue clock at time `t`
// represented as a Point.
func SecondHand(t time.Time) Point {
p := secondHandPoint(t)
p = Point{p.X * 90, p.Y * 90} // scale
p = Point{p.X, -p.Y} // flip
p = Point{p.X + 150, p.Y + 150} // translate
return p
}
缩放、翻转、平移,顺序必须如此。数学万岁!
PASS
ok github.com/gypsydave5/learn-go-with-tests/math/v5/clockface 0.007s
重构
这里有一些关键数字需要提取出来作为常量,所以
我们来做这件事。
const secondHandLength = 90
const clockCentreX = 150
const clockCentreY = 150
// SecondHand is the unit vector of the second hand of an analogue clock at time `t`
// represented as a Point.
func SecondHand(t time.Time) Point {
p := secondHandPoint(t)
p = Point{p.X * secondHandLength, p.Y * secondHandLength}
p = Point{p.X, -p.Y}
p = Point{p.X + clockCentreX, p.Y + clockCentreY} //translate
return p
}
画出时钟
嗯……至少是二手货……
咱们开始吧——因为没有什么比眼睁睁看着有价值的东西放在那里,等着被世人看到更糟糕的了。咱们来画一只秒针吧!
我们将在主clockface包目录下创建一个新目录,该目录的名称(容易让人困惑)是 `<package_name>` 。我们将把用于创建构建 SVG 的二进制文件的包clockface放在其中:main
├── clockface
│ └── main.go
├── clockface.go
├── clockface_acceptance_test.go
└── clockface_test.go
里面main.go
package main
import (
"fmt"
"io"
"os"
"time"
"github.com/gypsydave5/learn-go-with-tests/math/v6/clockface"
)
func main() {
t := time.Now()
sh := clockface.SecondHand(t)
io.WriteString(os.Stdout, svgStart)
io.WriteString(os.Stdout, bezel)
io.WriteString(os.Stdout, secondHandTag(sh))
io.WriteString(os.Stdout, svgEnd)
}
func secondHandTag(p clockface.Point) string {
return fmt.Sprintf(`<line x1="150" y1="150" x2="%f" y2="%f" style="fill:none;stroke:#f00;stroke-width:3px;"/>`, p.X, p.Y)
}
const svgStart = `<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg"
width="100%"
height="100%"
viewBox="0 0 300 300"
version="2.0">`
const bezel = `<circle cx="150" cy="150" r="100" style="fill:#fff;stroke:#000;stroke-width:5px;"/>`
const svgEnd = `</svg>`
哎呀,我可没指望用这段乱七八糟的代码去赢得什么奖项——不过它确实能完成任务。它的作用是os.Stdout逐个字符串地输出 SVG 图像。
如果我们建造这个
go build
运行它,并将输出发送到文件中
./clockface > clock.svg
我们应该会看到类似这样的东西。
重构
这太糟糕了。嗯,倒也不至于糟糕透顶,但我对此很不满意。
- 整个
SecondHand功能都与 SVG 格式密切相关……但却没有提及 SVG,也没有实际生成 SVG…… - ……与此同时,我却没有测试任何 SVG 代码。
是啊,看来我搞砸了。感觉不太对劲。我们试试用更侧重于 SVG 的测试方法来补救吧。
我们有哪些选择?我们可以尝试测试一下,从输出中涌出的字符SVGWriter是否包含我们期望在特定时间出现的 SVG 标签。例如:
func TestSVGWriterAtMidnight(t *testing.T) {
tm := time.Date(1337, time.January, 1, 0, 0, 0, 0, time.UTC)
var b strings.Builder
clockface.SVGWriter(&b, tm)
got := b.String()
want := `<line x1="150" y1="150" x2="150" y2="60"`
if !strings.Contains(got, want) {
t.Errorf("Expected to find the second hand %v, in the SVG output %v", want, got)
}
}
但这真的是一种进步吗?
即使我没有生成有效的 SVG,它仍然会通过(因为它只是测试字符串是否出现在输出中),而且如果我对该字符串进行最小的、不重要的更改(例如,如果我在属性之间添加一个额外的空格),它也会失败。
最大的问题在于,我竟然通过观察 XML 数据结构作为一系列字符(即字符串)的表示形式来测试它。这绝对不是一个好主意,因为它会产生像我上面提到的那种问题:测试既过于脆弱又不够灵敏。测试对象完全错了!
因此,唯一的解决办法是以XML 格式测试输出。而要做到这一点,我们需要
先解析它。
解析 XML
encoding/xml
是 Go 语言包,可以处理所有与简单 XML 解析相关的操作。
该函数xml.Unmarshall接受
一个[]byteXML 数据和一个指向结构体的指针,以便将 XML 数据反序列化到该结构体中
。
所以我们需要一个结构体来反序列化 XML。我们可以花些时间来
确定所有节点和属性的正确名称,以及如何
编写正确的结构体,但幸运的是,有人已经编写了zek一个程序来自动完成所有这些
繁琐的工作。更棒的是,它还有一个在线版本,网址是
https://www.onlinetool.io/xmltogo/。只需将
文件顶部的 SVG 粘贴到一个框中,然后——搞定!
- 突然冒出来:
type Svg struct {
XMLName xml.Name `xml:"svg"`
Text string `xml:",chardata"`
Xmlns string `xml:"xmlns,attr"`
Width string `xml:"width,attr"`
Height string `xml:"height,attr"`
ViewBox string `xml:"viewBox,attr"`
Version string `xml:"version,attr"`
Circle struct {
Text string `xml:",chardata"`
Cx string `xml:"cx,attr"`
Cy string `xml:"cy,attr"`
R string `xml:"r,attr"`
Style string `xml:"style,attr"`
} `xml:"circle"`
Line []struct {
Text string `xml:",chardata"`
X1 string `xml:"x1,attr"`
Y1 string `xml:"y1,attr"`
X2 string `xml:"x2,attr"`
Y2 string `xml:"y2,attr"`
Style string `xml:"style,attr"`
} `xml:"line"`
}
如果需要,我们可以对此进行调整(例如将
结构体的名称更改为SVG),但这绝对足以让我们开始。
func TestSVGWriterAtMidnight(t *testing.T) {
tm := time.Date(1337, time.January, 1, 0, 0, 0, 0, time.UTC)
b := bytes.Buffer{}
clockface.SVGWriter(&b, tm)
svg := Svg{}
xml.Unmarshal(b.Bytes(), &svg)
x2 := "150"
y2 := "60"
for _, line := range svg.Line {
if line.X2 == x2 && line.Y2 == y2 {
return
}
}
t.Errorf("Expected to find the second hand with x2 of %+v and y2 of %+v, in the SVG output %v", x2, y2, b.String())
}
我们将输出写入clockface.SVGWriter一个变量bytes.Buffer,然后Unmarshall将其写入另一个变量Svg。接下来,我们检查Line变量中的每个元素Svg,看它们是否具有预期的X2和Y2值。如果找到匹配项,则提前返回(测试通过);否则,我们将失败并显示一条(希望是)有用的错误信息。
# github.com/gypsydave5/learn-go-with-tests/math/v7b/clockface_test [github.com/gypsydave5/learn-go-with-tests/math/v7b/clockface.test]
./clockface_acceptance_test.go:41:2: undefined: clockface.SVGWriter
FAIL github.com/gypsydave5/learn-go-with-tests/math/v7b/clockface [build failed]
看来我们最好把那件事写下来SVGWriter……
package clockface
import (
"fmt"
"io"
"time"
)
const (
secondHandLength = 90
clockCentreX = 150
clockCentreY = 150
)
//SVGWriter writes an SVG representation of an analogue clock, showing the time t, to the writer w
func SVGWriter(w io.Writer, t time.Time) {
io.WriteString(w, svgStart)
io.WriteString(w, bezel)
secondHand(w, t)
io.WriteString(w, svgEnd)
}
func secondHand(w io.Writer, t time.Time) {
p := secondHandPoint(t)
p = Point{p.X * secondHandLength, p.Y * secondHandLength} // scale
p = Point{p.X, -p.Y} // flip
p = Point{p.X + clockCentreX, p.Y + clockCentreY} // translate
fmt.Fprintf(w, `<line x1="150" y1="150" x2="%.3f" y2="%.3f" style="fill:none;stroke:#f00;stroke-width:3px;"/>`, p.X, p.Y)
}
const svgStart = `<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg"
width="100%"
height="100%"
viewBox="0 0 300 300"
version="2.0">`
const bezel = `<circle cx="150" cy="150" r="100" style="fill:#fff;stroke:#000;stroke-width:5px;"/>`
const svgEnd = `</svg>`
最漂亮的SVG编辑器?不是。但希望它能胜任工作……
--- FAIL: TestSVGWriterAtMidnight (0.00s)
clockface_acceptance_test.go:56: Expected to find the second hand with x2 of 150 and y2 of 60, in the SVG output <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg"
width="100%"
height="100%"
viewBox="0 0 300 300"
version="2.0"><circle cx="150" cy="150" r="100" style="fill:#fff;stroke:#000;stroke-width:5px;"/><line x1="150" y1="150" x2="150.000000" y2="60.000000" style="fill:none;stroke:#f00;stroke-width:3px;"/></svg>
FAIL
exit status 1
FAIL github.com/gypsydave5/learn-go-with-tests/math/v7b/clockface 0.008s
糟糕!%f格式化指令将坐标打印成了默认精度——小数点后六位。我们应该明确指定坐标的精度,比如小数点后三位。
s := fmt.Sprintf(`<line x1="150" y1="150" x2="%.3f" y2="%.3f" style="fill:none;stroke:#f00;stroke-width:3px;"/>`, p.X, p.Y)
在我们更新测试预期之后
x2 := "150.000"
y2 := "60.000"
我们得到:
PASS
ok github.com/gypsydave5/learn-go-with-tests/math/v7b/clockface 0.006s
现在我们可以简化main函数:
package main
import (
"os"
"time"
"github.com/gypsydave5/learn-go-with-tests/math/v7b/clockface"
)
func main() {
t := time.Now()
clockface.SVGWriter(os.Stdout, t)
}
我们可以按照同样的模式为另一次测试编写测试,但在此之前不行……
重构
三点尤为突出:
- 我们并没有真正测试所有需要确保存在的信息——
x1例如,这些值呢? - 还有,那些属性
x1等等,实际上并不是strings属性,对吧?它们只是数字! - 我真的在乎
style那只手吗?或者,就此而言,Text我在乎由 ? 生成的空节点zak吗?
我们可以做得更好。让我们对Svg结构体和测试做一些调整,使一切更加完善。
type SVG struct {
XMLName xml.Name `xml:"svg"`
Xmlns string `xml:"xmlns,attr"`
Width float64 `xml:"width,attr"`
Height float64 `xml:"height,attr"`
ViewBox string `xml:"viewBox,attr"`
Version string `xml:"version,attr"`
Circle Circle `xml:"circle"`
Line []Line `xml:"line"`
}
type Circle struct {
Cx float64 `xml:"cx,attr"`
Cy float64 `xml:"cy,attr"`
R float64 `xml:"r,attr"`
}
type Line struct {
X1 float64 `xml:"x1,attr"`
Y1 float64 `xml:"y1,attr"`
X2 float64 `xml:"x2,attr"`
Y2 float64 `xml:"y2,attr"`
}
我这里有
- 将结构体的重要部分命名为类型——
Line和Circle - 将数值属性转换为
float64s 而不是strings。 - 删除了未使用的属性,例如
Style和Text - 更名
Svg是SVG因为这是正确的做法。
这将使我们能够更精确地确定我们正在寻找的直线:
func TestSVGWriterAtMidnight(t *testing.T) {
tm := time.Date(1337, time.January, 1, 0, 0, 0, 0, time.UTC)
b := bytes.Buffer{}
clockface.SVGWriter(&b, tm)
svg := SVG{}
xml.Unmarshal(b.Bytes(), &svg)
want := Line{150, 150, 150, 60}
for _, line := range svg.Line {
if line == want {
return
}
}
t.Errorf("Expected to find the second hand line %+v, in the SVG lines %+v", want, svg.Line)
}
最后,我们可以借鉴单元测试的做法,编写一个辅助函数containsLine(line Line, lines []Line) bool来真正提升这些测试的效果:
func TestSVGWriterSecondHand(t *testing.T) {
cases := []struct {
time time.Time
line Line
}{
{
simpleTime(0, 0, 0),
Line{150, 150, 150, 60},
},
{
simpleTime(0, 0, 30),
Line{150, 150, 150, 240},
},
}
for _, c := range cases {
t.Run(testName(c.time), func(t *testing.T) {
b := bytes.Buffer{}
clockface.SVGWriter(&b, c.time)
svg := SVG{}
xml.Unmarshal(b.Bytes(), &svg)
if !containsLine(c.line, svg.Line) {
t.Errorf("Expected to find the second hand line %+v, in the SVG lines %+v", c.line, svg.Line)
}
})
}
}
这才叫真正的验收测试!
先编写测试题。
秒针就做好了。现在我们开始做分针。
func TestSVGWriterMinutedHand(t *testing.T) {
cases := []struct {
time time.Time
line Line
}{
{
simpleTime(0, 0, 0),
Line{150, 150, 150, 70},
},
}
for _, c := range cases {
t.Run(testName(c.time), func(t *testing.T) {
b := bytes.Buffer{}
clockface.SVGWriter(&b, c.time)
svg := SVG{}
xml.Unmarshal(b.Bytes(), &svg)
if !containsLine(c.line, svg.Line) {
t.Errorf("Expected to find the minute hand line %+v, in the SVG lines %+v", c.line, svg.Line)
}
})
}
}
尝试运行测试
--- FAIL: TestSVGWriterMinutedHand (0.00s)
--- FAIL: TestSVGWriterMinutedHand/00:00:00 (0.00s)
clockface_acceptance_test.go:87: Expected to find the minute hand line {X1:150 Y1:150 X2:150 Y2:70}, in the SVG lines [{X1:150 Y1:150 X2:150 Y2:60}]
FAIL
exit status 1
FAIL github.com/gypsydave5/learn-go-with-tests/math/v8/clockface 0.007s
我们最好开始构建其他时针和分针。就像我们之前为秒针编写测试一样,我们可以迭代编写以下测试集。同样,在确保测试正常运行之前,我们会先注释掉验收测试:
func TestMinutesInRadians(t *testing.T) {
cases := []struct {
time time.Time
angle float64
}{
{simpleTime(0, 30, 0), math.Pi},
}
for _, c := range cases {
t.Run(testName(c.time), func(t *testing.T) {
got := minutesInRadians(c.time)
if got != c.angle {
t.Fatalf("Wanted %v radians, but got %v", c.angle, got)
}
})
}
}
尝试运行测试
# github.com/gypsydave5/learn-go-with-tests/math/v8/clockface [github.com/gypsydave5/learn-go-with-tests/math/v8/clockface.test]
./clockface_test.go:59:11: undefined: minutesInRadians
FAIL github.com/gypsydave5/learn-go-with-tests/math/v8/clockface [build failed]
编写最少的代码来运行测试,并检查失败的测试输出。
func minutesInRadians(t time.Time) float64 {
return math.Pi
}
针对新要求重复上述步骤
好了,现在让我们来做点实际工作。我们可以把分针建模成每分钟移动一次——这样它就会从30分钟“跳”到31分钟,中间不移动。但这看起来不太美观。我们希望它每秒移动一点点。
func TestMinutesInRadians(t *testing.T) {
cases := []struct {
time time.Time
angle float64
}{
{simpleTime(0, 30, 0), math.Pi},
{simpleTime(0, 0, 7), 7 * (math.Pi / (30 * 60))},
}
for _, c := range cases {
t.Run(testName(c.time), func(t *testing.T) {
got := minutesInRadians(c.time)
if got != c.angle {
t.Fatalf("Wanted %v radians, but got %v", c.angle, got)
}
})
}
}
那一点点究竟有多少呢?嗯……
- 一分钟六十秒
- 圆周旋转半圈(
math.Pi弧度)需要三十分钟 - 所以,
30 * 60半圈只需几秒钟。 - 所以,如果时间是整点过7秒……
- 我们预计分针会指向
7 * (math.Pi / (30 * 60))12 点之后的弧度。
尝试运行测试
--- FAIL: TestMinutesInRadians (0.00s)
--- FAIL: TestMinutesInRadians/00:00:07 (0.00s)
clockface_test.go:62: Wanted 0.012217304763960306 radians, but got 3.141592653589793
FAIL
exit status 1
FAIL github.com/gypsydave5/learn-go-with-tests/math/v8/clockface 0.009s
编写足够的代码使其通过测试。
用詹妮弗·安妮斯顿那句名言来说:接下来是科学部分。
func minutesInRadians(t time.Time) float64 {
return (secondsInRadians(t) / 60) +
(math.Pi / (30 / float64(t.Minute())))
}
与其从头开始计算分针每秒应该在表盘上移动多远,我们不如直接利用secondsInRadians函数。每秒钟,分针移动的角度是秒针移动角度的1/60。
secondsInRadians(t) / 60
然后我们只需加上分钟的移动——类似于秒针的移动。
math.Pi / (30 / float64(t.Minute()))
和...
PASS
ok github.com/gypsydave5/learn-go-with-tests/math/v8/clockface 0.007s
简单易行。
针对新要求重复上述步骤
我应该在测试中添加更多用例吗minutesInRadians?目前只有两个用例。在开始测试minuteHandPoint函数之前,我需要多少个用例?
我最喜欢的 TDD 名言之一,通常被认为是 Kent Beck 所说,是3 。
反复编写测试题,直到恐惧转化为厌倦。
坦白说,我已经厌倦了测试那个功能。我很有把握知道它的工作原理。所以,该测试下一个功能了。
先编写测试题。
func TestMinuteHandPoint(t *testing.T) {
cases := []struct {
time time.Time
point Point
}{
{simpleTime(0, 30, 0), Point{0, -1}},
}
for _, c := range cases {
t.Run(testName(c.time), func(t *testing.T) {
got := minuteHandPoint(c.time)
if !roughlyEqualPoint(got, c.point) {
t.Fatalf("Wanted %v Point, but got %v", c.point, got)
}
})
}
}
尝试运行测试
# github.com/gypsydave5/learn-go-with-tests/math/v9/clockface [github.com/gypsydave5/learn-go-with-tests/math/v9/clockface.test]
./clockface_test.go:79:11: undefined: minuteHandPoint
FAIL github.com/gypsydave5/learn-go-with-tests/math/v9/clockface [build failed]
编写最少的代码来运行测试,并检查失败的测试输出。
func minuteHandPoint(t time.Time) Point {
return Point{}
}
--- FAIL: TestMinuteHandPoint (0.00s)
--- FAIL: TestMinuteHandPoint/00:30:00 (0.00s)
clockface_test.go:80: Wanted {0 -1} Point, but got {0 0}
FAIL
exit status 1
FAIL github.com/gypsydave5/learn-go-with-tests/math/v9/clockface 0.007s
编写足够的代码使其通过测试。
func minuteHandPoint(t time.Time) Point {
return Point{0, -1}
}
PASS
ok github.com/gypsydave5/learn-go-with-tests/math/v9/clockface 0.007s
针对新要求重复上述步骤
现在开始实际工作。
func TestMinuteHandPoint(t *testing.T) {
cases := []struct {
time time.Time
point Point
}{
{simpleTime(0, 30, 0), Point{0, -1}},
{simpleTime(0, 45, 0), Point{-1, 0}},
}
for _, c := range cases {
t.Run(testName(c.time), func(t *testing.T) {
got := minuteHandPoint(c.time)
if !roughlyEqualPoint(got, c.point) {
t.Fatalf("Wanted %v Point, but got %v", c.point, got)
}
})
}
}
--- FAIL: TestMinuteHandPoint (0.00s)
--- FAIL: TestMinuteHandPoint/00:45:00 (0.00s)
clockface_test.go:81: Wanted {-1 0} Point, but got {0 -1}
FAIL
exit status 1
FAIL github.com/gypsydave5/learn-go-with-tests/math/v9/clockface 0.007s
编写足够的代码使其通过测试。
只需简单地复制粘贴该secondHandPoint函数,稍作修改即可……
func minuteHandPoint(t time.Time) Point {
angle := minutesInRadians(t)
x := math.Sin(angle)
y := math.Cos(angle)
return Point{x, y}
}
PASS
ok github.com/gypsydave5/learn-go-with-tests/math/v9/clockface 0.009s
重构
我们这里确实存在一些重复代码minuteHandPoint——secondHandPoint我知道,因为我们只是复制粘贴了一个代码来生成另一个。让我们用一个函数来简化它。
func angleToPoint(angle float64) Point {
x := math.Sin(angle)
y := math.Cos(angle)
return Point{x, y}
}
我们可以将minuteHandPoint其重写secondHandPoint为一行代码:
func minuteHandPoint(t time.Time) Point {
return angleToPoint(minutesInRadians(t))
}
func secondHandPoint(t time.Time) Point {
return angleToPoint(secondsInRadians(t))
}
PASS
ok github.com/gypsydave5/learn-go-with-tests/math/v9/clockface 0.007s
现在我们可以取消对验收测试的注释,开始绘制分针了。
编写足够的代码使其通过测试。
又一次快速复制粘贴,稍作调整。
func minuteHand(w io.Writer, t time.Time) {
p := minuteHandPoint(t)
p = Point{p.X * secondHandLength, p.Y * secondHandLength}
p = Point{p.X, -p.Y}
p = Point{p.X + clockCentreX, p.Y + clockCentreY}
fmt.Fprintf(w, `<line x1="150" y1="150" x2="%.3f" y2="%.3f" style="fill:none;stroke:#000;stroke-width:3px;"/>`, p.X, p.Y)
}
PASS
ok github.com/gypsydave5/learn-go-with-tests/math/v9/clockface 0.006s
但实践是检验真理的唯一标准——如果我们现在编译并运行clockface程序,应该会看到类似这样的结果:
重构
让我们删除secondHandandminuteHand函数中的重复代码,将所有缩放、翻转和平移逻辑放在一个地方。
func secondHand(w io.Writer, t time.Time) {
p := makeHand(secondHandPoint(t), secondHandLength)
fmt.Fprintf(w, `<line x1="150" y1="150" x2="%.3f" y2="%.3f" style="fill:none;stroke:#f00;stroke-width:3px;"/>`, p.X, p.Y)
}
func minuteHand(w io.Writer, t time.Time) {
p := makeHand(minuteHandPoint(t), minuteHandLength)
fmt.Fprintf(w, `<line x1="150" y1="150" x2="%.3f" y2="%.3f" style="fill:none;stroke:#000;stroke-width:3px;"/>`, p.X, p.Y)
}
func makeHand(p Point, length float64) Point {
p = Point{p.X * length, p.Y * length}
p = Point{p.X, -p.Y}
return Point{p.X + clockCentreX, p.Y + clockCentreY}
}
PASS
ok github.com/gypsydave5/learn-go-with-tests/math/v9/clockface 0.007s
好了……现在只需要把时针也调好了!
先编写测试题。
func TestSVGWriterHourHand(t *testing.T) {
cases := []struct {
time time.Time
line Line
}{
{
simpleTime(6, 0, 0),
Line{150, 150, 150, 200},
},
}
for _, c := range cases {
t.Run(testName(c.time), func(t *testing.T) {
b := bytes.Buffer{}
clockface.SVGWriter(&b, c.time)
svg := SVG{}
xml.Unmarshal(b.Bytes(), &svg)
if !containsLine(c.line, svg.Line) {
t.Errorf("Expected to find the minute hand line %+v, in the SVG lines %+v", c.line, svg.Line)
}
})
}
}
尝试运行测试
--- FAIL: TestSVGWriterHourHand (0.00s)
--- FAIL: TestSVGWriterHourHand/06:00:00 (0.00s)
clockface_acceptance_test.go:113: Expected to find the minute hand line {X1:150 Y1:150 X2:150 Y2:200}, in the SVG lines [{X1:150 Y1:150 X2:150 Y2:60} {X1:150 Y1:150 X2:150 Y2:70}]
FAIL
exit status 1
FAIL github.com/gypsydave5/learn-go-with-tests/math/v10/clockface 0.013s
同样,我们先把这行代码注释掉,直到
底层测试覆盖到足够的范围:
先编写测试题。
func TestHoursInRadians(t *testing.T) {
cases := []struct {
time time.Time
angle float64
}{
{simpleTime(6, 0, 0), math.Pi},
}
for _, c := range cases {
t.Run(testName(c.time), func(t *testing.T) {
got := hoursInRadians(c.time)
if got != c.angle {
t.Fatalf("Wanted %v radians, but got %v", c.angle, got)
}
})
}
}
尝试运行测试
# github.com/gypsydave5/learn-go-with-tests/math/v10/clockface [github.com/gypsydave5/learn-go-with-tests/math/v10/clockface.test]
./clockface_test.go:97:11: undefined: hoursInRadians
FAIL github.com/gypsydave5/learn-go-with-tests/math/v10/clockface [build failed]
编写最少的代码来运行测试,并检查失败的测试输出。
func hoursInRadians(t time.Time) float64 {
return math.Pi
}
PASS
ok github.com/gypsydave5/learn-go-with-tests/math/v10/clockface 0.007s
针对新要求重复上述步骤
func TestHoursInRadians(t *testing.T) {
cases := []struct {
time time.Time
angle float64
}{
{simpleTime(6, 0, 0), math.Pi},
{simpleTime(0, 0, 0), 0},
}
for _, c := range cases {
t.Run(testName(c.time), func(t *testing.T) {
got := hoursInRadians(c.time)
if got != c.angle {
t.Fatalf("Wanted %v radians, but got %v", c.angle, got)
}
})
}
}
尝试运行测试
--- FAIL: TestHoursInRadians (0.00s)
--- FAIL: TestHoursInRadians/00:00:00 (0.00s)
clockface_test.go:100: Wanted 0 radians, but got 3.141592653589793
FAIL
exit status 1
FAIL github.com/gypsydave5/learn-go-with-tests/math/v10/clockface 0.007s
编写足够的代码使其通过测试。
func hoursInRadians(t time.Time) float64 {
return (math.Pi / (6 / float64(t.Hour())))
}
针对新要求重复上述步骤
func TestHoursInRadians(t *testing.T) {
cases := []struct {
time time.Time
angle float64
}{
{simpleTime(6, 0, 0), math.Pi},
{simpleTime(0, 0, 0), 0},
{simpleTime(21, 0, 0), math.Pi * 1.5},
}
for _, c := range cases {
t.Run(testName(c.time), func(t *testing.T) {
got := hoursInRadians(c.time)
if got != c.angle {
t.Fatalf("Wanted %v radians, but got %v", c.angle, got)
}
})
}
}
尝试运行测试
--- FAIL: TestHoursInRadians (0.00s)
--- FAIL: TestHoursInRadians/21:00:00 (0.00s)
clockface_test.go:101: Wanted 4.71238898038469 radians, but got 10.995574287564276
FAIL
exit status 1
FAIL github.com/gypsydave5/learn-go-with-tests/math/v10/clockface 0.014s
编写足够的代码使其通过测试。
func hoursInRadians(t time.Time) float64 {
return (math.Pi / (6 / (float64(t.Hour() % 12))))
}
请记住,这不是 24 小时制时钟;我们必须使用取余运算符来获取当前小时数除以 12 的余数。
PASS
ok github.com/gypsydave5/learn-go-with-tests/math/v10/clockface 0.008s
先编写测试题。
现在让我们根据经过的分钟和秒数,尝试在钟面上移动时针。
func TestHoursInRadians(t *testing.T) {
cases := []struct {
time time.Time
angle float64
}{
{simpleTime(6, 0, 0), math.Pi},
{simpleTime(0, 0, 0), 0},
{simpleTime(21, 0, 0), math.Pi * 1.5},
{simpleTime(0, 1, 30), math.Pi / ((6 * 60 * 60) / 90)},
}
for _, c := range cases {
t.Run(testName(c.time), func(t *testing.T) {
got := hoursInRadians(c.time)
if got != c.angle {
t.Fatalf("Wanted %v radians, but got %v", c.angle, got)
}
})
}
}
尝试运行测试
--- FAIL: TestHoursInRadians (0.00s)
--- FAIL: TestHoursInRadians/00:01:30 (0.00s)
clockface_test.go:102: Wanted 0.013089969389957472 radians, but got 0
FAIL
exit status 1
FAIL github.com/gypsydave5/learn-go-with-tests/math/v10/clockface 0.007s
编写足够的代码使其通过测试。
现在又需要稍微思考一下了。我们需要稍微移动一下时针,使其指向分钟和秒钟。幸运的是,我们已经有了用于分钟和秒钟的角度——也就是通过函数返回的角度minutesInRadians。我们可以重复利用它!
所以唯一的问题是如何将这个角度缩小多少倍。分针转一圈代表一小时,而时针转一圈代表十二小时。因此,我们只需将得到的角度除以minutesInRadians十二即可:
func hoursInRadians(t time.Time) float64 {
return (minutesInRadians(t) / 12) +
(math.Pi / (6 / float64(t.Hour()%12)))
}
瞧!
--- FAIL: TestHoursInRadians (0.00s)
--- FAIL: TestHoursInRadians/00:01:30 (0.00s)
clockface_test.go:104: Wanted 0.013089969389957472 radians, but got 0.01308996938995747
FAIL
exit status 1
FAIL github.com/gypsydave5/learn-go-with-tests/math/v10/clockface 0.007s
啊啊啊啊啊该死的浮点运算!
让我们更新测试方法,以便用于角度roughlyEqualFloat64比较
。
func TestHoursInRadians(t *testing.T) {
cases := []struct {
time time.Time
angle float64
}{
{simpleTime(6, 0, 0), math.Pi},
{simpleTime(0, 0, 0), 0},
{simpleTime(21, 0, 0), math.Pi * 1.5},
{simpleTime(0, 1, 30), math.Pi / ((6 * 60 * 60) / 90)},
}
for _, c := range cases {
t.Run(testName(c.time), func(t *testing.T) {
got := hoursInRadians(c.time)
if !roughlyEqualFloat64(got, c.angle) {
t.Fatalf("Wanted %v radians, but got %v", c.angle, got)
}
})
}
}
PASS
ok github.com/gypsydave5/learn-go-with-tests/math/v10/clockface 0.007s
重构
如果我们要roughlyEqualFloat64在某个弧度测试中使用它,那么我们最好在所有测试中都使用它。这是一个简单而有效的重构。
时针点
好了,现在需要通过计算单位向量来计算时针指向的位置。
先编写测试题。
func TestHourHandPoint(t *testing.T) {
cases := []struct {
time time.Time
point Point
}{
{simpleTime(6, 0, 0), Point{0, -1}},
{simpleTime(21, 0, 0), Point{-1, 0}},
}
for _, c := range cases {
t.Run(testName(c.time), func(t *testing.T) {
got := hourHandPoint(c.time)
if !roughlyEqualPoint(got, c.point) {
t.Fatalf("Wanted %v Point, but got %v", c.point, got)
}
})
}
}
Wait, am I just going to throw two test cases out there at once? Isn't this bad TDD?
On TDD Zealotry
Test driven development is not a religion. Some people might act like it is - usually people who don't do TDD but who are happy to moan on Twitter or Dev.to that it's only done by zealots and that they're 'being pragmatic' when they don't write tests. But it's not a religion. It's tool.
I know what the two tests are going to be - I've tested two other clock hands in exactly the same way - and I already know what my implementation is going to be - I wrote a function for the general case of changing an angle into a point in the minute hand iteration.
I'm not going to plough through TDD ceremony for the sake of it. Tests are a tool to help me write better code. TDD is a technique to help me write better code. Neither tests nor TDD are an end in themselves.
My confidence has increased, so I feel I can make larger strides forward. I'm going to 'skip' a few steps, because I know where I am, I know where I'm going and I've been down this road before.
But also note: I'm not skipping writing the tests entirely.
Try to run the test
# github.com/gypsydave5/learn-go-with-tests/math/v11/clockface [github.com/gypsydave5/learn-go-with-tests/math/v11/clockface.test]
./clockface_test.go:119:11: undefined: hourHandPoint
FAIL github.com/gypsydave5/learn-go-with-tests/math/v11/clockface [build failed]
Write enough code to make it pass
func hourHandPoint(t time.Time) Point {
return angleToPoint(hoursInRadians(t))
}
As I said, I know where I am and I know where I'm going. Why pretend otherwise? The tests will soon tell me if I'm wrong.
PASS
ok github.com/gypsydave5/learn-go-with-tests/math/v11/clockface 0.009s
Draw the hour hand
And finally we get to draw in the hour hand. We can bring in that acceptance test by uncommenting it:
func TestSVGWriterHourHand(t *testing.T) {
cases := []struct {
time time.Time
line Line
}{
{
simpleTime(6, 0, 0),
Line{150, 150, 150, 200},
},
}
for _, c := range cases {
t.Run(testName(c.time), func(t *testing.T) {
b := bytes.Buffer{}
clockface.SVGWriter(&b, c.time)
svg := SVG{}
xml.Unmarshal(b.Bytes(), &svg)
if !containsLine(c.line, svg.Line) {
t.Errorf("Expected to find the minute hand line %+v, in the SVG lines %+v", c.line, svg.Line)
}
})
}
}
Try to run the test
--- FAIL: TestSVGWriterHourHand (0.00s)
--- FAIL: TestSVGWriterHourHand/06:00:00 (0.00s)
clockface_acceptance_test.go:113: Expected to find the minute hand line {X1:150 Y1:150 X2:150 Y2:200}, in the SVG lines [{X1:150 Y1:150 X2:150 Y2:60} {X1:150 Y1:150 X2:150 Y2:70}]
FAIL
exit status 1
FAIL github.com/gypsydave5/learn-go-with-tests/math/v10/clockface 0.013s
Write enough code to make it pass
And we can now make our final adjustments to svgWriter.go
const (
secondHandLength = 90
minuteHandLength = 80
hourHandLength = 50
clockCentreX = 150
clockCentreY = 150
)
//SVGWriter writes an SVG representation of an analogue clock, showing the time t, to the writer w
func SVGWriter(w io.Writer, t time.Time) {
io.WriteString(w, svgStart)
io.WriteString(w, bezel)
secondHand(w, t)
minuteHand(w, t)
hourHand(w, t)
io.WriteString(w, svgEnd)
}
// ...
func hourHand(w io.Writer, t time.Time) {
p := makeHand(hourHandPoint(t), hourHandLength)
fmt.Fprintf(w, `<line x1="150" y1="150" x2="%.3f" y2="%.3f" style="fill:none;stroke:#000;stroke-width:3px;"/>`, p.X, p.Y)
}
and so...
PASS
ok github.com/gypsydave5/learn-go-with-tests/math/v12/clockface 0.007s
Let's just check by compiling and running our clockface program.
Refactor
Looking at clockface.go, there are a few 'magic numbers' floating about. They are all based around how many hours/minutes/seconds there are in a half-turn around a clockface. Let's refactor so that we make explicit their meaning.
const (
secondsInHalfClock = 30
secondsInClock = 2 * secondsInHalfClock
minutesInHalfClock = 30
minutesInClock = 2 * minutesInHalfClock
hoursInHalfClock = 6
hoursInClock = 2 * hoursInHalfClock
)
Why do this? Well, it makes explicit what each number means in the equation. If - when - we come back to this code, these names will help us to understand what's going on.
Moreover, should we ever want to make some really, really WEIRD clocks - ones with 4 hours for the hour hand, and 20 seconds for the second hand say - these constants could easily become parameters. We're helping to leave that door open (even if we never go through it).
Wrapping up
Do we need to do anything else?
First, let's pat ourselves on the back - we've written a program that makes an SVG clockface. It works and it's great. It will only ever make one sort of clockface - but that's fine! Maybe you only want one sort of clockface. There's nothing wrong with a program that solves a specific problem and nothing else.
A Program... and a Library
But the code we've written does solve a more general set of problems to do with drawing a clockface. Because we used tests to think about each small part of the problem in isolation, and because we codified that isolation with functions, we've built a very reasonable little API for clockface calculations.
We can work on this project and turn it into something more general - a library for calculating clockface angles and/or vectors.
In fact, providing the library along with the program is a really good idea. It costs us nothing, while increasing the utility of our program and helping to document how it works.
APIs should come with programs, and vice versa. An API that you must write C code to use, which cannot be invoked easily from the command line, is harder to learn and use. And contrariwise, it's a royal pain to have interfaces whose only open, documented form is a program, so you cannot invoke them easily from a C program.
-- Henry Spencer, in The Art of Unix Programming
In my final take on this program, I've made the unexported functions within clockface into a public API for the library, with functions to calculate the angle and unit vector for each of the clock hands. I've also split the SVG generation part into its own package, svg, which is then used by the clockface program directly. Naturally I've documented each of the functions and packages.
Talking about SVGs...
The Most Valuable Test
I'm sure you've noticed that the most sophisticated piece of code for handling SVGs isn't in our application code at all; it's in the test code. Should this make us feel uncomfortable? Shouldn't we do something like
- use a template from
text/template? - use an XML library (much as we're doing in our test)?
- use an SVG library?
We could refactor our code to do any of these things, and we can do so because because it doesn't matter how we produce our SVG, what's important is that it's an SVG that we produce. As such, the part of our system that needs to know the most about SVGs - that needs to be the strictest about what constitutes an SVG - is the test for the SVG output; it needs to have enough context and knowledge about SVGs for us to be confident that we're outputting an SVG.
We may have felt odd that we were pouring a lot of time and effort into those SVG tests - importing an XML library, parsing XML, refactoring the structs - but that test code is a valuable part of our codebase - possibly more valuable than the current production code. It will help guarantee that the output is always a valid SVG, no matter what we choose to use to produce it.
测试并非二等公民——它们不是“一次性”代码。好的测试的寿命远比它们所测试的特定代码版本要长得多。你永远不应该觉得编写测试“花费了太多时间”。这通常是一项明智的投资。
封面图由才华横溢的Denise Yu绘制,她为本书专门创作了这幅图。如果您想使用这幅图,请先征得她的同意。
-
简而言之,用圆周率计算微积分更容易,因为如果用普通角度,π 总是会以角度的形式出现,所以如果用 π 来计算角度,所有的方程都会变得更简单。
-
这比手动把名字写成字符串,然后再费力地跟时间同步要容易得多。相信我,你绝对不想那样做…… ↩
-
这首诗的作者署名有误,因为像所有伟大的作家一样,肯特·贝克的作品被引用的次数远多于读者阅读的次数。贝克本人将其署名为菲利普。↩




