SubJSONOf operator allows to compare the JSON representation of
data against expectedJSON. Unlike JSON operator, marshaled data
must be a JSON object/map (aka {…}). expectedJSON can be a:
string containing JSON data like {"fullname":"Bob","age":42}
string containing a JSON filename, ending with “.json” (its
content is os.ReadFile before unmarshaling)
JSON data contained in expectedJSON must be a JSON object/map
(aka {…}) too. During a match, each expected entry should match in
the compared map. But some expected entries can be missing from the
compared map.
expectedJSON JSON value can contain placeholders. The params are
for any placeholder parameters in expectedJSON. params can contain
TestDeep operators as well as raw values. A placeholder can be
numeric like $2 or named like $name and always references an item
in params. params can also contains [encoding/json/v2.Options] when
[encoding/json/v2] must be used, see jsonv2 example.
Numeric placeholders reference the n’th “operators” item (starting
at 1). Named placeholders are used with Tag operator as follows:
td.Cmp(t,gotValue,td.SubJSONOf(`{"fullname": $name, "age": $2, "gender": $3}`,td.Tag("name",td.HasPrefix("Foo")),// matches $1 and $nametd.Between(41,43),// matches only $2"male"))// matches only $3
Note that placeholders can be double-quoted as in:
td.Cmp(t,gotValue,td.SubJSONOf(`{"fullname": "$name", "age": "$2", "gender": "$3"}`,td.Tag("name",td.HasPrefix("Foo")),// matches $1 and $nametd.Between(41,43),// matches only $2"male"))// matches only $3
It makes no difference whatever the underlying type of the replaced
item is (= double quoting a placeholder matching a number is not a
problem). It is just a matter of taste, double-quoting placeholders
can be preferred when the JSON data has to conform to the JSON
specification, like when used in a “.json” file.
SubJSONOf does its best to convert back the JSON corresponding to a
placeholder to the type of the placeholder or, if the placeholder
is an operator, to the type behind the operator. Allowing to do
things like:
Of course, it does this conversion only if the expected type can be
guessed. In the case the conversion cannot occur, data is compared
as is, in its freshly unmarshaled JSON form (so as bool, float64,
string, []any, map[string]any or simply nil).
To avoid a legit “$” string prefix causes a bad placeholder error,
just double it to escape it. Note it is only needed when the “$” is
the first character of a string:
td.Cmp(t,gotValue,td.SubJSONOf(`{"fullname": "$name", "details": "$$info", "age": $2}`,td.Tag("name",td.HasPrefix("Foo")),// matches $1 and $nametd.Between(41,43)))// matches only $2
For the “details” key, the raw value “$info” is expected, no
placeholders are involved here.
Note that Lax mode is automatically enabled by SubJSONOf operator to
simplify numeric tests.
Comments can be embedded in JSON data:
td.Cmp(t,gotValue,SubJSONOf(`
{
// A guy properties:
"fullname": "$name", // The full name of the guy
"details": "$$info", // Literally "$info", thanks to "$" escape
"age": $2 /* The age of the guy:
- placeholder unquoted, but could be without
any change
- to demonstrate a multi-lines comment */
}`,td.Tag("name",td.HasPrefix("Foo")),// matches $1 and $nametd.Between(41,43)))// matches only $2
Comments, like in go, have 2 forms. To quote the Go language specification:
line comments start with the character sequence // and stop at the
end of the line.
multi-lines comments start with the character sequence /* and stop
with the first subsequent character sequence */.
Other JSON divergences:
‘,’ can precede a ‘}’ or a ‘]’ (as in go);
strings can contain non-escaped \n, \r and \t;
raw strings are accepted (r{raw}, r!raw!, …), see below;
int_lit & float_lit numbers as defined in go spec are accepted;
numbers can be prefixed by ‘+’.
Most operators can be directly embedded in SubJSONOf without requiring
any placeholder. If an operators does not take any parameter, the
parenthesis can be omitted.
td.Cmp(t,gotValue,td.SubJSONOf(`
{
"fullname": HasPrefix("Foo"),
"age": Between(41, 43),
"details": SuperMapOf({
"address": NotEmpty, // () are optional when no parameters
"car": Any("Peugeot", "Tesla", "Jeep") // any of these
})
}`))
Placeholders can be used anywhere, even in operators parameters as in:
the optional 3rd parameter of Between has to be specified as a string
and can be: “[]” or “BoundsInIn” (default), “[[” or “BoundsInOut”,
“]]” or “BoundsOutIn”, “][” or “BoundsOutOut”;
the optional 3rd parameter of JSONPointer (opts) is filled by all
[encoding/json/v2.Options] values found in params, so it uses the
same marshal/unmarshal semantics as JSON itself. If that’s not
desirable, do not embed JSONPointer and use a placeholder instead;
It is also possible to embed operators in JSON strings. This way,
the JSON specification can be fulfilled. To avoid collision with
possible strings, just prefix the first operator name with
“$^”. The previous example becomes:
td.Cmp(t,gotValue,td.SubJSONOf(`
{
"fullname": "$^HasPrefix(\"Foo\")",
"age": "$^Between(41, 43)",
"details": "$^SuperMapOf({
\"address\": NotEmpty, // () are optional when no parameters
\"car\": Any(\"Peugeot\", \"Tesla\", \"Jeep\") // any of these
})"
}`))
As you can see, in this case, strings in strings have to be
escaped. Fortunately, newlines are accepted, but unfortunately they
are forbidden by JSON specification. To avoid too much escaping,
raw strings are accepted. A raw string is a “r” followed by a
delimiter, the corresponding delimiter closes the string. The
following raw strings are all the same as “foo\bar("zip")!”:
r’foo\bar"zip"!’
r,foo\bar"zip"!,
r%foo\bar"zip"!%
r(foo\bar(“zip”)!)
r{foo\bar(“zip”)!}
r[foo\bar(“zip”)!]
r<foo\bar(“zip”)!>
So non-bracketing delimiters use the same character before and
after, but the 4 sorts of ASCII brackets (round, angle, square,
curly) all nest: r[x[y]z] equals “x[y]z”. The end delimiter cannot
be escaped.
With raw strings, the previous example becomes:
td.Cmp(t,gotValue,td.SubJSONOf(`
{
"fullname": "$^HasPrefix(r<Foo>)",
"age": "$^Between(41, 43)",
"details": "$^SuperMapOf({
r<address>: NotEmpty, // () are optional when no parameters
r<car>: Any(r<Peugeot>, r<Tesla>, r<Jeep>) // any of these
})"
}`))
Note that raw strings are accepted anywhere, not only in original
JSON strings.
To be complete, $^ can prefix an operator even outside a
string. This is accepted for compatibility purpose as the first
operator embedding feature used this way to embed some operators.
As for placeholders, there is no differences between $^NotZero and
“$^NotZero”.
Tip: when an io.Reader is expected to contain JSON data, it
cannot be tested directly, but using the Smuggle operator simply
solves the problem:
varbodyio.Reader// …td.Cmp(t,body,td.Smuggle(json.RawMessage{},td.SubJSONOf(`{"foo":1,"bar":2}`)))// or equallytd.Cmp(t,body,td.Smuggle(json.RawMessage(nil),td.SubJSONOf(`{"foo":1,"bar":2}`)))
Smuggle reads from body into an encoding/json.RawMessage then
this buffer is unmarshaled by SubJSONOf operator before the comparison.
TypeBehind method returns the map[string]any type.
t:=&testing.T{}got:=&struct{Fullnamestring`json:"fullname"`Ageint`json:"age"`}{Fullname:"Bob",Age:42,}ok:=td.Cmp(t,got,td.SubJSONOf(`{"age":42,"fullname":"Bob","gender":"male"}`))fmt.Println("check got with age then fullname:",ok)ok=td.Cmp(t,got,td.SubJSONOf(`{"fullname":"Bob","age":42,"gender":"male"}`))fmt.Println("check got with fullname then age:",ok)ok=td.Cmp(t,got,td.SubJSONOf(`
// This should be the JSON representation of a struct
{
// A person:
"fullname": "Bob", // The name of this person
"age": 42, /* The age of this person:
- 42 of course
- to demonstrate a multi-lines comment */
"gender": "male" // This field is ignored as SubJSONOf
}`))fmt.Println("check got with nicely formatted and commented JSON:",ok)ok=td.Cmp(t,got,td.SubJSONOf(`{"fullname":"Bob","gender":"male"}`))fmt.Println("check got without age field:",ok)// Output:// check got with age then fullname: true// check got with fullname then age: true// check got with nicely formatted and commented JSON: true// check got without age field: false
Placeholders example
t:=&testing.T{}got:=&struct{Fullnamestring`json:"fullname"`Ageint`json:"age"`}{Fullname:"Bob Foobar",Age:42,}ok:=td.Cmp(t,got,td.SubJSONOf(`{"age": $1, "fullname": $2, "gender": $3}`,42,"Bob Foobar","male"))fmt.Println("check got with numeric placeholders without operators:",ok)ok=td.Cmp(t,got,td.SubJSONOf(`{"age": $1, "fullname": $2, "gender": $3}`,td.Between(40,45),td.HasSuffix("Foobar"),td.NotEmpty()))fmt.Println("check got with numeric placeholders:",ok)ok=td.Cmp(t,got,td.SubJSONOf(`{"age": "$1", "fullname": "$2", "gender": "$3"}`,td.Between(40,45),td.HasSuffix("Foobar"),td.NotEmpty()))fmt.Println("check got with double-quoted numeric placeholders:",ok)ok=td.Cmp(t,got,td.SubJSONOf(`{"age": $age, "fullname": $name, "gender": $gender}`,td.Tag("age",td.Between(40,45)),td.Tag("name",td.HasSuffix("Foobar")),td.Tag("gender",td.NotEmpty())))fmt.Println("check got with named placeholders:",ok)ok=td.Cmp(t,got,td.SubJSONOf(`{"age": $^NotZero, "fullname": $^NotEmpty, "gender": $^NotEmpty}`))fmt.Println("check got with operator shortcuts:",ok)// Output:// check got with numeric placeholders without operators: true// check got with numeric placeholders: true// check got with double-quoted numeric placeholders: true// check got with named placeholders: true// check got with operator shortcuts: true
File example
t:=&testing.T{}got:=&struct{Fullnamestring`json:"fullname"`Ageint`json:"age"`Genderstring`json:"gender"`}{Fullname:"Bob Foobar",Age:42,Gender:"male",}tmpDir,err:=os.MkdirTemp("","")iferr!=nil{t.Fatal(err)}deferos.RemoveAll(tmpDir)//nolint: errcheck // clean upfilename:=tmpDir+"/test.json"iferr=os.WriteFile(filename,[]byte(`
{
"fullname": "$name",
"age": "$age",
"gender": "$gender",
"details": {
"city": "TestCity",
"zip": 666
}
}`),0644);err!=nil{t.Fatal(err)}// OK let's test with this fileok:=td.Cmp(t,got,td.SubJSONOf(filename,td.Tag("name",td.HasPrefix("Bob")),td.Tag("age",td.Between(40,45)),td.Tag("gender",td.Re(`^(male|female)\z`))))fmt.Println("Full match from file name:",ok)// When the file is already openfile,err:=os.Open(filename)iferr!=nil{t.Fatal(err)}ok=td.Cmp(t,got,td.SubJSONOf(file,td.Tag("name",td.HasPrefix("Bob")),td.Tag("age",td.Between(40,45)),td.Tag("gender",td.Re(`^(male|female)\z`))))fmt.Println("Full match from io.Reader:",ok)// Output:// Full match from file name: true// Full match from io.Reader: true
args… are optional and allow to name the test. This name is
used in case of failure to qualify the test. If len(args) > 1 and
the first item of args is a string and contains a ‘%’ rune then
fmt.Fprintf is used to compose the name, else args are passed to
fmt.Fprint. Do not forget it is the name of the test, not the
reason of a potential failure.
t:=&testing.T{}got:=&struct{Fullnamestring`json:"fullname"`Ageint`json:"age"`}{Fullname:"Bob",Age:42,}ok:=td.CmpSubJSONOf(t,got,`{"age":42,"fullname":"Bob","gender":"male"}`,nil)fmt.Println("check got with age then fullname:",ok)ok=td.CmpSubJSONOf(t,got,`{"fullname":"Bob","age":42,"gender":"male"}`,nil)fmt.Println("check got with fullname then age:",ok)ok=td.CmpSubJSONOf(t,got,`
// This should be the JSON representation of a struct
{
// A person:
"fullname": "Bob", // The name of this person
"age": 42, /* The age of this person:
- 42 of course
- to demonstrate a multi-lines comment */
"gender": "male" // This field is ignored as SubJSONOf
}`,nil)fmt.Println("check got with nicely formatted and commented JSON:",ok)ok=td.CmpSubJSONOf(t,got,`{"fullname":"Bob","gender":"male"}`,nil)fmt.Println("check got without age field:",ok)// Output:// check got with age then fullname: true// check got with fullname then age: true// check got with nicely formatted and commented JSON: true// check got without age field: false
Placeholders example
t:=&testing.T{}got:=&struct{Fullnamestring`json:"fullname"`Ageint`json:"age"`}{Fullname:"Bob Foobar",Age:42,}ok:=td.CmpSubJSONOf(t,got,`{"age": $1, "fullname": $2, "gender": $3}`,[]any{42,"Bob Foobar","male"})fmt.Println("check got with numeric placeholders without operators:",ok)ok=td.CmpSubJSONOf(t,got,`{"age": $1, "fullname": $2, "gender": $3}`,[]any{td.Between(40,45),td.HasSuffix("Foobar"),td.NotEmpty()})fmt.Println("check got with numeric placeholders:",ok)ok=td.CmpSubJSONOf(t,got,`{"age": "$1", "fullname": "$2", "gender": "$3"}`,[]any{td.Between(40,45),td.HasSuffix("Foobar"),td.NotEmpty()})fmt.Println("check got with double-quoted numeric placeholders:",ok)ok=td.CmpSubJSONOf(t,got,`{"age": $age, "fullname": $name, "gender": $gender}`,[]any{td.Tag("age",td.Between(40,45)),td.Tag("name",td.HasSuffix("Foobar")),td.Tag("gender",td.NotEmpty())})fmt.Println("check got with named placeholders:",ok)ok=td.CmpSubJSONOf(t,got,`{"age": $^NotZero, "fullname": $^NotEmpty, "gender": $^NotEmpty}`,nil)fmt.Println("check got with operator shortcuts:",ok)// Output:// check got with numeric placeholders without operators: true// check got with numeric placeholders: true// check got with double-quoted numeric placeholders: true// check got with named placeholders: true// check got with operator shortcuts: true
File example
t:=&testing.T{}got:=&struct{Fullnamestring`json:"fullname"`Ageint`json:"age"`Genderstring`json:"gender"`}{Fullname:"Bob Foobar",Age:42,Gender:"male",}tmpDir,err:=os.MkdirTemp("","")iferr!=nil{t.Fatal(err)}deferos.RemoveAll(tmpDir)//nolint: errcheck // clean upfilename:=tmpDir+"/test.json"iferr=os.WriteFile(filename,[]byte(`
{
"fullname": "$name",
"age": "$age",
"gender": "$gender",
"details": {
"city": "TestCity",
"zip": 666
}
}`),0644);err!=nil{t.Fatal(err)}// OK let's test with this fileok:=td.CmpSubJSONOf(t,got,filename,[]any{td.Tag("name",td.HasPrefix("Bob")),td.Tag("age",td.Between(40,45)),td.Tag("gender",td.Re(`^(male|female)\z`))})fmt.Println("Full match from file name:",ok)// When the file is already openfile,err:=os.Open(filename)iferr!=nil{t.Fatal(err)}ok=td.CmpSubJSONOf(t,got,file,[]any{td.Tag("name",td.HasPrefix("Bob")),td.Tag("age",td.Between(40,45)),td.Tag("gender",td.Re(`^(male|female)\z`))})fmt.Println("Full match from io.Reader:",ok)// Output:// Full match from file name: true// Full match from io.Reader: true
Returns true if the test is OK, false if it fails.
args… are optional and allow to name the test. This name is
used in case of failure to qualify the test. If len(args) > 1 and
the first item of args is a string and contains a ‘%’ rune then
fmt.Fprintf is used to compose the name, else args are passed to
fmt.Fprint. Do not forget it is the name of the test, not the
reason of a potential failure.
t:=td.NewT(&testing.T{})got:=&struct{Fullnamestring`json:"fullname"`Ageint`json:"age"`}{Fullname:"Bob",Age:42,}ok:=t.SubJSONOf(got,`{"age":42,"fullname":"Bob","gender":"male"}`,nil)fmt.Println("check got with age then fullname:",ok)ok=t.SubJSONOf(got,`{"fullname":"Bob","age":42,"gender":"male"}`,nil)fmt.Println("check got with fullname then age:",ok)ok=t.SubJSONOf(got,`
// This should be the JSON representation of a struct
{
// A person:
"fullname": "Bob", // The name of this person
"age": 42, /* The age of this person:
- 42 of course
- to demonstrate a multi-lines comment */
"gender": "male" // This field is ignored as SubJSONOf
}`,nil)fmt.Println("check got with nicely formatted and commented JSON:",ok)ok=t.SubJSONOf(got,`{"fullname":"Bob","gender":"male"}`,nil)fmt.Println("check got without age field:",ok)// Output:// check got with age then fullname: true// check got with fullname then age: true// check got with nicely formatted and commented JSON: true// check got without age field: false
Placeholders example
t:=td.NewT(&testing.T{})got:=&struct{Fullnamestring`json:"fullname"`Ageint`json:"age"`}{Fullname:"Bob Foobar",Age:42,}ok:=t.SubJSONOf(got,`{"age": $1, "fullname": $2, "gender": $3}`,[]any{42,"Bob Foobar","male"})fmt.Println("check got with numeric placeholders without operators:",ok)ok=t.SubJSONOf(got,`{"age": $1, "fullname": $2, "gender": $3}`,[]any{td.Between(40,45),td.HasSuffix("Foobar"),td.NotEmpty()})fmt.Println("check got with numeric placeholders:",ok)ok=t.SubJSONOf(got,`{"age": "$1", "fullname": "$2", "gender": "$3"}`,[]any{td.Between(40,45),td.HasSuffix("Foobar"),td.NotEmpty()})fmt.Println("check got with double-quoted numeric placeholders:",ok)ok=t.SubJSONOf(got,`{"age": $age, "fullname": $name, "gender": $gender}`,[]any{td.Tag("age",td.Between(40,45)),td.Tag("name",td.HasSuffix("Foobar")),td.Tag("gender",td.NotEmpty())})fmt.Println("check got with named placeholders:",ok)ok=t.SubJSONOf(got,`{"age": $^NotZero, "fullname": $^NotEmpty, "gender": $^NotEmpty}`,nil)fmt.Println("check got with operator shortcuts:",ok)// Output:// check got with numeric placeholders without operators: true// check got with numeric placeholders: true// check got with double-quoted numeric placeholders: true// check got with named placeholders: true// check got with operator shortcuts: true
File example
t:=td.NewT(&testing.T{})got:=&struct{Fullnamestring`json:"fullname"`Ageint`json:"age"`Genderstring`json:"gender"`}{Fullname:"Bob Foobar",Age:42,Gender:"male",}tmpDir,err:=os.MkdirTemp("","")iferr!=nil{t.Fatal(err)}deferos.RemoveAll(tmpDir)//nolint: errcheck // clean upfilename:=tmpDir+"/test.json"iferr=os.WriteFile(filename,[]byte(`
{
"fullname": "$name",
"age": "$age",
"gender": "$gender",
"details": {
"city": "TestCity",
"zip": 666
}
}`),0644);err!=nil{t.Fatal(err)}// OK let's test with this fileok:=t.SubJSONOf(got,filename,[]any{td.Tag("name",td.HasPrefix("Bob")),td.Tag("age",td.Between(40,45)),td.Tag("gender",td.Re(`^(male|female)\z`))})fmt.Println("Full match from file name:",ok)// When the file is already openfile,err:=os.Open(filename)iferr!=nil{t.Fatal(err)}ok=t.SubJSONOf(got,file,[]any{td.Tag("name",td.HasPrefix("Bob")),td.Tag("age",td.Between(40,45)),td.Tag("gender",td.Re(`^(male|female)\z`))})fmt.Println("Full match from io.Reader:",ok)// Output:// Full match from file name: true// Full match from io.Reader: true