Go语言:利用 TDD 逐步为一个字典应用创建完整的 CRUD API

Posted slowlydance2me

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Go语言:利用 TDD 逐步为一个字典应用创建完整的 CRUD API相关的知识,希望对你有一定的参考价值。

前言

在数组这一章节中,我们学会了如何按顺序存储值。现在,我们再来看看如何通过存储值,并快速查找它们。
Maps 允许你以类似于字典的方式存储值。你可以将视为单词,将视为定义。
所以,难道还有比构建我们自己的字典更好的学习 map 的方式吗?
 

正文

首先编写测试

dictionary_test.go 中编写代码:

package main

import "testing"

func TestSearch(t *testing.T) 
    dictionary := map[string]string"test": "this is just a test"

    got := Search(dictionary, "test")
    want := "this is just a test"

    if got != want 
        t.Errorf("got \'%s\' want \'%s\' given, \'%s\'", got, want, "test")
    
声明 map 的方式有点儿类似于数组。
不同之处是,它以 map 关键字开头,需要两种类型。第一个是键的类型,写在 [] 中。第二个是值的类型,跟在 [] 之后。
键的类型很特别,它只能是一个可比较的类型,因为如果不能判断两个键是否相等,我们就无法确保我们得到的是正确的值。可比类型在语言规范中有详细解释。
另一方面,值的类型可以是任意类型,它甚至可以是另一个 map。

尝试运行测试

运行 go test 后编译器会提示失败信息 ./dictionary_test.go:8:9: undefined: Search

 

编写最少量的代码让测试运行并检查输出

dictionary.go 中:

package main

func Search(dictionary map[string]string, word string) string 
    return ""
测试应该失败并显示明确的错误信息
dictionary_test.go:12: got \'\' want \'this is just a test\' given, \'test\'
 

编写足够的代码使测试通过

func Search(dictionary map[string]string, word string) string 
    return dictionary[word]

从 map 中获取值和数组相同,都是通过 map[key] 的方式。

 

重构

func TestSearch(t *testing.T) 
    dictionary := map[string]string"test": "this is just a test"

    got := Search(dictionary, "test")
    want := "this is just a test"

    assertStrings(t, got, want)


func assertStrings(t *testing.T, got, want string) 
    t.Helper()

    if got != want 
        t.Errorf("got \'%s\' want \'%s\'", got, want)
    

创建一个 assertStrings 辅助函数并删除 given 的部分让实现更通用。

 

使用自定义的类型

我们可以通过为 map 创建新的类型并使用 Search 方法改进字典的使用。

dictionary_test.go 中:

func TestSearch(t *testing.T) 
    dictionary := Dictionary"test": "this is just a test"

    got := dictionary.Search("test")
    want := "this is just a test"

    assertStrings(t, got, want)
我们已经开始使用 Dictionary 类型了,但是我们还没有定义它。然后要在 Dictionary 实例上调用 Search 方法。
我们不需要更改 assertStrings
dictionary.go 中:
type Dictionary map[string]string

func (d Dictionary) Search(word string) string 
    return d[word]

在这里,我们创建了一个 Dictionary 类型,它是对 map 的简单封装。定义了自定义类型后,我们可以创建 Search 方法。

首先编写测试

基本的搜索很容易实现,但是如果我们提供一个不在我们字典中的单词,会发生什么呢?
我们实际上得不到任何返回。这很好,因为程序可以继续运行,但还有更好的方法。
这个函数可以证明该单词不在字典中。这样,用户就不用猜测这个单词是不存在还是未定义了(这看起来可能对于字典没有用。但是,这可能是其他用例的关键场景)。
 
func TestSearch(t *testing.T) 
    dictionary := Dictionary"test": "this is just a test"

    t.Run("known word", func(t *testing.T) 
        got, _ := dictionary.Search("test")
        want := "this is just a test"

        assertStrings(t, got, want)
    )

    t.Run("unknown word", func(t *testing.T) 
        _, err := dictionary.Search("unknown")
        want := "could not find the word you were looking for"

        if err == nil 
            t.Fatal("expected to get an error.")
        

        assertStrings(t, err.Error(), want)
    )

在 Go 中处理这种情况的方法是返回第二个参数,它是一个 Error 类型。

Error 类型可以使用 .Error() 方法转换为字符串,我们将其传递给断言时会执行此操作。

我们也用 if 来保护 assertStrings,以确保我们不在 nil 上调用 .Error()

 

尝试运行测试

这不会通过编译

./dictionary_test.go:18:10: assignment mismatch: 2 variables but 1 values

编写最少量的代码让测试运行并检查输出

func (d Dictionary) Search(word string) (string, error)
return d[word], nil

现在你的测试将会失败,并显示更加清晰的错误信息

dictionary_test.go:22: expected to get an error.

编写足够的代码使测试通过

func (d Dictionary) Search(word string) (string, error) 
    definition, ok := d[word]
    if !ok 
        return "", errors.New("could not find the word you were looking for")
    

    return definition, nil

为了使测试通过,我们使用了一个 map 查找的有趣特性。

它可以返回两个值。第二个值是一个布尔值,表示是否成功找到 key

此特性允许我们区分单词不存在还是未定义。

 

重构

var ErrNotFound = errors.New("could not find the word you were looking for")

func (d Dictionary) Search(word string) (string, error) 
    definition, ok := d[word]
    if !ok 
        return "", ErrNotFound
    

    return definition, nil

我们通过将错误提取为变量的方式,摆脱 Search 中魔术错误(magic error)。这也会使我们获得更好的测试。

t.Run("unknown word", func(t *testing.T) 
    _, got := dictionary.Search("unknown")

    assertError(t, got, ErrNotFound)
)

func assertError(t *testing.T, got, want error) 
    t.Helper()

    if got != want 
        t.Errorf("got error \'%s\' want \'%s\'", got, want)
    

通过创建一个新的辅助函数,我们能够简化测试,并使用 ErrNotFound 变量,如果我们将来更改显示错误的文字,测试也不会失败。

首先编写测试

我们现在有很好的方法来搜索字典。但是,我们无法在字典中添加新单词。

func TestAdd(t *testing.T) 
    dictionary := Dictionary
    dictionary.Add("test", "this is just a test")

    want := "this is just a test"
    got, err := dictionary.Search("test")
    if err != nil 
        t.Fatal("should find added word:", err)
    

    if want != got 
        t.Errorf("got \'%s\' want \'%s\'", got, want)
    

在这个测试中,我们利用 Search 方法使字典的验证更加容易。

编写最少量的代码让测试运行并检查输出

dictionary.go 中:

func (d Dictionary) Add(word, definition string) 

测试现在应该会失败:

dictionary_test.go:31: should find added word: could not find the word you were looking for

编写足够的代码使测试通过

func (d Dictionary) Add(word, definition string) 
    d[word] = definition

向 map 添加元素也类似于数组。你只需指定键并给它赋一个值。

引用类型

Map 有一个有趣的特性,不使用指针传递你就可以修改它们。

这是因为 map 是引用类型。这意味着它拥有对底层数据结构的引用,就像指针一样。

它底层的数据结构是 hash tablehash map

Map 作为引用类型是非常好的,因为无论 map 有多大,都只会有一个副本

 

引用类型引入了 maps 可以是 nil 值。如果你尝试使用一个 nil 的 map,你会得到一个 nil 指针异常,这将导致程序终止运行。

由于 nil 指针异常,你永远不应该初始化一个空的 map 变量:

var m map[string]string

相反,你可以像我们上面那样初始化空 map,或使用 make 关键字创建 map

dictionary = map[string]string

// OR

dictionary = make(map[string]string)

这两种方法都可以创建一个空的 hash map 并指向 dictionary。这确保永远不会获得 nil 指针异常

 

重构

在我们的实现中没有太多可以重构的地方,但测试可以简化一点。

func TestAdd(t *testing.T) 
    dictionary := Dictionary
    word := "test"
    definition := "this is just a test"

    dictionary.Add(word, definition)

    assertDefinition(t, dictionary, word, definition)


func assertDefinition(t *testing.T, dictionary Dictionary, word, definition string) 
    t.Helper()

    got, err := dictionary.Search(word)
    if err != nil 
        t.Fatal("should find added word:", err)
    

    if definition != got 
        t.Errorf("got \'%s\' want \'%s\'", got, definition)
    

我们为单词和定义创建了变量,并将定义断言移到了自己的辅助函数中。

我们的 Add 看起来不错。除此之外,我们没有考虑当我们尝试添加的值已经存在时会发生什么!

如果值已存在,map 不会抛出错误。相反,它们将继续并使用新提供的值覆盖该值。

这在实践中很方便,但会导致我们的函数名称不准确。Add 不应修改现有值。它应该只在我们的字典中添加新单词。

 

首先编写测试

func TestAdd(t *testing.T) 
    t.Run("new word", func(t *testing.T) 
        dictionary := Dictionary
        word := "test"
        definition := "this is just a test"

        err := dictionary.Add(word, definition)

        assertError(t, err, nil)
        assertDefinition(t, dictionary, word, definition)
    )

    t.Run("existing word", func(t *testing.T) 
        word := "test"
        definition := "this is just a test"
        dictionary := Dictionaryword: definition
        err := dictionary.Add(word, "new test")

        assertError(t, err, ErrWordExists)
        assertDefinition(t, dictionary, word, definition)
    )

对于此测试,我们修改了 Add 以返回错误,我们将针对新的错误变量 ErrWordExists 进行验证。我们还修改了之前的测试以检查是否为 nil 错误。

尝试运行测试

编译将失败,因为我们没有为 Add 返回值。

./dictionary_test.go:30:13: dictionary.Add(word, definition) used as value
./dictionary_test.go:41:13: dictionary.Add(word, "new test") used as value

 

编写最少量的代码让测试运行并检查输出

dictionary.go 中:

var (
    ErrNotFound   = errors.New("could not find the word you were looking for")
    ErrWordExists = errors.New("cannot add word because it already exists")
)

func (d Dictionary) Add(word, definition string) error 
    d[word] = definition
    return nil

现在我们又得到两个错误。我们仍在修改值,并返回 nil 错误。

dictionary_test.go:43: got error \'%!s(<nil>)\' want \'cannot add word because it already exists\'
dictionary_test.go:44: got \'new test\' want \'this is just a test\'

编写足够的代码使测试通过

func (d Dictionary) Add(word, definition string) error 
    _, err := d.Search(word)

    switch err 
    case ErrNotFound:
        d[word] = definition
    case nil:
        return ErrWordExists
    default:
        return err
    

    return nil

这里我们使用 switch 语句来匹配错误。如上使用 switch 提供了额外的安全,以防 Search 返回错误而不是 ErrNotFound

重构

我们没有太多需要重构的地方,但随着对错误使用的增多,我们还可以做一些修改。

const (
    ErrNotFound   = DictionaryErr("could not find the word you were looking for")
    ErrWordExists = DictionaryErr("cannot add word because it already exists")
)

type DictionaryErr string

func (e DictionaryErr) Error() string 
    return string(e)

我们将错误声明为常量,这需要我们创建自己的 DictionaryErr 类型来实现 error 接口。

你可以在 Dave Cheney 的这篇优秀文章中了解更多相关的细节。

简而言之,它使错误更具可重用性和不可变性

首先编写测试

func TestUpdate(t *testing.T) 
    word := "test"
    definition := "this is just a test"
    dictionary := Dictionaryword: definition
    newDefinition := "new definition"

    dictionary.Update(word, newDefinition)

    assertDefinition(t, dictionary, word, newDefinition)

以上是关于Go语言:利用 TDD 逐步为一个字典应用创建完整的 CRUD API的主要内容,如果未能解决你的问题,请参考以下文章

Go语言:利用 TDD 驱动开发测试 学习结构体方法和接口

Go 语言:如何利用好 TDD 学习指针并了解 Golang 中的 error 处理

Go语言:通过TDD驱动测试开发为同事写的程序优化提速——初次接触并发与channel

Go语言核心36讲(Go语言实战与应用十三)--学习笔记

go语言学习之路四:字典

go 语言字典遍历