Hello, World
按照传统,我们用新语言编写的第一个程序都是 Hello,world。
按照以下路径创建目录
$GOPATH/src/github.com/{your-user-id}/hello
。我们假定你使用的是基于 Unix 的系统,你的名字是「bob」并且愿意遵循 Go 关于
$GOPATH
的约定(这是最简单的设置方式)。那么你可以执行以下命令 mkdir -p $GOPATH/src/github.com/bob/hello
快速创建目录。对于后面的章节,存放代码的文件夹名称可以按照自己的喜好定义,例如下一节可以用
$GOPATH/src/github.com/{your-user-id}/integers
。一些读者喜欢将所有代码放到一个文件夹中,比如「learn-go-with-tests/hello」。简而言之,这取决于你如何组织你的文件夹。在该目录下创建名为
hello.go
的文件并编写以下代码,键入 go run hello.go
来运行程序。package main
import "fmt"
func main() {
fmt.Println("Hello, world")
}
用 Go 编写程序,你需要定义一个
main
包,并在其中定义一个 main
函数。包是一种将相关的 Go 代码组合到一起的方式。func
关键字通过函数名和函数体来定义函数。通过
import "fmt"
导入一个包含 Println
函数的包,我们用它来打印输出。你打算如何测试这个程序?将你「领域」内的代码和外界(会引起副作用)分离开会更好。
fmt.Println
会产生副作用(打印到标准输出),我们发送的字符串在自己的领域内。所以为了更容易测试,我们把这些问题拆分开。
package main
import "fmt"
func Hello() string {
return "Hello, world"
}
func main() {
fmt.Println(Hello())
}
我们再次使用
func
创建了一个新函数,但是这次我们在定义中添加了另一个关键字 string
。这意味着这个函数 将返回一个字符串。现在创建一个
hello_test.go
的新文件,来为 Hello
函数编写测试package main
import "testing"
func TestHello(t *testing.T) {
got := Hello()
want := "Hello, world"
if got != want {
t.Errorf("got '%q' want '%q'", got, want)
}
}
在解释这个测试之前,让我们先在终端运行
go test
,它应该通过测试了!为了再次验证,可以尝试改变 want
字符串来破坏测试的结果。注意,你不必在多个测试框架之间进行选择,然后再去理解如何安装它们。你需要的一切都内建在 Go 语言中,语法与你将要编写的其余代码相同。
编写测试和函数很类似,其中有一些规则
- 程序需要在一个名为
xxx_test.go
的文件中编写 - 测试函数的命名必须以单词
Test
开始 - 测试函数只接受一个参数
t *testing.T
现在这些信息足以让我们明白,类型为
*testing.T
的变量 t
是你在测试框架中的 hook(钩子),所以当你想让测试失败时可以执行 t.Fail()
之类的操作。我们再讨论一些新的话题:
Go 的
if
语句非常类似于其他编程语言。我们使用
varName := value
的语法声明变量,它允许我们在测试中重用一些值使代码更具可读性。我们调用
t
的 Errorf
方法打印一条消息并使测试失败。f
表示格式化,它允许我们构建一个字符串,并将值插入占位符值 %q
中。当你的测试失败时,它能够让你清楚是什么错误导致的。稍后我们将探讨方法和函数之间的区别。
现在有了测试,就可以安全地迭代我们的软件了。
在上一个示例中,我们在写好代码 之后 编写了测试,以便让你学会如何编写测试和声明函数。从此刻起,我们将 首先编写测试。
我们的下一个需求是指定问候的接受者。
让我们从在测试中捕获这些需求开始。这是基本的测试驱动开发,可以确保我们的测试用例 真正 在测试我们想要的功能。当你回顾编写的测试时,存在一个风险:即使代码没有按照预期工作,测试也可能继续通过。
package main
import "testing"
func TestHello(t *testing.T) {
got := Hello("Chris")
want := "Hello, Chris"
if got != want {
t.Errorf("got '%q' want '%q'", got, want)
}
}
这时运行
go test
,你应该会获得一个编译错误./hello_test.go:6:18: too many arguments in call to Hello
have (string)
want ()
当使用像 Go 这样的静态类型语言时,聆听编译器 是很重要的。编译器理解你的代码应该如何拼接到一起工作,所以你就不必关心这些了。
在这种情况下,编译器告诉你需要怎么做才能继续。我们必须修改函数
Hello
来接受一个参数。修改
Hello
函数以接受字符串类型的参数func Hello(name string) string {
return "Hello, world"
}
如果你尝试再次运行测试,
main.go
会编译失败,因为你没有传递参数。传入参数「world」让它通过。func main() {
fmt.Println(Hello("world"))
}
现在,当你运行测试时,你应该看到类似的内容
hello_test.go:10: got 'Hello, world' want 'Hello, Chris''
我们最终得到了一个可编译的程序,但是根据测试它并没有达到我们的要求。
为了使测试通过,我们使用
name
参数并用 Hello,
字符串连接它,func Hello(name string) string {
return "Hello, " + name
}
现在再运行测试应该就通过了。通常作为 TDD 周期的一部分,我们该着手 重构 了。
此时,如果你正在使用版本控制(你应该这样做!)我将按原样 提交 代码。因为我们拥有一个基于测试的可用版本。
不过我不会推送到主分支上,因为我下一步计划重构。现在提交很合适,你总是可以在重构中陷入混乱时回到这个可用版本。
这里没有太多可重构的,但我们可以介绍一下另一种语言特性 常量。
通常我们这样定义一个常量
const englishHelloPrefix = "Hello, "
现在我们可以重构代码
const englishHelloPrefix = "Hello, "
func Hello(name string) string {
return englishHelloPrefix + name
}
重构之后,重新测试以确保程序无误。
常量应该可以提高应用程序的性能,它避免了每次使用
Hello
时创建 "Hello, "
字符串实例。显然,对于这个例子来说,性能提升是微不足道的!但是创建常量的价值是可以快速理解值的含义,有时还可以帮助提高性能。
下一个需求是当我们的函数用空字符串调用时,它默认为打印 "Hello, World" 而不是 "Hello, "
首先编写一个新的失败测试
func TestHello(t *testing.T) {
t.Run("saying hello to people", func(t *testing.T) {
got := Hello("Chris")
want := "Hello, Chris"
if got != want {
t.Errorf("got '%q' want '%q'", got, want)
}
})
t.Run("say hello world when an empty string is supplied", func(t *testing.T) {
got := Hello("")
want := "Hello, World"
if got != want {
t.Errorf("got '%q' want '%q'", got, want)
}
})
}
这里我们将介绍测试库中的另一个工具 -- 子测试。有时,对一个「事情」进行分组测试,然后再对不同场景进行子测试非常有效。
这种方法的好处是,你可以建立在其他测试中也能够使用的共享代码。
当我们检查信息是否符合预期时,会有重复的代码。
重构不 仅仅 是针对程序的代码!
重要的是,你的测试 清楚地说明 了代码需要做什么。
我们可以并且应该重构我们的测试。
func TestHello(t *testing.T) {
assertCorrectMessage := func(t *testing.T, got, want string) {
t.Helper()
if got != want {
t.Errorf("got '%q' want '%q'", got, want)
}
}
t.Run("saying hello to people", func(t *testing.T) {
got := Hello("Chris")
want := "Hello, Chris"
assertCorrectMessage(t, got, want)
})
t.Run("empty string defaults to 'world'", func(t *testing.T) {
got := Hello("")
want := "Hello, World"
assertCorrectMessage(t, got, want)
})
}
我们在这里做了什么?
我们将断言重构为函数。这减少了重复并且提高了测试的可读性。在 Go 中 ,你可以在其他函数中声明函数并将它们分配给变量。你可以像调用普通函数一样调用它们。我们需要传入
t *testing.T
,这样我们就可以在需要的时候令测试代码失败。t.Helper()
需要告诉测试套件这个方法是辅助函数(helper)。通过这样做,当测试失败时所报告的行号将在函数调用中而不是在辅助函数内部。这将帮助其他开发人员更容易地跟踪问题。如果你仍然不理解,请注释掉它,使测试失败并观察测试输出。现在我们有了一个很好的失败测试,让我们使用
if
修复代码。const englishHelloPrefix = "Hello, "
func Hello(name string) string {
if name == "" {
name = "World"
}
return englishHelloPrefix + name
}
如果我们运行测试,应该看到它满足了新的要求,并且我们没有意外地破坏其他功能。
现在我们对代码很满意,我将修改之前的提交,所以我们只提交认为好的版本及其测试。
让我们再次回顾一下这个周期
- 编写一个测试
- 让编译通过
- 运行测试,查看失败原因并检查错误消息是很有意义的
- 编写足够的代码以使测试通过
- 重构
从表面上看可能很乏味,但坚持这种反馈循环非常重要。
它不仅确保你有 相关的测试,还可以确保你通过重构测试的安全性来 设计优秀的软件。
查看测试失败是一个重要的检查手段,因为它还可以让你看到错误信息。作为一名开发人员,如果测试失败时不能清楚地说明问题所在,那么使用这个代码库可能会非常困难。
通过确保你的测试的 快速,并设置你的工具,可以使运行测试足够简单,你在编写代码时就可以进入流畅的状态。
如果不写测试,你提交的时候通过运行软件来手动检查你的代码,这会打破你的流畅状态,而且你任何时候都无法将自己从这种状态中拯救出来,尤其是从长远来看。
天呐,我们有更多的需求了。现在需要支持第二个参数,指定问候的语言。如果一种不能识别的语言被传进来,就默认为英语。
通过 TDD 轻松实现这一功能,我们是有信心的!
为使用西班牙语的用户编写测试,将其添加到现有的测试用例中。
t.Run("in Spanish", func(t *testing.T) {
got := Hello("Elodie", "Spanish")
want := "Hola, Elodie"
assertCorrectMessage(t, got, want)
})
记住不要欺骗自己!先编写测试。当你尝试运行测试时,编译器 应该 会出错,因为你用两个参数而不是一个来调用
Hello
。./hello_test.go:27:19: too many arguments in call to Hello
have (string, string)
want (string)
通过向
Hello
添加另一个字符串参数来解决编译问题func Hello(name string, language string) string {
if name == "" {
name = "World"
}
return englishHelloPrefix + name
}
当你尝试再次运行测试时,它会报错在其他测试和
hello.go
中没有传递足够的参数给 Hello
函数./hello.go:15:19: not enough arguments in call to Hello
have (string)
want (string, string)
通过传递空字符串来解决它们。现在,除了我们的新场景外,你的所有测试都应该编译并通过
hello_test.go:29: got 'Hello, Elodie' want 'Hola, Elodie'
这里我们可以使用
if
检查语言是否是「西班牙语」,如果是就修改信息func Hello(name string, language string) string {
if name == "" {
name = "World"
}
if language == "Spanish" {
return "Hola, " + name
}
return englishHelloPrefix + name
}
测试现在应该通过了。
现在是 重构 的时候了。你应该在代码中看出了一些问题,其中有一些重复的「魔术」字符串。自己尝试重构它,每次更改都要重新运行测试,以确保重构不会破坏任何内容。
const spanish = "Spanish"
const helloPrefix = "Hello, "
const spanishHelloPrefix = "Hola, "
func Hello(name string, language string) string {
if name == "" {
name = "World"
}
if language == spanish {
return spanishHelloPrefix + name
}
return englishHelloPrefix + name
}
- 编写一个测试,断言如果你传递
"French"
你会得到"Bonjour, "
- 看到它失败,检查错误信息是否容易理解
- 在代码中进行最小的合理更改
你可能写了一些看起来大致如此的东西
func Hello(name string, language string) string {
if name == "" {
name = "World"
}
if language == spanish {
return spanishHelloPrefix + name
}
if language == french {
return frenchHelloPrefix + name
}
return englishHelloPrefix + name
}
当你有很多
if
语句检查一个特定的值时,通常使用 switch
语句来代替。如果我们希望稍后添加更多的语言支持,我们可以使用 switch
来重构代码,使代码更易于阅读和扩展。func Hello(name string, language string) string {
if name == "" {
name = "World"
}
prefix := englishHelloPrefix
switch language {
case french:
prefix = frenchHelloPrefix
case spanish:
prefix = spanishHelloPrefix
}
return prefix + name
}
编写一个测试,添加用你选择的语言写的问候,你应该可以看到扩展这个 神奇 的函数是多么简单。
你可能会抱怨说也许我们的函数正在变得很臃肿。对此最简单的重构是将一些功能提取到另一个函数中。
func Hello(name string, language string) string {
if name == "" {
name = "World"
}
return greetingPrefix(language) + name
}
func greetingPrefix(language string) (prefix string) {
switch language {
case french:
prefix = frenchHelloPrefix
case spanish:
prefix = spanishHelloPrefix
default:
prefix = englishHelloPrefix
}
return
}
一些新的概念:
- 在我们的函数签名中,我们使用了 命名返回值(
prefix string
)。 - 这将在你的函数中创建一个名为
prefix
的变量。- 它将被分配「零」值。这取决于类型,例如
int
是 0,对于字符串它是""
。- 你只需调用
return
而不是return prefix
即可返回所设置的值。
- 这将显示在 Go Doc 中,所以它使你的代码更加清晰。
- 如果没有其他
case
语句匹配,将会执行default
分支。 - 函数名称以小写字母开头。在 Go 中,公共函数以大写字母开始,私有函数以小写字母开头。我们不希望我们算法的内部结构暴露给外部,所以我们将这个功能私有化。
谁会知道你可以从
Hello, world
中学到这么多东西呢?现在你应该对以下内容有了一定的理解:
- 编写测试
- 用参数和返回类型声明函数
if
,const
,switch
- 声明变量和常量
- 编写一个失败的测试,并查看失败信息,我们知道现在有一个为需求编写的 相关 的测试,并且看到它产生了 易于理解的失败描述
- 编写最少量的代码使其通过,以获得可以运行的程序
- 然后 重构,基于我们测试的安全性,以确保我们拥有易于使用的精心编写的代码
在我们的例子中,我们通过小巧易懂的步骤从
Hello()
到 Hello("name")
,到 Hello("name", "french")
。与「现实世界」的软件相比, 这当然是微不足道的,但原则依然通用。TDD 是一门需要通过开发去实践的技能,通过将问题分解成更小的可测试的组件,你编写软件将会更加轻松。
Last modified 3yr ago