JSON是Go服务之间通信最常见的数据格式,但encoding/json包在使用过程中藏着不少容易踩的坑:字段名大小写不匹配导致反序列化为零值、浮点数经过一轮序列化后精度丢失、time.Time默认输出成RFC3339字符串而下游期待的是时间戳等等。这些问题如果只靠手动调用接口去验证,很难覆盖所有分支,最靠谱的方式是针对序列化和反序列化逻辑编写专门的单元测试。这篇文章就从字段映射验证、round-trip测试和边界场景三个角度,详细讲讲如何在Go项目里把JSON测试做扎实。

一、先验证struct tag与字段映射的正确性
JSON序列化的第一步是struct tag写对。Go的encoding/json默认使用字段名原样输出,所以绝大多数项目都会给每个字段加上json:"xxx"形式的tag。但tag写错的情况非常普遍:拼写错误、大小写不一致、tag后面忘加omitempty导致零值也输出、把json误写成Json导致整个tag失效。这些错误编译器完全不会提示,只有真正跑起来才会暴露。
最直接的验证方式是写一个"序列化快照"测试,把结构体序列化成JSON后与预期字符串做精确比对:
type User struct {
ID int64 `json:"id"`
Name string `json:"name"`
Email string `json:"email,omitempty"`
Birthday *time.Time `json:"birthday,omitempty"`
}
func TestUserMarshal(t *testing.T) {
birth := time.Date(1995, 6, 15, 0, 0, 0, 0, time.UTC)
u := User{ID: 1001, Name: "张三", Email: "zhangsan@ipipp.com", Birthday: &birth}
data, err := json.Marshal(u)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
want := `{"id":1001,"name":"张三","email":"zhangsan@ipipp.com","birthday":"1995-06-15T00:00:00Z"}`
if string(data) != want {
t.Errorf("got %s\nwant %s", data, want)
}
}
这种精确比对的好处是一旦字段顺序、命名、omitempty行为有任何变化,测试立刻失败,能逼着开发者正视每一次JSON结构的改动。缺点是结构体字段一多,预期字符串会变得很长。对于大型结构体,更推荐反着来:直接反序列化一个固定JSON串,逐字段断言,这样写出来的测试可读性更好,也能顺便验证字段名大小写的兼容性。值得注意的是,Go的json解析是大小写不敏感的,{"Name":"xx"}也能匹配到json:"name"的字段,如果业务上需要严格大小写,就不能只依赖默认行为,得自己实现UnmarshalJSON做校验。
二、用round-trip测试保证双向一致性
round-trip测试指的是先把对象序列化成JSON,再把这段JSON反序列化回对象,然后比对前后两个对象是否相等。这是验证序列化正确性最经济的方式,因为它不需要手写预期字符串,一份代码就能同时覆盖Marshal和Unmarshal两条路径。
func TestRoundTrip(t *testing.T) {
birth := time.Date(2000, 1, 2, 3, 4, 5, 0, time.UTC)
origin := User{ID: 42, Name: "李四", Email: "lisi@ipipp.com", Birthday: &birth}
data, err := json.Marshal(origin)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
var restored User
if err := json.Unmarshal(data, &restored); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if !reflect.DeepEqual(origin, restored) {
t.Errorf("round trip mismatch:\norigin=%+v\nrestored=%+v", origin, restored)
}
}
这里有一个关键细节:使用reflect.DeepEqual时,结构体内部不能包含不可比较的字段,比如map、slice嵌套在结构体里本身是允许的,DeepEqual可以处理,但如果字段是函数类型或者包含互斥锁,比对就会失效。另外,time.Time虽然包含时区信息,DeepEqual能正确处理相等时间,但如果序列化精度只到秒,而对象里带着纳秒,round-trip后纳秒就会丢失,测试会失败——这其实是好事,它提醒你时间精度在传输中被动裁剪了。
round-trip测试还有一个升级玩法:结合表驱动测试(table-driven test)批量验证多种输入。把正常值、边界值、空值、特殊字符统一放进用例表里循环执行,一套代码就能覆盖十几种场景,这在给老接口补测试时特别高效。如果项目中大量使用自定义的MarshalJSON和UnmarshalJSON方法,round-trip测试更是必不可少,因为这两个方法需要成对实现,任何一边写漏了都只有通过往返比对才能发现。
三、重点覆盖那些最容易出错的边界场景
常规数据测通之后,真正决定测试质量的是边界场景。第一个大坑是浮点数精度:json.Unmarshal到float64时,超大整数会丢失精度,比如int64的最大值9223372036854775807反序列化回来就变成了另一个数。第二个坑是零值与缺省字段无法区分:一个字段值是0和这个字段根本没传,反序列化后都是零值,如果业务需要区分,必须用指针类型或者sql.NullInt64这类包装类型,而这个决策本身也应该用测试固化下来。
func TestEdgeCases(t *testing.T) {
// 场景一:大整数精度
raw := `{"id":9223372036854775807}`
var m map[string]interface{}
_ = json.Unmarshal([]byte(raw), &m)
// 解析到 interface{} 后 id 会变成 float64,精度已丢失
t.Logf("id via interface{}: %v", m["id"])
var u User
if err := json.Unmarshal([]byte(raw), &u); err != nil {
t.Errorf("unmarshal int64 failed: %v", err)
}
if u.ID != math.MaxInt64 {
t.Errorf("int64 precision lost, got %d", u.ID)
}
// 场景二:字段缺失与零值
var u2 User
_ = json.Unmarshal([]byte(`{"id":0}`), &u2)
if u2.Name != "" {
t.Errorf("expect empty name, got %q", u2.Name)
}
if u2.Birthday != nil {
t.Error("nil pointer should stay nil when field is missing")
}
}
第三个值得测的是错误路径:传入格式非法的JSON、类型不匹配的数据(字符串塞给int字段)、数字超出字段类型范围等,Unmarshal会返回错误,但要确认返回的是你预期的错误,而不是被上层代码吞掉。特别注意Go的json解析有一条特性:遇到类型不匹配的字段时会返回UnmarshalTypeError,但已经解析的字段依然会被赋值,也就是说错误发生了数据却"半成功"了。如果你的业务要求要么全部成功要么全部失败,就必须在拿到错误后丢弃整个结果,并用测试把这条规则验证清楚。
最后,如果JSON来源不可信,还要测试未知字段的处理。默认情况下Unmarshal会静默忽略JSON里struct中不存在的字段,这在接口升级时通常是优点,但在需要严格校验的场景(比如支付回调)就变成了漏洞,此时应改用json.Decoder并调用DisallowUnknownFields()。针对这个行为同样应该写测试:传入带未知字段的JSON,断言Decoder返回错误。把这些边界场景都纳入测试范围后,你的JSON序列化逻辑才算真正经过了考验,后续无论是重构结构体还是升级Go版本,跑一遍测试就能确认数据格式没有发生任何意外变化。
Golang JSON测试序列化反序列化单元测试修改时间:2026-09-13 17:30:50