Files
errorx/error_test.go
T

411 lines
14 KiB
Go
Raw Normal View History

2025-10-26 20:32:02 +08:00
package errorx_test
import (
"context"
"errors"
"fmt"
"os"
"strings"
"sync"
2025-10-26 20:32:02 +08:00
"testing"
2025-10-26 20:58:12 +08:00
"github.com/yuninks/errorx"
2025-10-26 20:32:02 +08:00
"github.com/yuninks/langx"
)
// 测试用的错误常量:key + 业务码 + 默认文案(占位符用 #name#)
var (
ErrUserNotFound = errorx.New("test_user_not_found", 10001, "用户不存在")
ErrParamInvalid = errorx.New("test_param_invalid", 10002, "参数错误: #field#")
ErrDB = errorx.New("test_db_error", 50001, "数据库繁忙")
)
2025-10-26 20:32:02 +08:00
func TestMain(m *testing.M) {
2025-10-26 20:32:02 +08:00
langx.InitLangx(
langx.SetDefaultLanguage("zh"),
langx.SetDefaultCode(-1), // 故意设成 -1,用来验证未注册的 key 不会退化成默认码
2025-10-26 20:32:02 +08:00
)
langx.AppendTrans("en", map[string]string{
"test_user_not_found": "user not found",
"test_param_invalid": "invalid parameter: #field#",
"test_db_error": "database is busy",
2025-10-26 20:32:02 +08:00
})
os.Exit(m.Run())
}
2025-10-26 20:32:02 +08:00
func TestErrBasics(t *testing.T) {
e := ErrUserNotFound.Err()
if got, want := e.GetCode(), 10001; got != want {
t.Fatalf("GetCode() = %d, want %d", got, want)
}
if got, want := e.GetKey(), "test_user_not_found"; got != want {
t.Fatalf("GetKey() = %q, want %q", got, want)
}
if got, want := e.GetDefault(), "用户不存在"; got != want {
t.Fatalf("GetDefault() = %q, want %q", got, want)
}
if got, want := e.Error(), "用户不存在"; got != want {
t.Fatalf("Error() = %q, want %q", got, want)
}
// 错误常量本身也实现了 error 接口
var err error = ErrUserNotFound
if got, want := err.Error(), "用户不存在"; got != want {
t.Fatalf("ErrorLanguage.Error() = %q, want %q", got, want)
}
if got, want := ErrUserNotFound.GetCode(), 10001; got != want {
t.Fatalf("ErrorLanguage.GetCode() = %d, want %d", got, want)
}
if got, want := ErrUserNotFound.GetDefault(), "用户不存在"; got != want {
t.Fatalf("ErrorLanguage.GetDefault() = %q, want %q", got, want)
}
}
func TestPlaceholder(t *testing.T) {
cases := []struct {
name string
got string
want string
}{
{"ErrKV", ErrParamInvalid.ErrKV("field", "age").Error(), "参数错误: age"},
{"ErrWith", ErrParamInvalid.ErrWith(map[string]string{"field": "name"}).Error(), "参数错误: name"},
{"Err().WithFormatKV", ErrParamInvalid.Err().WithFormatKV("field", "id").Error(), "参数错误: id"},
{"MsgKV", ErrParamInvalid.MsgKV(context.Background(), "field", "phone"), "参数错误: phone"},
{"MsgWith", ErrParamInvalid.MsgWith(context.Background(), map[string]string{"field": "mail"}), "参数错误: mail"},
}
for _, c := range cases {
if c.got != c.want {
t.Errorf("%s = %q, want %q", c.name, c.got, c.want)
}
}
}
func TestMultiLanguage(t *testing.T) {
ctx := context.Background()
enCtx := langx.SetCtxLang(ctx, "en")
if got, want := ErrUserNotFound.Msg(ctx), "用户不存在"; got != want {
t.Fatalf("中文 Msg = %q, want %q", got, want)
}
if got, want := ErrUserNotFound.Msg(enCtx), "user not found"; got != want {
t.Fatalf("英文 Msg = %q, want %q", got, want)
}
if got, want := ErrParamInvalid.MsgKV(enCtx, "field", "age"), "invalid parameter: age"; got != want {
t.Fatalf("英文占位符 = %q, want %q", got, want)
}
// 固定语言:不再跟随 ctx
err := ErrUserNotFound.Err().WithLang("en")
if got, want := err.Msg(ctx), "user not found"; got != want {
t.Fatalf("WithLang 后 Msg = %q, want %q", got, want)
}
if got, want := err.Error(), "user not found"; got != want {
t.Fatalf("WithLang 后 Error = %q, want %q", got, want)
}
// 未注册的语言回退到默认语言,而不是吐裸 key
if got, want := ErrUserNotFound.Msg(langx.SetCtxLang(ctx, "ja")), "用户不存在"; got != want {
t.Fatalf("未注册语言 = %q, want %q", got, want)
}
}
func TestImmutability(t *testing.T) {
base := ErrParamInvalid.Err()
derived := base.WithFormatKV("field", "age")
if got, want := base.Error(), "参数错误: #field#"; got != want {
t.Fatalf("派生后原错误被修改:%q, want %q", got, want)
}
if got, want := derived.Error(), "参数错误: age"; got != want {
t.Fatalf("派生错误 = %q, want %q", got, want)
}
if got, want := ErrParamInvalid.Error(), "参数错误: #field#"; got != want {
t.Fatalf("全局常量被污染:%q, want %q", got, want)
}
// GetFormat 返回副本,外部修改不影响错误本身
f := derived.GetFormat()
f["field"] = "hacked"
if got, want := derived.Error(), "参数错误: age"; got != want {
t.Fatalf("GetFormat 泄漏了内部 map:%q, want %q", got, want)
}
// WithLang / WithFormat 不互相影响
a := base.WithLang("en")
b := base.WithFormatKV("field", "x")
if a.GetLang() != "en" || b.GetLang() != "" {
t.Fatalf("派生值互相污染:a.lang=%q b.lang=%q", a.GetLang(), b.GetLang())
}
}
func TestConcurrentUse(t *testing.T) {
// 同一个错误值被多个 goroutine 共享:旧版本要么 fatal: concurrent map writes,
// 要么不同请求之间互相改写文案。
base := ErrParamInvalid.Err()
const workers, rounds = 32, 50
var wg sync.WaitGroup
bad := make(chan string, workers)
for w := 0; w < workers; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
for r := 0; r < rounds; r++ {
v := fmt.Sprintf("%d-%d", w, r)
if got, want := base.WithFormatKV("field", v).Error(), "参数错误: "+v; got != want {
bad <- fmt.Sprintf("派生错误被串改: got %q, want %q", got, want)
return
}
if got, want := base.GetFormat()["field"], ""; got != want {
bad <- fmt.Sprintf("原错误被派生值污染: got %q, want %q", got, want)
return
}
if got, want := ErrUserNotFound.Error(), "用户不存在"; got != want {
bad <- fmt.Sprintf("全局常量被改写: got %q, want %q", got, want)
return
}
}
}(w)
}
wg.Wait()
close(bad)
for msg := range bad {
t.Fatal(msg)
}
}
func TestUnknownKeyIsNotSuccess(t *testing.T) {
e := errorx.NewError(context.Background(), "totally_unknown_key")
if got, want := e.GetCode(), errorx.ErrUnregistered.GetCode(); got != want {
t.Fatalf("未注册 key 的 code = %d, want %d", got, want)
}
if e.GetCode() == 200 {
t.Fatal("未注册的 key 被伪装成了成功(200)")
}
if !strings.Contains(e.Error(), "totally_unknown_key") {
t.Fatalf("未注册 key 的文案应包含原始 key,实际 %q", e.Error())
}
// 空 key 同样按未注册处理
if got, want := errorx.NewError(context.Background(), "").GetCode(), errorx.ErrUnregistered.GetCode(); got != want {
t.Fatalf("空 key 的 code = %d, want %d", got, want)
}
}
func TestRegistryOverwriteKeepsBuiltin(t *testing.T) {
// 用户习惯用 Register*,它会整体替换 langx 的注册表
langx.RegisterCode(map[string]int{"biz_code": 500})
langx.RegisterTrans("zh", map[string]string{"biz_code": "业务错误"})
defer func() {
langx.AppendCode(map[string]int{"error": 400, "success": 200, "error_with_msg": 400})
langx.AppendTrans("zh", map[string]string{
"error": "操作失败", "success": "操作成功", "error_with_msg": "操作失败: #msg#",
})
}()
// code 来自错误常量自身,不会被清空
if got, want := errorx.Error.Err().GetCode(), 400; got != want {
t.Fatalf("内置错误 code = %d, want %d(旧版本会变成 200)", got, want)
}
// 文案用错误常量自带的默认值兜底,不会退化成裸 key
if got, want := errorx.Error.Err().Error(), "操作失败"; got != want {
t.Fatalf("内置错误文案 = %q, want %q", got, want)
}
if got, want := errorx.ErrWithMsg.ErrKV("msg", "超时").Error(), "操作失败: 超时"; got != want {
t.Fatalf("内置错误占位符 = %q, want %q", got, want)
}
// 用户仍然可以用资源里的 key
if got, want := errorx.NewError(context.Background(), "biz_code").GetCode(), 500; got != want {
t.Fatalf("资源 key 的 code = %d, want %d", got, want)
2025-10-26 20:32:02 +08:00
}
}
2025-10-26 20:32:02 +08:00
func TestNilAndDirtyCtx(t *testing.T) {
if got, want := ErrUserNotFound.Msg(nil), "用户不存在"; got != want {
t.Fatalf("nil ctx = %q, want %q", got, want)
}
// langx 会对非 string 的 ctx 值做强制断言,errorx 必须兜住
dirty := context.WithValue(context.Background(), "Accept-Language", 123)
if got, want := ErrUserNotFound.Msg(dirty), "用户不存在"; got != want {
t.Fatalf("脏 ctx = %q, want %q", got, want)
}
if got, want := errorx.NewError(nil, "test_user_not_found").Error(), "用户不存在"; got != want {
t.Fatalf("nil ctx 动态构造 = %q, want %q", got, want)
}
}
2025-10-26 20:32:02 +08:00
func TestErrorsIsAsUnwrap(t *testing.T) {
cause := errors.New("dial tcp 127.0.0.1:3306: connection refused")
err := ErrDB.Wrap(cause)
2025-10-26 20:32:02 +08:00
if !errors.Is(err, ErrDB) {
t.Fatal("errors.Is(err, ErrDB) = false, want true")
}
if !errors.Is(err, cause) {
t.Fatal("errors.Is 应该能穿透到底层错误")
}
if errors.Is(err, ErrUserNotFound) {
t.Fatal("不同 key 的错误不应相等")
}
if got := errors.Unwrap(err); got != cause {
t.Fatalf("Unwrap() = %v, want %v", got, cause)
}
var ei errorx.ErrorInterface
if !errors.As(err, &ei) {
t.Fatal("errors.As(err, &ErrorInterface) = false, want true")
}
if ei.GetCode() != 50001 {
t.Fatalf("errors.As 拿到的 code = %d", ei.GetCode())
}
// 文案里不应出现底层错误的细节
if strings.Contains(err.Error(), "connection refused") {
t.Fatalf("底层错误不应出现在对外文案里:%q", err.Error())
}
// 常量在左边:错误常量直接当 error 返回时走 ErrorLanguage.Is
if !errors.Is(ErrUserNotFound, ErrUserNotFound.Err()) {
t.Fatal("errors.Is(常量, 错误值) = false, want true")
}
if errors.Is(ErrUserNotFound, ErrDB.Err()) {
t.Fatal("不同 key 的常量不应匹配")
}
if errors.Is(ErrUserNotFound, errors.New("plain")) {
t.Fatal("常量不应匹配非 errorx 的错误")
2025-10-26 20:32:02 +08:00
}
// 非 errorx 的普通错误不应匹配
if errors.Is(err, errors.New("boom")) {
t.Fatal("普通 error 不应匹配 errorx 错误")
}
}
2025-10-26 20:32:02 +08:00
func TestStackTrace(t *testing.T) {
st := ErrUserNotFound.Err().StackTrace()
if len(st) == 0 {
t.Fatal("StackTrace() 为空")
}
var found bool
for _, f := range st {
if strings.HasPrefix(f.Function, "github.com/yuninks/errorx.") {
t.Fatalf("栈里不应包含 errorx 自身帧:%s", f.Function)
}
if strings.Contains(f.Function, "TestStackTrace") {
found = true
}
}
if !found {
t.Fatal("栈里没有调用方帧")
}
if !strings.Contains(st.String(), "error_test.go") {
t.Fatalf("StackTrace.String() 内容异常:%s", st.String())
}
// 空栈渲染为空串,不 panic
var empty errorx.StackTrace
if got := empty.String(); got != "" {
t.Fatalf("空 StackTrace.String() = %q, want \"\"", got)
}
var f errorx.Frame
if !strings.Contains(f.String(), ":0") {
t.Fatalf("Frame.String() = %q", f.String())
}
}
func TestFormatVerb(t *testing.T) {
err := ErrDB.Wrap(errors.New("boom"))
2025-10-26 20:32:02 +08:00
if got, want := fmt.Sprintf("%v", err), "数据库繁忙"; got != want {
t.Fatalf("%%v = %q, want %q", got, want)
}
if got, want := fmt.Sprintf("%s", err), "数据库繁忙"; got != want {
t.Fatalf("%%s = %q, want %q", got, want)
}
if got, want := fmt.Sprintf("%q", err), "\"数据库繁忙\""; got != want {
t.Fatalf("%%q = %q, want %q", got, want)
}
plus := fmt.Sprintf("%+v", err)
if !strings.Contains(plus, "cause: boom") || !strings.Contains(plus, "stack:") {
t.Fatalf("%%+v 应包含 cause 与 stack,实际:%s", plus)
}
}
func TestFromRegistry(t *testing.T) {
langx.AppendCode(map[string]int{"test_registry_key": 777})
langx.AppendTrans("zh", map[string]string{"test_registry_key": "注册表错误: #msg#"})
e := errorx.NewErrorWith(context.Background(), "test_registry_key", map[string]string{"msg": "磁盘满了"})
if got, want := e.GetCode(), 777; got != want {
t.Fatalf("code = %d, want %d", got, want)
}
if got, want := e.Error(), "注册表错误: 磁盘满了"; got != want {
t.Fatalf("msg = %q, want %q", got, want)
}
// 构造时带上 ctx 语言
enCtx := langx.SetCtxLang(context.Background(), "en")
langx.AppendTrans("en", map[string]string{"test_registry_key": "registry error: #msg#"})
if got, want := errorx.NewErrorWith(enCtx, "test_registry_key", map[string]string{"msg": "full"}).Error(), "registry error: full"; got != want {
t.Fatalf("en msg = %q, want %q", got, want)
}
}
func TestResolveBranches(t *testing.T) {
// 资源里只有文案、没有 code:保留文案,code 用 CodeUnknown 兜底
langx.AppendTrans("zh", map[string]string{"test_msg_only": "只有文案"})
e := errorx.NewError(context.Background(), "test_msg_only")
if got, want := e.GetCode(), errorx.CodeUnknown; got != want {
t.Fatalf("只有文案时 code = %d, want %d", got, want)
}
if got, want := e.Error(), "只有文案"; got != want {
t.Fatalf("只有文案时 msg = %q, want %q", got, want)
}
// 资源里只有 code、没有文案:code 生效,文案退化为 key
langx.AppendCode(map[string]int{"test_code_only": 1234})
e2 := errorx.NewError(context.Background(), "test_code_only")
if got, want := e2.GetCode(), 1234; got != want {
t.Fatalf("只有 code 时 code = %d, want %d", got, want)
}
if got, want := e2.Error(), "test_code_only"; got != want {
t.Fatalf("只有 code 时 msg = %q, want %q", got, want)
}
}
func TestZeroValueAndEmptyKey(t *testing.T) {
var l errorx.ErrorLanguage
if l.GetCode() != 0 || l.GetKey() != "" {
t.Fatalf("零值访问器异常:code=%d key=%q", l.GetCode(), l.GetKey())
}
var err error = l
if err.Error() == "" {
t.Fatal("零值错误也应该能安全渲染")
}
if l.Err().Error() == "" {
t.Fatal("零值错误常量也应该能安全渲染")
}
}
func TestNewPanicsOnEmptyKey(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("空 key 应该 panic,避免注册出无法定位的错误")
}
}()
_ = errorx.New("", 1, "x")
}
func TestNewErrorWith(t *testing.T) {
langx.AppendCode(map[string]int{"test_dynamic": 901})
langx.AppendTrans("zh", map[string]string{"test_dynamic": "动态错误: #msg#"})
e := errorx.NewErrorWith(context.Background(), "test_dynamic", map[string]string{"msg": "磁盘满了"})
if got, want := e.GetCode(), 901; got != want {
t.Fatalf("code = %d, want %d", got, want)
}
if got, want := e.Error(), "动态错误: 磁盘满了"; got != want {
t.Fatalf("msg = %q, want %q", got, want)
}
// 带 ctx 语言
enCtx := langx.SetCtxLang(context.Background(), "en")
langx.AppendTrans("en", map[string]string{"test_dynamic": "dynamic error: #msg#"})
if got, want := errorx.NewErrorWith(enCtx, "test_dynamic", map[string]string{"msg": "full"}).Error(), "dynamic error: full"; got != want {
t.Fatalf("en msg = %q, want %q", got, want)
}
2025-10-26 20:32:02 +08:00
}