发布于 2026-01-06 9 阅读
0

可测试的 Go 代码

可测试的 Go 代码

也许你是测试新手,也许你是 Go 语言新手,或者你只是好奇如何用 Go 语言测试代码。在本文中,我将选取一些“糟糕”的 Go 代码,并为其添加测试。在这个过程中,我们还需要改进代码本身,使其更易于测试。因此,即使你不编写测试,也可以从中借鉴经验,调整自己的代码。

概括

  1. 什么是考试?考试的目标是什么?
  2. 测试有哪些不同类型?
  3. 现在进入实际代码测试阶段。
  4. 结语
  5. 参考

什么是考试?考试的目标是什么?

代码测试的目的是确保代码在添加或删除代码、随着时间的推移,甚至修改代码本身之后,行为仍然保持一致。代码测试并不能保证代码完全没有错误,也不能保证没有未知行为,它只是确保在给定输入的情况下,代码能够按照预期运行不要以为编写了测试就意味着代码完美无缺,没有任何错误,测试并不能取代质量保证、日志记录和问题追踪。将测试作为参考,但不要盲目地将其当作代码行为的规则手册。


测试有哪些不同类型?

如果你是一位经验丰富的开发者,你有时可以猜到一些情况,但为了节省时间和便于解释,本文仅展示其中三种情况。

  1. 类型测试
  2. 单元测试
  3. 集成测试

类型测试

这种测试是最常见的类型。它们表现为代码下方的红线或编译错误。任何拥有完善类型系统的语言都能为你进行这种测试。而且完全免费。真的。

// src/main.rs
fn main() {
    let a = 1 + "a";
}

// cargo build --release
   Compiling something v0.1.0 (/private/tmp/something)
error[E0277]: cannot add `&str` to `{integer}`
 --> src/main.rs:2:15
Enter fullscreen mode Exit fullscreen mode

我没有编写任何测试,但其中隐含着一个测试。

单元测试

单元测试是一种非常常见且应用广泛的测试类型。单元测试的重点在于检查纯函数,而不是产生任何副作用。如果运行同一个函数 100 次,则 100 次都应该得到相同的预期结果。

// sum.go
package math

// Sum takes a slice of integers and returns their sum.
func Sum(numbers []int) int {
    sum := 0
    for _, num := range numbers {
        sum += num
    }
    return sum
}

// sum_test.go
package math_test

import "testing"

func TestSum(t *testing.T) {
    input := int[]{1, 2, 3}

    result := Sum(input)
    expected := 6

    if result != expected {
        t.Errorf("Expected %d, but got %d for input %v", expected, result, input)
    }
}
Enter fullscreen mode Exit fullscreen mode

集成测试

这是一种更具体的测试类型。该测试旨在整合系统的不同部分,这些部分可能会出现故障且无法控制。例如,访问外部资源(如数据库)或使用系统调用(如读取或写入文件)。通常会创建模拟对象、桩对象和/或间谍对象来避免执行过程中的不稳定性。

// file_writer.go
package file

import (
    "io/ioutil"
    "os"
)

// WriteToFile writes content to a file with the given filename.
func WriteToFile(filename, content string) error {
    return ioutil.WriteFile(filename, []byte(content), 0644)
}


// file_writer_test.go

package file

import (
    "io/ioutil"
    "os"
    "testing"
)

func TestWriteToFile(t *testing.T) {
    // Define a test filename and content.
    filename := "testfile.txt"
    content := "This is a test file."

    // Clean up the file after the test.
    defer func() {
        err := os.Remove(filename)
        if err != nil {
            t.Errorf("Error deleting test file: %v", err)
        }
    }()

    // Call the function to write to the file.
    err := WriteToFile(filename, content)
    if err != nil {
        t.Fatalf("Error writing to file: %v", err)
    }

    // Read the file to verify its content.
    fileContent, err := ioutil.ReadFile(filename)
    if err != nil {
        t.Fatalf("Error reading from file: %v", err)
    }

    // Check if the content matches the expected content.
    if string(fileContent) != content {
        t.Errorf("File content doesn't match. Expected: %s, Got: %s", content, string(fileContent))
    }
}
Enter fullscreen mode Exit fullscreen mode

现在进入实际代码测试阶段。

我们的执行代码如下。

// pkg/database.go
package pkg

import (
    "log"

    "github.com/jmoiron/sqlx"
    _ "github.com/lib/pq"
)

var connection *sqlx.DB

func InitConnection() {
    db, err := sqlx.Connect("postgres", "user=postgres password=postgres dbname=lab sslmode=disable")
    if err != nil {
        log.Fatalln("failed to connect", err)
    }

    if _, err := db.Exec(getSql()); err != nil {
        log.Fatalln("failed to execute sql", err)
    }

    connection = db
}

// not the best way to do this, but it works for this context
func getSql() string {
    return `
        create table if not exists posts
        (
            id         serial primary key,
            title      varchar(255) not null,
            body       text         not null,
            created_at timestamp default current_timestamp,
            updated_at timestamp default current_timestamp
        );

        create table if not exists comments
        (
            id         serial primary key,
            post_id    int  not null references posts (id) on delete cascade,
            body       text not null,
            created_at timestamp default current_timestamp
        );
    `
}

// pkg/service.go
package pkg

type Post struct {
    ID        int       `db:"id" json:"id"`
    Title     string    `db:"title" json:"title"`
    Body      string    `db:"body" json:"body"`
    CreatedAt string    `db:"created_at" json:"created_at"`
    UpdatedAt string    `db:"updated_at" json:"updated_at"`
    Comments  []Comment `json:"comments"`
}

type Comment struct {
    ID        int    `db:"id" json:"id"`
    Body      string `db:"body" json:"body"`
    PostID    int    `db:"post_id" json:"-"`
    CreatedAt string `db:"created_at" json:"created_at"`
}

func GetPostsWithComments() ([]Post, error) {
    var posts []Post

    if err := connection.Select(&posts, "select * from posts"); err != nil {
        return nil, err
    }

    for i := range posts {
        if err := connection.Select(&posts[i].Comments, "select * from comments where post_id = $1", posts[i].ID); err != nil {
            return nil, err
        }
    }

    return posts, nil
}

// main.go
package main

import (
    "encoding/json"
    "fmt"
    "log"

    "github.com/stneto1/better-go-article/pkg"
)

func main() {
    pkg.InitConnection()

    posts, err := pkg.GetPostsWithComments()
    if err != nil {
        log.Fatalln("failed to get posts", err)
    }

    jsonData, err := json.MarshalIndent(posts, "", "  ")
    if err != nil {
        log.Fatalln("failed to marshal json", err)
    }

    fmt.Println(string(jsonData))
}
Enter fullscreen mode Exit fullscreen mode

上面的代码实现了以下功能:

  1. 初始化全局连接
  2. 获取所有帖子及其评论
  3. 以美观的 JSON 格式打印到终端。

简单明了。现在让我们给主代码添加测试GetPostsWithComments

// pkg/service_test.go
package pkg_test

import (
    "testing"

    "github.com/go-playground/assert/v2"
    "github.com/stneto1/better-go-article/pkg"
)

func TestGetPostsWithComments(t *testing.T) {
    posts, err := pkg.GetPostsWithComments()

    assert.Equal(t, err, nil)
    assert.Equal(t, len(posts), 0)
}
Enter fullscreen mode Exit fullscreen mode

运行代码后应该会出现错误,因为测试已运行,但全局连接尚未初始化。

package pkg_test

import (
    "testing"

    "github.com/go-playground/assert/v2"
    "github.com/stneto1/better-go-article/pkg"
)

func TestGetPostsWithComments(t *testing.T) {
    pkg.InitConnection() // remember to call before each test

    posts, err := pkg.GetPostsWithComments()

    assert.Equal(t, err, nil)
    assert.Equal(t, len(posts), 0)
}
Enter fullscreen mode Exit fullscreen mode

现在你运行测试,所有测试都通过了,干得好。但是如果你修改了数据库,将来测试可能会失败,因为无法保证测试运行时数据库的状态。

当前代码存在哪些问题?

  • RealDB 连接
  • 全球联系
  • N+1 查询
  • 连接未关闭
  • 抽象泄漏 → 输出模型 = 数据库模型

让我们来解决其中的一些问题。

// pkg/database.go
func InitConnection() *sqlx.DB {
    db, err := sqlx.Connect("postgres", "user=postgres password=postgres dbname=lab sslmode=disable")
    if err != nil {
        log.Fatalln("failed to connect", err)
    }

    if _, err := db.Exec(getSql()); err != nil {
        log.Fatalln("failed to execute sql", err)
    }

    return db
}

//pkg/service.go
func GetPostsWithComments(conn *sqlx.DB) ([]Post, error) {
    // redacted
}


// main.go
func main() {
    conn := pkg.InitConnection()
    defer conn.Close()

    posts, err := pkg.GetPostsWithComments(conn)
    // redacted
}

// pkg/service_test.go

package pkg_test

import (
    "testing"

    "github.com/go-playground/assert/v2"
    "github.com/stneto1/better-go-article/pkg"
)

func TestGetPostsWithComments(t *testing.T) {
    conn := pkg.InitConnection()
    defer conn.Close()

    posts, err := pkg.GetPostsWithComments(conn)

    // redacted
}
Enter fullscreen mode Exit fullscreen mode

现在该函数GetPostsWithComments会将连接作为参数接收,因此我们可以测试更可控的连接。我们还可以在运行测试后关闭连接。

  • RealDB 连接
  • 全球联系
  • N+1 查询
  • 连接未关闭
  • 抽象泄漏 → 输出模型 = 数据库模型

下一期,我们将讨论真正的数据库连接。

我们目前的测试需要一个 PostgreSQL 连接。如果运行环境没有这个连接,测试就无法进行。所以我们来改进一下。注意数据库连接类型*sqlx.DB,而不是 PostgreSQL 特有的连接。只要我们提供有效的 sqlx 连接,就能确保测试顺利执行。为此,我们可以使用 sqlite,既可以内存存储也可以写入单个文件。由于 SQL 大部分是一种规范语言,我们可以根据运行环境灵活地在 sqlite 和 PostgreSQL 之间切换。

// pkg/database.go

// redacted

func InitTempDB() *sqlx.DB {
    // This commented line is to create a temporary database in /tmp,
    // in case you want to access the file itself
    // db, err := sqlx.Connect("sqlite3", fmt.Sprintf("file:/tmp/%s.db", ulid.MustNew(ulid.Now(), nil).String()))
    db, err := sqlx.Connect("sqlite3", ":memory:")
    if err != nil {
        log.Fatalln("failed to connect", err)
    }

    if _, err := db.Exec(getSql()); err != nil {
        log.Fatalln("failed to execute sql", err)
    }

    return db
}

// pkg/service_test.go
package pkg_test

import (
    "testing"

    "github.com/go-playground/assert/v2"
    "github.com/stneto1/better-go-article/pkg"
)

func TestGetPostsWithComments(t *testing.T) {
    conn := pkg.InitTempDB()
    defer conn.Close()

    posts, err := pkg.GetPostsWithComments(conn)

    assert.Equal(t, err, nil)
    assert.Equal(t, len(posts), 0)
}
Enter fullscreen mode Exit fullscreen mode

我们的主要代码不会改变,因为我们需要实际连接才能在生产环境中执行我们的应用程序,但对于测试,我们可以使用内存中的 sqlite,以便为每个测试创建一个“干净的”数据库。

  • RealDB 连接
  • N+1 查询
  • 抽象泄漏 → 输出模型 = 数据库模型

现在让我们来解决最后的问题。

我们仍然需要对数据库进行 n+1 次查询,一次查询用于获取帖子,n 次查询用于获取评论。为此,让我们创建一个业务结构体。

// pkg/service.go

// redacted

type postsWithCommentsRow struct {
    PostID           int    `db:"posts_id"`
    PostTitle        string `db:"posts_title"`
    PostBody         string `db:"posts_body"`
    PostCreatedAt    string `db:"posts_created_at"`
    CommentID        int    `db:"comments_id"`
    CommentBody      string `db:"comments_body"`
    CommentCreatedAt string `db:"comments_created_at"`
}

func GetPostsWithComments(conn *sqlx.DB) ([]Post, error) {
    var rawPosts []postsWithCommentsRow

    if err := conn.Select(&rawPosts, `
        select posts.id       as posts_id,
            posts.title         as posts_title,
            posts.body          as posts_body,
            posts.created_at    as posts_created_at,
            comments.id         as comments_id,
            comments.body       as comments_body,
            comments.created_at as comments_created_at
        from posts
                left join comments on posts.id = comments.post_id
        order by posts.id;
    `); err != nil {
        return nil, err
    }

    posts := make([]Post, 0)

OuterLoop:
    for _, rawPost := range rawPosts {
        post := Post{
            ID:        rawPost.PostID,
            Title:     rawPost.PostTitle,
            Body:      rawPost.PostBody,
            CreatedAt: rawPost.PostCreatedAt,
        }

        for _, post := range posts {
            if post.ID == rawPost.PostID {
                continue OuterLoop
            }
        }

        posts = append(posts, post)
    }

    for _, rawPost := range rawPosts {
        for i, post := range posts {
            if post.ID == rawPost.PostID {
                comment := Comment{
                    ID:        rawPost.CommentID,
                    Body:      rawPost.CommentBody,
                    CreatedAt: rawPost.CommentCreatedAt,
                }

                posts[i].Comments = append(posts[i].Comments, comment)
            }
        }
    }

    return posts, nil
}
Enter fullscreen mode Exit fullscreen mode

我们的代码量确实增加了不少。但具体有哪些变化呢?首先,现在我们只向数据库发出一次查询。此外,我们还手动将查询结果映射到业务规则结构中PostComments同时,我们也把数据库结构postsWithCommentsRow与外部结构分离了。

  • N+1 查询
  • 抽象泄漏 → 输出模型 = 数据库模型

就这样,我们解决了最后两个问题。

结语

在对测试有了更深入的了解之后,我们选取​​了一些 Go 代码,编写了一些测试并改进了代码。那么接下来该做什么呢?即使你不喜欢 Go,你仍然可以理解我们在这里讨论的概念,并将其应用到你自己的代码中。测试是一项需要通过时间和经验积累才能掌握的技能。

参考

文章来源:https://dev.to/stneto1/testable-go-code-13k5