xgo
English | 简体中文
xgo
为 golang 提供全in一的测试工具,包括:
对于猴子补丁部分,xgo
作为 go run
、go build
和 go test
的预处理器工作(参见我们的博客)。
顺便说一句,我保证这是一个有趣的项目。
安装
go install github.com/xhd2015/xgo/cmd/xgo@latest
验证安装:
xgo version
# 输出:
# 1.0.x
xgo help
# 输出:帮助信息
如果找不到 xgo
,你可能需要检查 $GOPATH/bin
是否已添加到你的 PATH
变量中。
对于 CI 作业(如 github 工作流),请参阅 doc/INSTALLATION.md。
要求
xgo
需要至少 go1.17
才能编译。
对操作系统和架构没有特定限制。
所有操作系统和架构只要被 go
支持,就都被 xgo
支持。
x86 | x86_64 (amd64) | arm64 | 任何其他架构... | |
---|---|---|---|---|
Linux | Y | Y | Y | Y |
Windows | Y | Y | Y | Y |
macOS | Y | Y | Y | Y |
任何其他操作系统... | Y | Y | Y | Y |
快速开始
让我们用 xgo
编写一个单元测试:
- 确保你已按照安装部分安装了
xgo
,并通过以下命令验证安装:
xgo version
# 输出
# 1.0.x
- 初始化一个 go 项目:
mkdir demo
cd demo
go mod init demo
- 添加
demo_test.go
:
package demo_test
import (
"testing"
"github.com/xhd2015/xgo/runtime/mock"
)
func MyFunc() string {
return "my func"
}
func TestFuncMock(t *testing.T) {
mock.Patch(MyFunc, func() string {
return "mock func"
})
text := MyFunc()
if text != "mock func" {
t.Fatalf("expect MyFunc() to be 'mock func', actual: %s", text)
}
}
- 获取
github.com/xhd2015/xgo/runtime
依赖:
go get github.com/xhd2015/xgo/runtime
- 测试代码:
xgo test -v ./
# 注意:xgo 首次设置时会花一些时间
输出:
=== RUN TestFuncMock
--- PASS: TestFuncMock (0.00s)
PASS
ok demo
注意:使用 xgo
而不是 go
来测试你的代码。
在底层,xgo
预处理你的代码以添加模拟钩子,然后调用 go
完成剩余工作。
上述代码可以在 doc/demo 中找到。
API
Patch
在当前 goroutine 中修补给定函数。
API:
Patch(fn,replacer) func()
速查表:
// 包级函数
mock.Patch(SomeFunc, mockFunc)
// 每个实例的方法
// 只有绑定的实例 `v` 会被模拟
// `v` 可以是结构体或接口
mock.Patch(v.Method, mockMethod)
// 每个 TParam 的泛型函数
// 只有指定的 `int` 版本会被模拟
mock.Patch(GenericFunc[int], mockFuncInt)
// 每个 TParam 和实例的泛型方法
v := GenericStruct[int]
mock.Mock(v.Method, mockMethod)
// 闭包也可以被模拟
// 使用较少,但也支持
mock.Mock(closure, mockFunc)
参数:
- 如果
fn
是一个简单函数(即包级函数、类型拥有的函数或闭包(是的,我们确实支持模拟闭包)),那么对该函数的所有调用都将被拦截, replacer
另一个将替换fn
的函数
范围:
- 如果
Patch
在init
中调用,那么所有 goroutine 都将被模拟。 - 否则,如果
Patch
在init
之后调用,那么模拟拦截器只对当前 goroutine 有效,其他 goroutine 不受影响。
注意:fn
和 replacer
应该有相同的签名。
返回:
- 一个
func()
可以用来在当前 goroutine 退出之前提前移除替换器
Patch 在当前 goroutine 中用 replacer
替换给定的 fn
。一旦当前 goroutine 退出,它将移除替换器。
示例:
package patch_test
import (
"testing"
"github.com/xhd2015/xgo/runtime/mock"
)
func greet(s string) string {
return "hello " + s
}
func TestPatchFunc(t *testing.T) {
mock.Patch(greet, func(s string) string {
return "mock " + s
})
res := greet("world")
if res != "mock world" {
t.Fatalf("expect patched result to be %q, actual: %q", "mock world", res)
}
}
注意:Patch
和 Mock
(下文)支持顶级变量和常量,参见 runtime/mock/MOCK_VAR_CONST.md。
模拟标准库的注意事项:模拟标准库函数有不同的模式,参见 runtime/mock/stdlib.md。
Mock
runtime/mock
还提供了另一个名为 Mock
的 API,类似于 Patch
。
它们之间的唯一区别在于第二个参数:Mock
接受一个拦截器。
Mock
可以在 Patch
无法使用的情况下使用,比如具有未导出类型的函数。
API 详情:runtime/mock/README.md
Mock API:
Mock(fn, interceptor)
参数:
fn
与 Patch 部分描述的相同- 如果
fn
是一个方法(即file.Read
),那么只有对该实例的调用会被拦截,其他实例不受影响
拦截器签名:func(ctx context.Context, fn *core.FuncInfo, args core.Object, results core.Object) error
- 如果拦截器返回
nil
,则目标函数被模拟, - 如果拦截器返回
mock.ErrCallOld
,则目标函数会被再次调用, - 否则,拦截器返回非 nil 错误,该错误将被设置为函数的返回错误。
还有其他 2 个 API 可用于基于名称设置模拟,详情请查看 runtime/mock/README.md。
方法模拟示例:
type MyStruct struct {
name string
}
func (c *MyStruct) Name() string {
return c.name
}
func TestMethodMock(t *testing.T){
myStruct := &MyStruct{
name: "my struct",
}
otherStruct := &MyStruct{
name: "other struct",
}
mock.Mock(myStruct.Name, func(ctx context.Context, fn *core.FuncInfo, args core.Object, results core.Object) error {
results.GetFieldIndex(0).Set("mock struct")
return nil
})
// myStruct 受影响
name := myStruct.Name()
if name!="mock struct"{
t.Fatalf("expect myStruct.Name() to be 'mock struct', actual: %s", name)
}
// otherStruct 不受影响
otherName := otherStruct.Name()
if otherName!="other struct"{
t.Fatalf("expect otherStruct.Name() to be 'other struct', actual: %s", otherName)
}
}
Trace
Trace 可能是 xgo 提供的最强大的工具,这个博客有更详细的示例:https://blog.xhd2015.xyz/posts/xgo-trace_a-powerful-visualization-tool-in-go
在调试深层调用栈时会很痛苦。
Trace 通过收集分层堆栈跟踪并将其存储到文件中供以后使用来解决这个问题。
不用说,有了 Trace,调试变得不那么常见:
package trace_test
import (
"fmt"
"testing"
)
func TestTrace(t *testing.T) {
A()
B()
C()
}
func A() { fmt.Printf("A\n") }
func B() { fmt.Printf("B\n");C(); }
func C() { fmt.Printf("C\n") }
用 xgo
运行:
# 运行测试
# 这将把跟踪写入 TestTrace.json
# --strace 表示堆栈跟踪
xgo test --strace ./
# 查看跟踪
xgo tool trace TestTrace.json
输出:
来自 runtime/test/stack_trace/update_test.go 的另一个更复杂的示例:
真实世界的例子:
Trace 帮助你快速上手新项目。
默认情况下,Trace 会将跟踪写入当前工作目录下的临时目录。可以通过将 XGO_TRACE_OUTPUT
设置为不同的值来覆盖此行为:
XGO_TRACE_OUTPUT=stdout
:跟踪将写入标准输出,用于调试目的,XGO_TRACE_OUTPUT=<dir>
:跟踪将写入<dir>
,XGO_TRACE_OUTPUT=off
:关闭跟踪。
除了 --strace
标志外,xgo 还允许你定义应该收集哪个跨度,使用 trace.Begin()
:
import "github.com/xhd2015/xgo/runtime/trace"
func TestTrace(t *testing.T) {
A()
finish := trace.Begin()
defer finish()
B()
C()
}
跟踪将只包括 B()
和 C()
。
Trap
Xgo 在调用 go
之前预处理源代码和 IR(中间表示),为用户提供在调用任何函数时拦截的机会。
Trap 允许开发人员动态拦截函数执行。 以下示例通过添加Trap拦截器来记录函数执行跟踪:
(查看 test/testdata/trap/trap.go 获取更多详情。)
package main
import (
"context"
"fmt"
"github.com/xhd2015/xgo/runtime/core"
"github.com/xhd2015/xgo/runtime/trap"
)
func init() {
trap.AddInterceptor(&trap.Interceptor{
Pre: func(ctx context.Context, f *core.FuncInfo, args core.Object, results core.Object) (interface{}, error) {
if f.Name == "A" {
fmt.Printf("trap A\n")
return nil, nil
}
if f.Name == "B" {
fmt.Printf("abort B\n")
return nil, trap.ErrAbort
}
return nil, nil
},
})
}
func main() {
A()
B()
}
func A() {
fmt.Printf("A\n")
}
func B() {
fmt.Printf("B\n")
}
使用 go
运行:
go run ./
# 输出:
# A
# B
使用 xgo
运行:
xgo run ./
# 输出:
# trap A
# A
# abort B
AddInterceptor()
将给定的拦截器添加到全局或局部,取决于它是在 init
中调用还是在 init
之后调用:
- 在
init
之前: 对所有goroutine全局生效, - 在
init
之后: 仅对当前goroutine生效,并在当前goroutine退出后清除。
当 AddInterceptor()
在 init
之后调用时,它将返回一个dispose函数,用于在当前goroutine退出之前提前清除拦截器。
示例:
func main(){
clear := trap.AddInterceptor(...)
defer clear()
...
}
Trap还有一个名为 Direct(fn)
的辅助函数,可用于绕过任何trap和模拟拦截器,直接调用原始函数。
工具
测试资源管理器
xgo e
子命令将在浏览器中打开一个测试资源管理器UI,提供一种简单的方法来测试和调试go代码。
使用测试资源管理器时,会使用 xgo test
而不是 go test
,以启用模拟功能。
$ xgo e
服务器监听于 http://localhost:7070
注:xgo e
是 xgo tool test-explorer
的别名。
如需帮助,运行 xgo e help
。
有关配置详情,请参阅 doc/test-explorer/README.md。
增量覆盖率
xgo tool coverage
子命令扩展了go内置的 go tool cover
,以实现更好的可视化。
首先,运行 go test
或 xgo test
获取覆盖率配置文件:
go test -cover -coverpkg ./... -coverprofile cover.out ./...
然后,使用 xgo
显示覆盖率:
xgo tool coverage serve cover.out
输出:
显示的覆盖率是覆盖率和git差异的组合。默认情况下,只显示修改的行:
- 已覆盖的行显示为浅蓝色,
- 未覆盖的行显示为浅黄色
这有助于快速定位未覆盖的更改,并逐步为它们添加测试。
并发安全
我知道你们使用其他猴子补丁库的人因这些框架隐含的不安全性而困扰。
但我向你保证,xgo中的模拟是内置的并发安全的。这意味着,你可以随心所欲地并发运行多个测试。
为什么?当你运行一个测试时,你设置了一些模拟,这些模拟只会影响运行测试的goroutine。当goroutine结束时,无论测试通过还是失败,这些模拟都会被清除。
想知道为什么吗?敬请期待,我们正在编写内部文档。
实现细节
正在进行中...
有关更多详情,请参阅 Issue#7。
这篇博客有一个基本解释: https://blog.xhd2015.xyz/posts/xgo-monkey-patching-in-go-using-toolexec
为什么选择 xgo
?
原因很简单:没有接口。
是的,没有接口,仅用于模拟。如果抽象接口的唯一原因是为了模拟,那只会让我感到无聊,而不是工作。
仅为模拟而提取接口对我来说从不是一个选项。对于问题的领域来说,它仅仅是一个变通方法。它强制代码以一种风格编写,这就是我们不喜欢它的原因。
猴子补丁简单地为问题做了正确的事。但现有的库在兼容性方面表现不佳。
所以我创建了 xgo
,希望它最终能取代其他解决模拟问题的方案。
将 xgo
与 monkey
进行比较
项目 bouk/monkey 最初由bouk创建,如他的博客 https://bou.ke/blog/monkey-patching-in-go 所述。
简而言之,它使用低级汇编黑客技术在运行时替换函数。随着它被越来越广泛地使用(尤其是在macOS上),这给用户带来了许多令人困惑的问题。
后来,作者本人将其存档并不再维护。然而,后来有两个项目接管了ASM的想法,并为较新的go版本和架构(如Apple M1)添加了支持。
尽管如此,这两个项目并没有解决ASM引入的底层兼容性问题,包括跨平台支持、需要写入执行代码的只读部分以及缺乏通用模拟。
因此,开发人员仍然时不时会遇到令人烦恼的故障。
Xgo通过避免对语言本身进行低级黑客攻击来解决这些问题。相反,它依赖于go编译器使用的IR表示。
它在编译器编译源代码时对IR(中间表示)进行所谓的"IR重写"。IR比机器代码更接近源代码。因此,它比monkey解决方案要稳定得多。
总之,xgo
和monkey的比较如下:
xgo | monkey | |
---|---|---|
技术 | IR | ASM |
函数模拟 | 是 | 是 |
未导出函数模拟 | 是 | 否 |
每实例方法模拟 | 是 | 否 |
每Goroutine模拟 | 是 | 否 |
每泛型类型模拟 | 是 | 是 |
变量模拟 | 是 | 否 |
常量模拟 | 是 | 否 |
闭包模拟 | 是 | 是 |
堆栈跟踪 | 是 | 否 |
通用陷阱 | 是 | 否 |
兼容性 | 无限制 | 限于amd64和arm64 |
API | 简单 | 复杂 |
集成工作量 | 容易 | 困难 |
贡献
想要帮助贡献 xgo
吗?太好了!查看 CONTRIBUTING
获取帮助。
xgo
的演变
xgo
是原始 go-mock 的继任者,后者通过在编译前重写go代码来工作。
go-mock
采用的策略运行良好,但由于源代码膨胀,导致大型项目的构建时间大大延长。
然而,go-mock
在发现Trap、Trace和Mock之外的功能方面非常出色,还具有额外的能力,如捕获变量和禁用map随机性。
它是 xgo
所站立的肩膀。