用例 – BookSwap 应用

Go 最常见的应用场景之一是构建 Web 应用程序。因此,了解如何构建和测试 Web 应用程序非常重要。我们将在本章和接下来的章节中探索并测试 BookSwap 应用程序。

这个简单的应用程序允许用户注册、列出自己可交换的书籍。其他用户可以注册该应用程序并查看其他用户的可用书籍。然后,他们可以请求借阅其他用户的书籍。BookSwap 应用程序随后生成订单并将其发送到包装服务,以便进行包装和运输。

图 4.8 展示了 BookSwap 应用程序的概述:

image 2025 01 04 17 11 29 868
Figure 1. Figure 4.8 – Overview of the book swap web application

BookSwap Web 应用程序有一些简单的组件:

  • 用户与 UserService 服务端点进行交互。它暴露了几个简单的端点,提供应用程序所需的所有功能:

    • GET / 返回欢迎消息并展示应用程序中的所有书籍列表。这个端点将作为应用程序的主页,显示所有可交换的书籍。为了完整性,GET /books 端点还将返回可用书籍的列表。

    • POST /users 创建一个新用户。用户将收到一个唯一的 userID 值,之后需要记住这个值以便进行后续的交互。为了简化起见,我们将不处理用户认证或任何安全问题。

    • GET /users/{id} 返回给定用户的书籍列表。这个端点将作为某个用户的个人主页。

  • UserService 服务依赖于 BookService。它管理 BookSwap Web 应用程序中所有可用书籍的详细信息和状态。它暴露以下端点:

    • POST /books 在 BookSwap 服务中创建一个新的书籍条目。此请求将接受包含书籍详情的 JSON 请求体。

    • POST /books/{id}?user={userId} 为特定书籍和指定用户创建一个新的请求。这个请求将创建一个新的请求,将指定的书籍发送给新用户。

  • BookService 依赖于外部的 PostingService,它处理邮票的创建和包装请求。一旦 PostingService 处理完订单请求,我们就可以将书籍标记为已交换,并更新其 ownerID 值。

你可以在我们的 GitHub 仓库中查看 BookSwap 应用程序的完整实现。该应用程序是使用标准库中的 net/http 包实现的。我们将在本章中探索 BookSwap Web 应用程序的相关部分,这些部分展示了我们在本章中学到的内容。

测试 BookService

我们将把书籍表示为一种非常简单的数据类型,使用 JSON 标签来格式化其内容,以便在 REST API 中显示,这些 API 提供 JSON 数据:

type Book struct {
    ID      string `json:"id"`
    Name    string `json:"name"`
    Author  string `json:"author"`
    OwnerID string `json:"owner_id"`
    Status  string `json:"status"`
}

虽然 REST API 不一定需要操作 JSON 数据,但 application/json 数据格式是最常用的。书籍具有 OwnerIDStatus 字段,后者显示书籍是否可用于交换。

BookService 是一个非常简单的服务,用于管理书籍。它需要能够通过六个简单的方法来检索和管理书籍:

// NewBookService 初始化一个 BookService,接受初始书籍列表和 PostingService 服务作为参数
func NewBookService(initial []Book, ps PostingService) *BookService

// Get 根据给定的 ID 返回一本书,如果没有找到则返回错误
func (bs *BookService) Get(id string) (*Book, error)

// Upsert 创建或更新一本书
func (bs *BookService) Upsert(b Book) Book

// List 返回所有可用书籍的列表
func (bs *BookService) List() []Book

// ListByUser 根据用户 ID 返回该用户的书籍列表
func (bs *BookService) ListByUser(userID string) []Book

// SwapBook 检查书籍是否可用,并在可能的情况下将其标记为已交换
func (bs *BookService) SwapBook(bookID, userID string) (*Book, error)

NewBookService 方法使用给定的书籍列表和 PostingService 服务初始化 BookServiceGet 方法尝试根据给定的 ID 查找一本书,如果没有找到,则返回错误。Upsert 方法创建一个新的书籍条目,或者如果给定的 ID 已经存在,则更新该条目。List 操作返回所有可供借阅的书籍。ListByUser 根据给定的用户过滤所有书籍,使我们能够为某个用户生成主页。SwapBook 是一个函数,它处理可用性检查,并在交换请求的情况下更新书籍的拥有者 ID。

BookService 会将书籍条目保存在一个以书籍 ID 为键的 map 中:

type BookService struct {
    books map[string]Book
    ps    PostingService
}

这个 map 将方便进行查找操作,这是 BookSwap 应用程序所需要的。GetList 操作预计将是最常用的,因为它们会出现在主页和个人主页上。

让我们看看如何为 BookServiceGet 操作编写表驱动测试。我们声明一个包含两个子测试的测试:一个用于初始数量的书籍,另一个用于空的书籍 map

func TestGetBook(t *testing.T) {
    t.Run("initial books", func(t *testing.T) {
        // 在 BookService 中已有书籍
    })
    t.Run("empty books", func(t *testing.T) {
        // BookService 中没有书籍
    })
}

我们使用两个不同的子测试来处理这两种情况,因为它们需要不同的测试设置。如我们所讨论的,表驱动测试不适合需要不同设置条件的场景。我们首先创建一个示例书籍并创建一个新的 BookService 实例:

eb := db.Book{
    ID:     uuid.New().String(),
    Name:   "Existing book",
    Status: db.Available.String(),
}

这个起始点将在所有测试用例中共享。注意,我们将 PostingService 服务传递为 nil,因为这些测试不涉及它。接下来,我们在第一个子测试中实现一个表驱动测试,包含三个场景:

tests := map[string]struct {
    id      string
    want    db.Book
    wantErr error
}{
    "existing book": {id: eb.ID, want: eb},
    "no book found": {id: "not-found", wantErr: errors.New("no book found")},
    "empty id":      {id: "", wantErr: errors.New("no book found")},
}

这三个测试用例分别是:查找现有书籍、查找在 BookService 中不存在的书籍、以及查找空 ID。然后,我们遍历测试用例,并根据输入和期望的结果进行断言:

for name, tc := range tests {
    t.Run(name, func(t *testing.T) {
        b, err := bs.Get(tc.id)
        if tc.wantErr != nil {
            assert.Equal(t, tc.wantErr, err)
            assert.Nil(t, b)
            return
        }
        assert.Nil(t, err)
        assert.Equal(t, tc.want, *b)
    })
}

正如我们在 “表驱动测试实践” 一节中所做的那样,我们遍历测试用例的 map,首先处理错误情况。记得验证所有期望的返回值。

在第二个子测试中,名为 "empty books",我们运行了一个单独的测试,并对不同的 UUT 实例执行了所需的验证:

t.Run("empty books", func(t *testing.T) {
    bs := db.NewBookService([]db.Book{})
    b, err := bs.Get("id")
    assert.Equal(t, errors.New("no book found"), err)
    assert.Nil(t, b)
})

我们本来可以为第二个子测试也实现表驱动测试,但为了简洁起见,我们选择在这里只包含一个测试。

最后,我们使用 go test 命令运行测试,确保它们通过:

$ go test -run TestGetBook ./chapter04/db -v
=== RUN TestGetBook
--- PASS: TestGetBook (0.00s)
--- PASS: TestGetBook/initial_books (0.00s)
--- PASS: TestGetBook/initial_books/existing_book (0.00s)
--- PASS: TestGetBook/initial_books/no_book_found (0.00s)
--- PASS: TestGetBook/initial_books/empty_id (0.00s)
--- PASS: TestGetBook/empty_books (0.00s)
PASS
ok  github.com/PacktPublishing/Test-Driven-Developmentin-Go/chapter04/db 0.217s

请注意,输出显示了两个不同子测试的嵌套,这使我们能够构建详细的测试层次结构。我们将在接下来的章节中继续探索和测试 BookSwap 应用程序的其他部分,因此有很多时间来进一步探索它。