Skip to main content
ThunderLang
← All articles
ai-engineering

ASCII tests miss character-count bugs in byte limits

7 min read · 2026-11-11 · Allen Codewell

Summary slide: ASCII tests cannot prove a byte limit Define the contract. Test beyond ASCII. Reject rather than shorten.

ASCII tests cannot prove a byte limit. Define what gets counted, then test multibyte boundaries and verify that oversized input is rejected unchanged. Save the slide (PNG)

For example, with a 16-byte UTF-8 budget, eight precomposed é characters occupy 16 bytes and must pass; nine occupy 18 bytes and must fail. An incorrect character counter accepts both, even if it passes every ASCII boundary test.

ASCII-only tests cannot distinguish a byte limit from a character limit.

Before accepting a validation change, specify the encoding and normalization policy, then test inputs whose byte counts differ from their character counts. Include assertions that oversized text is rejected rather than shortened. The useful fixtures make competing interpretations produce different outcomes.

Decide what representation the limit measures

“Maximum length: 16” leaves several plausible implementations:

Intended limit What gets counted Consequence
Encoded bytes Bytes produced by a specified encoding Different characters consume different amounts of the budget.
Unicode code points Individual Unicode values A visible character can contain multiple code points.
User-perceived characters Grapheme clusters under a chosen segmentation rule A visible character can span multiple code points and many bytes.

A language's native string-length operation is not a substitute for choosing among these. Depending on the language, it can count bytes, code points, or code units.

For an encoded payload budget, I would use a byte contract. For a user-facing instruction such as “write a short display name,” I would first clarify whether a byte limit expresses the actual product requirement. A character-oriented limit and a storage-oriented limit can coexist, but they need separate checks.

A requirement-to-contract worksheet

Carry the 16-byte budget into an explicit validator contract:

Contract field Decision
Input A Unicode string, excluding unpaired surrogate values.
Encoding Strict UTF-8, with no byte-order mark added.
Normalization None. Measure the input exactly as received.
Acceptance Accept if and only if the encoded length is at most 16 bytes.
Success result Return an accepted result containing the unchanged input.
Failure result Return a rejected result with no accepted text value.
Oversized input Reject it. Do not truncate, replace characters, or retry with altered text.
Empty input Accept it under this size rule; any required-field rule is separate.

Implement this contract and its acceptance tests without changing the worksheet's decisions. That is a checkable requirement, unlike “validate length.”

Rejecting text and shortening text are different behaviors.

A shortened result might fit the budget while violating the requirement to preserve accepted input and reject oversized input.

Use fixtures that separate competing implementations

Save the following table as UTF-8 boundary fixtures. U+… identifies a Unicode code point, and “repeat” means concatenating the exact unit that many times. These identifiers avoid depending on how an editor displays or normalizes a pasted character.

The “Mistake exposed” column identifies the competing behavior each fixture helps distinguish. Read boundary pairs together, and assert returned values as well as acceptance decisions.

Fixture Exact unit Repeat Expected UTF-8 bytes Result Mistake exposed
Empty U+0061 (a) 0 0 Accept Treating the size rule as a required-field rule
ASCII below U+0061 15 15 Accept Rejecting valid input below the limit
ASCII boundary U+0061 16 16 Accept Using a strict less-than comparison
ASCII above U+0061 17 17 Reject Accepting one byte over the limit or truncating
Accented boundary U+00E9 (é) 8 16 Accept Rejecting valid multibyte input at the limit
Accented above U+00E9 9 18 Reject Counting code points or grapheme clusters instead of bytes
Emoji boundary U+1F600 (😀) 4 16 Accept Rejecting valid four-byte characters at the limit
Emoji above U+1F600 5 20 Reject Counting code points, grapheme clusters, or UTF-16 code units
Decomposed below U+0065 U+0301 (e plus combining acute) 5 15 Accept Rejecting valid combining sequences below the limit
Decomposed above U+0065 U+0301 6 18 Reject Counting characters or applying NFC before measurement
Normalization detector U+0065 U+0301 8 24 Reject Applying NFC before measurement

The arithmetic is explicit: ASCII a uses one byte, precomposed é uses two, and 😀 uses four. The decomposed unit uses three bytes: one for e and two for the combining acute accent.

Under NFC normalization, the last row's eight decomposed units become eight precomposed é characters, occupying 16 bytes. They would pass a normalize-first validator but must fail this contract.

Normalization can change an acceptance decision.

If normalization is desired, change the contract deliberately: specify the normalization form, measure after normalization, and define whether success returns the original or normalized text. Then update the expected fixtures.

Assert behavior, not just the calculated length

For every fixture, make the acceptance test check these outcomes:

  • Accepted input: the result reports acceptance, and its text equals the original code-point sequence.
  • Rejected input: the result reports rejection and contains no accepted text value.
  • All input: the validator does not modify the caller's input or silently substitute a shorter value.

A test that only checks output_bytes <= 16 is insufficient. A truncating implementation could satisfy that assertion while accepting something the contract requires it to reject.

If validation guards a write operation, add an integration test using a mock writer or isolated test resource. For a rejected fixture, assert that the writer is never called. For an accepted fixture, assert that it receives the unchanged text.

For multiple implementations, store the fixture table as one language-neutral data file, using code-point arrays, repeat counts, fixed expected byte counts, and expected decisions. Each language's test adapter should expand those same records and call its own validator. Do not regenerate expected decisions using the validator's length logic: that risks reproducing the same mistake on both sides of the assertion.

These fixtures distinguish specific mistakes, not every Unicode sequence or input path. Generated tests can add mixed strings and unusual combining sequences, but passing remains evidence rather than exhaustive verification. If serialization, database conversion, or another service transforms text or enforces a different limit, test that downstream boundary separately with isolated resources.

Start with four fixtures

Take one validation change and write its encoding, normalization policy, and rejection behavior above the tests. Add four fixtures: 16 ASCII characters, 17 ASCII characters, eight precomposed é characters, and nine precomposed é characters. Check both the decision and the returned text.

This five-minute exercise tests byte-versus-character counting and rejection behavior, but not normalization.

Save the field guide

Field guide: Check whether a byte limit really counts bytes

A field guide distilled from this article. The text version follows below.

Save the infographic (PNG)

Text version

  • The length rule is ambiguous: A requirement says maximum length without defining what gets counted. Choose bytes, code points, or user-perceived characters, then specify encoding and normalization so the rule is checkable.
  • Only ASCII boundaries pass: ASCII tests pass, but they cannot distinguish byte counting from character counting. Add eight and nine precomposed é characters for a 16-byte UTF-8 limit so the competing counters produce different decisions.
  • Normalization may change results: Eight decomposed e-plus-acute units use 24 bytes but become 16 bytes under NFC. Test this input against the chosen normalization policy because normalizing before measurement can change rejection into acceptance.
  • Output fits but may be shortened: An output-size assertion can pass even when the validator silently truncates input. Assert that accepted text matches the original and rejected results contain no accepted text value, so shortening cannot masquerade as rejection.

Illustrative 16-byte UTF-8 boundary: Eight precomposed é characters occupy 16 bytes and must pass; nine occupy 18 bytes and must fail. A character counter incorrectly accepts both.

These fixtures expose specific mistakes, not every Unicode sequence or input path. Test downstream boundaries separately if they transform text or enforce another limit.