github.com/ipld/go-ipld-prime@v0.21.0/node/tests/testcase.go (about) 1 package tests 2 3 import ( 4 "bytes" 5 "errors" 6 "fmt" 7 "strings" 8 "testing" 9 10 qt "github.com/frankban/quicktest" 11 "github.com/polydawn/refmt/json" 12 "github.com/polydawn/refmt/shared" 13 14 "github.com/ipld/go-ipld-prime/codec" 15 "github.com/ipld/go-ipld-prime/codec/dagjson" 16 "github.com/ipld/go-ipld-prime/datamodel" 17 "github.com/ipld/go-ipld-prime/schema" 18 "github.com/ipld/go-ipld-prime/traversal" 19 ) 20 21 // This file introduces a testcase struct and a bunch of functions around it. 22 // This structure can be used to specify many test scenarios easily, using json as a shorthand for the fixtures. 23 // Not everything can be tested this way (in particular, there's some fun details around maps with complex keys, and structs with absent fields), but it covers a lot. 24 25 /* 26 testcase contains data for directing a sizable number of tests against a NodePrototype 27 (or more specifically, a pair of them -- one for the type-level node, one for the representation), 28 all of which are applied by calling the testcase.Test method: 29 30 - Creation of values using the type-level builder is tested. 31 - This is done using a json input as a convenient shorthand. 32 - n.b. this is optional, because it won't work for maps with complex keys. 33 - In things that behave as maps: this tests the AssembleEntry path (rather than AssembleKey+AssembleValue; this is the case because this is implemented using unmarshal codepaths). 34 - If this is expected to fail, an expected error may be specified (which will also make all other tests after creation inapplicable to this testcase). 35 - Creation of values using the repr-level builder is tested. 36 - This is (again) done using a json input as a convenient shorthand. 37 - At least *one* of this or the json for type-level must be present. If neither: the testcase spec is broken. 38 - As for the type-level test: in things that behave as maps, this tests the AssembleEntry path. 39 - If this is expected to fail, an expected error may be specified (which will also make all other tests after creation inapplicable to this testcase). 40 - If both forms of creation were exercised: check that the result nodes are deep-equal. 41 - A list of "point" observations may be provided, which can probe positions in the data tree for expected values (or just type kind, etc). 42 - This tests that direct lookups work. (It doesn't test iterators; that'll come in another step, later.) 43 - Pathing (a la traversal.Get) is used for this this, so it's ready to inspect deep structures. 44 - The field for expected value is just `interface{}`; it handles nodes, some primitives, and will also allow asserting an error. 45 - The node is *copied*, and deep-equal checked again. 46 - The purpose of this is to exercise the AssembleKey+AssembleValue path (as opposed to AssembleEntry (which is already exercised by our creation tests, since they use unmarshal codepaths)). 47 - Access of type-level data via iterators is tested in one of two ways: 48 - A list of expected key+values expected of the iterator can be provided explicitly; 49 - If an explicit list isn't provided, but type-level json is provided, the type-level data will be marshalled and compared to the json fixture. 50 - Most things can use the json path -- those that can't (e.g. maps with complex keys; structs with absent values -- neither is marshallable) use the explicit key+value system instead. 51 - Access of the representation-level data via interators is tested via marshalling, and asserting it against the json fixture data (if present). 52 - There's no explicit key+value list alternative here -- it's not needed; there is no data that is unmarshallable, by design! 53 54 This system should cover a lot of things, but doesn't cover everything. 55 56 - Good coverage for "reset" pathways is reached somewhat indirectly... 57 - Tests for recursive types containing nontrivial reset methods exercise both the child type's assembler reset method, and that the parent calls it correctly. 58 - Maps with complex keys are tricky to handle, as already noted above. 59 - But you should be able to do it, with some care. 60 - This whole system depends on json parsers and serializers already working. 61 - This is arguably an uncomfortably large and complex dependency for a test system. However, the json systems are tested by using basicnode; there's no cycle here. 62 - "Unhappy paths" in creation are a bit tricky to test. 63 - It can be done, but for map-like things, only for the AssembleEntry path. 64 - PRs welcome if someone's got a clever idea for a good way to exercise AssembleKey+AssembleValue. (A variant of unmarshaller implementation? Would do it; just verbose.) 65 - No support yet for checking properties like Length. 66 - Future: we could add another type-hinted special case to the testcasePoint.expect for this, i suppose. 67 */ 68 type testcase struct { 69 name string // name for the testcase. 70 typeJson string // json that will be fed to unmarshal together with a type-level assembler. marshal output will also be checked for equality. may be absent. 71 reprJson string // json that will be fed to unmarshal together with a representational assembler. marshal output will also be checked for equality. 72 expectUnmarshalFail error // if present, this error will be expected from the unmarshal process (and implicitly, marshal tests will not be applicable for this testcase). 73 typePoints []testcasePoint // inspections that will be made by traversing the type-level nodes. 74 reprPoints []testcasePoint // inspections that will be made by traversing the representation nodes. 75 typeItr []entry // if set, the type will be iterated in this way. The remarshalling and checking against typeJson will not be tested. This is used to probe for correct iteration over Absent values in structs (which needs special handling, because they are unserializable). 76 // there's really no need for an 'expectFail' that applies to marshal, because it shouldn't be possible to create data that's unmarshallable! (excepting data which is not marshallable by some *codec* due to incompleteness of that codec. But that's not what we're testing, here.) 77 // there's no need for a reprItr because the marshalling to reprJson always covers that; unlike with the type level, neither absents nor complex keys can throw a wrench in serialization, so it's always available to us to exercise the iteration code. 78 } 79 80 type testcasePoint struct { 81 path string 82 expect interface{} // if primitive: we'll AsFoo and assert equal on that; if an error, we'll expect an error and compare error types; if a kind, we'll check that the thing reached simply has that kind. 83 } 84 85 type entry struct { 86 key interface{} // (mostly string. not yet defined how this will handle maps with complex keys.) 87 value interface{} // same rules as testcasePoint.expect 88 } 89 90 func (tcase testcase) Test(t *testing.T, np, npr datamodel.NodePrototype) { 91 t.Run(tcase.name, func(t *testing.T) { 92 // We'll produce either one or two nodes, depending on the fixture; if two, we'll be expecting them to be equal. 93 var n, n2 datamodel.Node 94 95 // Attempt to produce a node by using unmarshal on type-level fixture data and the type-level NodePrototype. 96 // This exercises creating a value using the AssembleEntry path (but note, not AssembleKey+AssembleValue path). 97 // This test section is optional because we can't use it for some types (namely, maps with complex keys -- which simply need custom tests). 98 if tcase.typeJson != "" { 99 t.Run("typed-create", func(t *testing.T) { 100 n = testUnmarshal(t, np, tcase.typeJson, tcase.expectUnmarshalFail) 101 }) 102 } 103 104 // Attempt to produce a node by using unmarshal on repr-level fixture data and the repr-level NodePrototype. 105 // This exercises creating a value using the AssembleEntry path (but note, not AssembleKey+AssembleValue path). 106 // This test section is optional simply because it's nice to be able to omit it when writing a new system and not wanting to test representation yet. 107 if tcase.reprJson != "" { 108 t.Run("repr-create", func(t *testing.T) { 109 n3 := testUnmarshal(t, npr, tcase.reprJson, tcase.expectUnmarshalFail) 110 if n == nil { 111 n = n3 112 } else { 113 n2 = n3 114 } 115 }) 116 } 117 118 // If unmarshalling was expected to fail, the rest of the tests are inapplicable. 119 if tcase.expectUnmarshalFail != nil { 120 return 121 } 122 123 // Check the nodes are equal, if there's two of them. (Or holler, if none.) 124 if n == nil { 125 t.Fatalf("invalid fixture: need one of either typeJson or reprJson provided") 126 } 127 if n2 != nil { 128 t.Run("type-create and repr-create match", func(t *testing.T) { 129 qt.Check(t, n, NodeContentEquals, n2) 130 }) 131 } 132 133 // Perform all the point inspections on the type-level node. 134 if tcase.typePoints != nil { 135 t.Run("type-level inspection", func(t *testing.T) { 136 for _, point := range tcase.typePoints { 137 wishPoint(t, n, point) 138 } 139 }) 140 } 141 142 // Perform all the point inspections on the repr-level node. 143 if tcase.reprPoints != nil { 144 t.Run("repr-level inspection", func(t *testing.T) { 145 for _, point := range tcase.reprPoints { 146 wishPoint(t, n.(schema.TypedNode).Representation(), point) 147 } 148 }) 149 } 150 151 // Serialize the type-level node, and check that we get the original json again. 152 // This exercises iterators on the type-level node. 153 // OR, if typeItr is present, do that instead (this is necessary when handling maps with complex keys or handling structs with absent values, since both of those are unserializable). 154 if tcase.typeItr != nil { 155 // This can unconditionally assume we're going to handle maps, 156 // because the only kind of thing that needs this style of testing are some instances of maps and some instances of structs. 157 itr := n.MapIterator() 158 for _, entry := range tcase.typeItr { 159 qt.Check(t, itr.Done(), qt.IsFalse) 160 k, v, err := itr.Next() 161 qt.Check(t, k, closeEnough, entry.key) 162 qt.Check(t, v, closeEnough, entry.value) 163 qt.Check(t, err, qt.IsNil) 164 } 165 qt.Check(t, itr.Done(), qt.IsTrue) 166 k, v, err := itr.Next() 167 qt.Check(t, k, qt.IsNil) 168 qt.Check(t, v, qt.IsNil) 169 qt.Check(t, err, qt.Equals, datamodel.ErrIteratorOverread{}) 170 } else if tcase.typeJson != "" { 171 t.Run("type-marshal", func(t *testing.T) { 172 testMarshal(t, n, tcase.typeJson) 173 }) 174 } 175 176 // Serialize the repr-level node, and check that we get the original json again. 177 // This exercises iterators on the repr-level node. 178 if tcase.reprJson != "" { 179 t.Run("repr-marshal", func(t *testing.T) { 180 testMarshal(t, n.(schema.TypedNode).Representation(), tcase.reprJson) 181 }) 182 } 183 184 // Copy the node. If it's a map-like. 185 // This exercises the AssembleKey+AssembleValue path for maps (or things that act as maps, such as structs and unions), 186 // as opposed to the AssembleEntry path (which is what was exercised by the creation via unmarshal). 187 // Assumes that the iterators are working correctly. 188 if n.Kind() == datamodel.Kind_Map { 189 t.Run("type-create with AK+AV", func(t *testing.T) { 190 n3, err := shallowCopyMap(np, n) 191 qt.Check(t, err, qt.IsNil) 192 qt.Check(t, n, NodeContentEquals, n3) 193 }) 194 } 195 196 // Copy the node, now at repr level. Again, this is for exercising AssembleKey+AssembleValue paths. 197 // Assumes that the iterators are working correctly. 198 if n.(schema.TypedNode).Representation().Kind() == datamodel.Kind_Map { 199 t.Run("repr-create with AK+AV", func(t *testing.T) { 200 n3, err := shallowCopyMap(npr, n.(schema.TypedNode).Representation()) 201 qt.Check(t, err, qt.IsNil) 202 qt.Check(t, n3, NodeContentEquals, n) 203 }) 204 } 205 206 }) 207 } 208 209 func shallowCopyMap(np datamodel.NodePrototype, n datamodel.Node) (datamodel.Node, error) { 210 nb := np.NewBuilder() 211 ma, err := nb.BeginMap(n.Length()) 212 if err != nil { 213 return nil, err 214 } 215 for itr := n.MapIterator(); !itr.Done(); { 216 k, v, err := itr.Next() 217 if err != nil { 218 return nil, err 219 } 220 if v.IsAbsent() { 221 continue 222 } 223 if err := ma.AssembleKey().AssignNode(k); err != nil { 224 return nil, err 225 } 226 if err := ma.AssembleValue().AssignNode(v); err != nil { 227 return nil, err 228 } 229 } 230 if err := ma.Finish(); err != nil { 231 return nil, err 232 } 233 return nb.Build(), nil 234 } 235 236 func testUnmarshal(t *testing.T, np datamodel.NodePrototype, data string, expectFail error) datamodel.Node { 237 t.Helper() 238 nb := np.NewBuilder() 239 err := dagjson.Decode(nb, strings.NewReader(data)) 240 switch { 241 case expectFail == nil && err != nil: 242 t.Fatalf("fixture parse failed: %s", err) 243 case expectFail == nil && err == nil: 244 // carry on 245 case expectFail != nil && err != nil: 246 qt.Check(t, err, qt.ErrorAs, expectFail) 247 case expectFail != nil && err == nil: 248 t.Errorf("expected creation to fail with a %T error, but got no error", expectFail) 249 } 250 return nb.Build() 251 } 252 253 func testMarshal(t *testing.T, n datamodel.Node, data string) { 254 t.Helper() 255 // We'll marshal with "pretty" linebreaks and indents (and re-format the fixture to the same) for better diffing. 256 prettyprint := json.EncodeOptions{Line: []byte{'\n'}, Indent: []byte{'\t'}} 257 var buf bytes.Buffer 258 err := dagjson.Marshal(n, json.NewEncoder(&buf, prettyprint), dagjson.EncodeOptions{ 259 EncodeLinks: true, 260 EncodeBytes: true, 261 MapSortMode: codec.MapSortMode_Lexical, 262 }) 263 if err != nil { 264 t.Errorf("marshal failed: %s", err) 265 } 266 qt.Check(t, buf.String(), qt.Equals, reformat(data, prettyprint)) 267 } 268 269 func wishPoint(t *testing.T, n datamodel.Node, point testcasePoint) { 270 t.Helper() 271 reached, err := traversal.Get(n, datamodel.ParsePath(point.path)) 272 switch point.expect.(type) { 273 case error: 274 qt.Check(t, err, qt.ErrorAs, point.expect) 275 qt.Check(t, err, qt.Equals, point.expect) 276 default: 277 qt.Check(t, err, qt.IsNil) 278 if reached == nil { 279 return 280 } 281 qt.Check(t, reached, closeEnough, point.expect) 282 } 283 } 284 285 // closeEnough conforms to quicktest.Checker (so we can use it in quicktest invocations), 286 // and lets Nodes be compared to primitives in convenient ways. 287 // 288 // If the expected value is a primitive string, it'll AsStrong on the Node; etc. 289 // 290 // Using a datamodel.Kind value is also possible, which will just check the kind and not the value contents. 291 // 292 // If a datamodel.Node is the expected value, a full deep qt.Equals is used as normal. 293 var closeEnough = &closeEnoughChecker{} 294 295 var _ qt.Checker = (*closeEnoughChecker)(nil) 296 297 type closeEnoughChecker struct{} 298 299 func (c *closeEnoughChecker) ArgNames() []string { 300 return []string{"got", "want"} 301 } 302 303 func (c *closeEnoughChecker) Check(actual interface{}, args []interface{}, note func(key string, value interface{})) (err error) { 304 expected := args[0] 305 if expected == nil { 306 return qt.IsNil.Check(actual, args, note) 307 } 308 a, ok := actual.(datamodel.Node) 309 if !ok { 310 return errors.New("this checker only supports checking datamodel.Node values") 311 } 312 switch expected.(type) { 313 case datamodel.Kind: 314 return qt.Equals.Check(a.Kind(), args, note) 315 case string: 316 if a.Kind() != datamodel.Kind_String { 317 return fmt.Errorf("expected something with kind string, got kind %s", a.Kind()) 318 } 319 x, _ := a.AsString() 320 return qt.Equals.Check(x, args, note) 321 case int: 322 if a.Kind() != datamodel.Kind_Int { 323 return fmt.Errorf("expected something with kind int, got kind %s", a.Kind()) 324 } 325 x, _ := a.AsInt() 326 return qt.Equals.Check(x, args, note) 327 case datamodel.Node: 328 return qt.Equals.Check(actual, args, note) 329 default: 330 return fmt.Errorf("this checker doesn't support an expected value of type %T", expected) 331 } 332 } 333 334 func reformat(x string, opts json.EncodeOptions) string { 335 var buf bytes.Buffer 336 if err := (shared.TokenPump{ 337 TokenSource: json.NewDecoder(strings.NewReader(x)), 338 TokenSink: json.NewEncoder(&buf, opts), 339 }).Run(); err != nil { 340 panic(err) 341 } 342 return buf.String() 343 }