Quick answer: Test Go request decoding for exactly one document, unknown fields, colliding names, numeric precision, null and omission, and partial writes into the destination. A successful decode answers only the questions your decoder and target type actually enforce. Run negative and positive controls before passing the result to application code.
A client sends a valid order followed by another JSON value. The first call to Decode succeeds. Has the handler accepted a valid request, or just read the first value? That difference is where these tests start. See JSON in REST APIs for the broader contract.
The program below uses the standard-library encoding/json API, without importing encoding/json/v2. Each numbered section calls out a block in the same program. The examples use synthetic strings and Go 1.27.3; they do not exercise a live server or measure performance. Wikipedia's JSON overview provides format background; Go behavior follows the package reference.
1. Does the decoder accept exactly one request document?
A single Decode call reads the next JSON value, so it can succeed even if more input follows. When an endpoint expects one document, perform a second decode and accept only io.EOF. Test trailing whitespace, a second complete value, malformed trailing bytes, and an empty input so the endpoint treats each case deliberately.
The Decoder.Decode contract is useful for streams. An ordinary create-order endpoint usually has a narrower contract. The helper below adds that narrower rule without insisting that whitespace after the closing brace is an error. Its empty input is also rejected by the first decode, even though reaching EOF is expected on the second decode.
Use the same decoder for the follow-up read. A decoder can read ahead into its buffer, so a separate check on the underlying reader does not reliably tell you what the decoder still has available. Likewise, matching a literal error string such as “EOF” can accidentally accept a different error. The test compares the returned value with the documented io.EOF sentinel.
A newline between two objects does not make them a single JSON document. If the endpoint deliberately supports multiple records, document the framing and build a separate test for that protocol. Do not share the one-document helper with a stream consumer and silently drop the second record. A batch can still be a single document if it is represented as one array and the endpoint expects that shape.
2. Does a misspelled field fail instead of silently disappearing?
Decode a payload with a valid field and an unexpected field into a struct, both with and without DisallowUnknownFields. The default decoder ignores unknown struct members; the option reports them. Test the typed destination you actually use, because decoding into a map is a different contract and does not reject arbitrary names.
A typo such as qtyy is easy to miss when the correct qty is also present. The default decode can leave the handler with a plausible order and discard the extra member. That may be a deliberate compatibility policy on a read endpoint, but on a write endpoint it can hide a client mistake. Make the choice explicit in the contract rather than depending on whichever decoder happens to be instantiated.
The DisallowUnknownFields reference specifies the struct case. The map fixture is a useful control: it prevents the mistaken conclusion that turning on the option globally creates a whitelist for free-form objects. An endpoint can still allow an extension map inside an otherwise closed request; the names inside that map need a separate policy if the application restricts them.
When rolling out strict checking, keep real compatibility in mind. An older client may send a retired field, while a new client may send a field that an older server does not recognize. Test both directions if deployments overlap. Decide whether to reject the field, continue accepting it during a transition, or version the contract. The decoder option does not make that product decision.
3. Do repeated names and case variants follow your collision policy?
Test repeated known names and differently cased spellings of a struct field separately from unknown fields. Go's encoding/json can match field names without regard to case and process duplicate members in order. DisallowUnknownFields does not reject these collisions. If exact spelling or unique names matters, verify an explicit policy before trusting the decoded struct.
The Unmarshal documentation explains case matching and how later duplicate members can replace or merge with earlier values, depending on the destination. The fixture uses an integer field, so the later value replaces the earlier one. Do not generalize that observed result into a universal last-value rule for maps, nested structs, or custom unmarshallers.
This distinction matters when multiple components read the same request. A gateway might check one occurrence of a key while a service uses another. Even when each component can parse the text, they may disagree about what it means. Keep raw synthetic bytes for the test: once an ordinary decoder collapses repeated names, converting its output back into JSON cannot recover the original evidence. See the duplicate JSON keys guide for the interoperability consequences.
For exact names, test a different case even when the spelling otherwise looks correct. An application that rejects duplicates but permits case folding may still bind two distinct member names to one field. In a production contract that forbids this, choose a parser or a token-level policy that checks the original names, and include nested objects if the rule applies there. The example below exposes the acceptance; it does not implement that additional parser.
4. Do untyped numbers survive decoding without accidental rounding?
Decode a large integer into an interface value twice, first with default settings and then with UseNumber. The default float64 representation can round the value, while json.Number preserves the number text for later conversion. Validate the chosen numeric range and type after decoding, especially for identifiers that must retain exact digits.
The UseNumber reference limits the setting to numbers decoded into interface values. It does not reinterpret an explicit float64 struct field. The fixture uses 9007199254740993, which cannot be represented exactly by float64. It checks both the rounded value and the retained text, then verifies that an explicit Int64 conversion yields the intended integer.
Pick the destination based on what the field means. A quantity may have a documented integer range; a decimal amount may need a decimal representation and a scale rule; an identifier may be a string because nobody should perform arithmetic on it. UseNumber defers a choice but does not make it. Int64 conversion can fail for a fractional token or an out-of-range integer, and those failures should not be silently replaced by zero.
Compare outputs by type and value, not by how a logger formats them. A decimal rendering can obscure that a value was rounded or that an identifier became a floating-point number. The JSON precision guide covers interoperability across runtimes. Use the JSON Viewer to inspect a sanitized fixture's structure; use the Go assertion on the original bytes to verify exact digits.
5. Does the request distinguish null, omission, and a supplied zero?
Test an omitted field, an explicit null, and a supplied zero as three separate inputs. With a fresh ordinary pointer field, omission and null both result in nil, while a supplied zero gives a non-nil pointer. If the endpoint treats omission and null differently, track member presence separately and test that policy directly.
This is common in partial updates: a missing value can mean leave the record unchanged, while null can mean clear the field. Those meanings belong to the endpoint, not to the JSON decoder. The test uses a raw member map as a narrow presence control. A missing key is absent from the map; an explicit null leaves a raw token that can be checked before converting the member to its business type.
Use a fresh destination for each of these cases. A reused struct can retain a previous value when the next document omits that field. The final test group shows this separately so the null matrix does not confuse presence with stale state. Also check whether a complete body of null is allowed: a request for an object should not be accepted just because decoding null into a struct returns no error. The helper rejects that case by decoding into a pointer and checking the result.
6. Does a failed decode leave application state untouched?
A decode error does not guarantee that the destination stayed unchanged. Test a payload with a valid early field and an incompatible later value, then reject the result as a whole. Decode into a fresh temporary value and expose it to application code only after decoding, document checks, and business validation all succeed.
The package documents that a type mismatch can leave some fields populated. Do not depend on which later fields happen to be filled. The fixture checks the earlier valid field to demonstrate partial mutation, not to specify an error recovery format. It also shows why reusing a destination with an omitted member can retain stale data even on a successful decode.
Save this program as a Go file and run it with the Go toolchain. Its small checks panic on failure; a repository test suite should normally report named failures through Go's testing package. The helper is intentionally limited to a bounded synthetic string, so a server still needs to enforce its request size, deadline, media-type rules, and application validation before performing side effects.
package main
import (
"encoding/json"
"fmt"
"io"
"strings"
)
type order struct {
Qty int `json:"qty"`
}
func check(ok bool, label string) {
if !ok {
panic(label)
}
}
func oneOrder(text string) (*order, error) {
d := json.NewDecoder(strings.NewReader(text))
d.DisallowUnknownFields()
var result *order
if err := d.Decode(&result); err != nil {
return nil, err
}
if result == nil {
return nil, fmt.Errorf("expected an order object")
}
var extra any
if err := d.Decode(&extra); err != io.EOF {
return nil, fmt.Errorf("expected end of input; second decode: %v", err)
}
return result, nil
}
func main() {
// 1. Exactly one document.
got, err := oneOrder("{\"qty\":2} \n")
check(err == nil && got.Qty == 2, "one document")
for _, text := range []string{`{"qty":2} {}`, `{"qty":2} !`, "", "null"} {
_, err = oneOrder(text)
check(err != nil, "invalid document boundary")
}
// 2. Unknown struct fields versus open map names.
var loose order
check(json.Unmarshal([]byte(`{"qty":2,"qtyy":3}`), &loose) == nil && loose.Qty == 2, "loose struct")
_, err = oneOrder(`{"qty":2,"qtyy":3}`)
check(err != nil, "unknown field")
d := json.NewDecoder(strings.NewReader(`{"qtyy":3}`))
d.DisallowUnknownFields()
var open map[string]int
check(d.Decode(&open) == nil && open["qtyy"] == 3, "open map")
// 3. Known-name collisions.
for _, text := range []string{`{"qty":2,"qty":3}`, `{"qty":2,"QTY":3}`} {
got, err = oneOrder(text)
check(err == nil && got.Qty == 3, "later scalar wins")
}
// 4. Numeric conversion.
var n any
check(json.Unmarshal([]byte("9007199254740993"), &n) == nil, "default number")
check(int64(n.(float64)) == 9007199254740992, "rounded number")
d = json.NewDecoder(strings.NewReader("9007199254740993"))
d.UseNumber()
check(d.Decode(&n) == nil, "preserved number")
num := n.(json.Number)
integer, err := num.Int64()
check(num.String() == "9007199254740993" && err == nil && integer == 9007199254740993, "exact number")
// 5. Presence, null and zero.
for _, text := range []string{`{}`, `{"qty":null}`, `{"qty":0}`} {
var input struct {
Qty *int `json:"qty"`
}
var raw map[string]json.RawMessage
check(json.Unmarshal([]byte(text), &input) == nil, "pointer decode")
check(json.Unmarshal([]byte(text), &raw) == nil, "raw decode")
switch text {
case `{}`:
_, present := raw["qty"]
check(!present && input.Qty == nil, "omission")
case `{"qty":null}`:
check(string(raw["qty"]) == "null" && input.Qty == nil, "null")
default:
check(input.Qty != nil && *input.Qty == 0, "zero")
}
}
// 6. Destination mutation and reuse.
var partial struct {
Qty int
Code string
}
err = json.Unmarshal([]byte(`{"Qty":7,"Code":false}`), &partial)
check(err != nil && partial.Qty == 7, "partial mutation")
check(json.Unmarshal([]byte(`{}`), &partial) == nil && partial.Qty == 7, "stale value")
fmt.Println("All six Go decoder test groups passed")
}
Keep the fixture bytes and expected outcomes beside the adapter. The goal is to find where acceptance changes: syntax, document framing, decoding policy, destination state, or business rules. An unexpected result can then point to the exact boundary to investigate instead of looking like another generic invalid JSON error.