可测试的 Go 代码
也许你是测试新手,也许你是 Go 语言新手,或者你只是好奇如何用 Go 语言测试代码。在本文中,我将选取一些“糟糕”的 Go 代码,并为其添加测试。在这个过程中,我们还需要改进代码本身,使其更易于测试。因此,即使你不编写测试,也可以从中借鉴经验,调整自己的代码。
概括
什么是考试?考试的目标是什么?
代码测试的目的是确保代码在添加或删除代码、随着时间的推移,甚至修改代码本身之后,行为仍然保持一致。代码测试并不能保证代码完全没有错误,也不能保证没有未知行为,它只是确保在给定输入的情况下,代码能够按照预期运行。不要以为编写了测试就意味着代码完美无缺,没有任何错误,测试并不能取代质量保证、日志记录和问题追踪。将测试作为参考,但不要盲目地将其当作代码行为的规则手册。
测试有哪些不同类型?
如果你是一位经验丰富的开发者,你有时可以猜到一些情况,但为了节省时间和便于解释,本文仅展示其中三种情况。
- 类型测试
- 单元测试
- 集成测试
类型测试
这种测试是最常见的类型。它们表现为代码下方的红线或编译错误。任何拥有完善类型系统的语言都能为你进行这种测试。而且完全免费。真的。
// 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
我没有编写任何测试,但其中隐含着一个测试。
单元测试
单元测试是一种非常常见且应用广泛的测试类型。单元测试的重点在于检查纯函数,而不是产生任何副作用。如果运行同一个函数 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)
}
}
集成测试
这是一种更具体的测试类型。该测试旨在整合系统的不同部分,这些部分可能会出现故障且无法控制。例如,访问外部资源(如数据库)或使用系统调用(如读取或写入文件)。通常会创建模拟对象、桩对象和/或间谍对象来避免执行过程中的不稳定性。
// 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))
}
}
现在进入实际代码测试阶段。
我们的执行代码如下。
// 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))
}
上面的代码实现了以下功能:
- 初始化全局连接
- 获取所有帖子及其评论
- 以美观的 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)
}
运行代码后应该会出现错误,因为测试已运行,但全局连接尚未初始化。
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)
}
现在你运行测试,所有测试都通过了,干得好。但是如果你修改了数据库,将来测试可能会失败,因为无法保证测试运行时数据库的状态。
当前代码存在哪些问题?
- 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
}
现在该函数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)
}
我们的主要代码不会改变,因为我们需要实际连接才能在生产环境中执行我们的应用程序,但对于测试,我们可以使用内存中的 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
}
我们的代码量确实增加了不少。但具体有哪些变化呢?首先,现在我们只向数据库发出一次查询。此外,我们还手动将查询结果映射到业务规则结构中Post。Comments同时,我们也把数据库结构postsWithCommentsRow与外部结构分离了。
-
N+1 查询 -
抽象泄漏 → 输出模型 = 数据库模型
就这样,我们解决了最后两个问题。
结语
在对测试有了更深入的了解之后,我们选取了一些 Go 代码,编写了一些测试并改进了代码。那么接下来该做什么呢?即使你不喜欢 Go,你仍然可以理解我们在这里讨论的概念,并将其应用到你自己的代码中。测试是一项需要通过时间和经验积累才能掌握的技能。