Every so often a test fails with a diff you cannot see. The expected value and the actual value are printed one above the other, they are character for character the same as far as your eyes are concerned, and the assertion still says they don't match. The usual reaction is to blame trailing whitespace, then an invisible control character, then the test runner, and eventually to add a workaround and move on. Often the real cause is that the two strings genuinely are different, and Unicode is perfectly happy about it. This post is about that difference, where it comes from, and why the obvious fix is the wrong place to apply it.
Unicode lets some characters be written in more than one way. The letter "é" can be a single code point, U+00E9, or it can be a plain "e" followed by a combining acute accent, U+0301. Both render identically. Both are correct. They are called canonically equivalent, which means they represent the same text but not the same sequence of code points.
The single code point version is called NFC, or composed form. The split version is NFD, or decomposed form. Written out with explicit escapes, the difference is obvious in a way it never is on screen, which is why every decomposed value in this post is written that way.
const nfc = 'caf\u00E9'; // composed: c a f é
const nfd = 'cafe\u0301'; // decomposed: c a f e + combining acute
console.log(nfc, nfd); // café café — identical on screen
console.log(nfc === nfd); // false
console.log(nfc.length, nfd.length); // 4 5
console.log(nfc.localeCompare(nfd)); // 0 — collation says they're equal
That last line is worth pausing on. localeCompare uses collation rules and reports the two strings as equal, while === compares code units and reports them as different. Your assertion library uses the second one. So does Set, Map, object keys, and every indexOf or includes call in your suite.
The length difference causes its own small disasters. Any code that truncates a string to a fixed number of characters can slice between the "e" and its accent, leaving a stray combining mark that attaches itself to whatever character follows it. Character counters disagree with what the user typed. Regular expressions anchored on a letter stop matching.
If everything in your stack produced NFC, none of this would matter. The problem is that text crosses boundaries, and different systems on either side of those boundaries made different choices a long time ago.
This is what makes the resulting failures look like flake rather than bugs. The form a string arrives in depends on which machine produced it, so the same test passes on your laptop and fails on a CI runner, or passes for eleven months and fails when someone regenerates a fixture on a different operating system. Nothing in the code changed. The bytes did.
File download verification is where most teams meet this for the first time, because filenames come from outside the browser and carry whatever form the producing system used.
test('exported report keeps its name', async ({ page }) => {
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Export' }).click();
const download = await downloadPromise;
// Green locally, red on a runner that hands back the decomposed name
expect(download.suggestedFilename()).toBe('Résumé_2026.pdf');
});
The reported diff shows two strings that print the same, which is exactly the kind of failure people rerun rather than investigate. The same trap catches getByText assertions against content that was seeded through an API, screenshot names built from user-supplied values, and any comparison between a database row and a value typed into a form.
For text you do not control, the fix is to compare canonical forms rather than raw code points. Normalizing both sides keeps the assertion strict about the thing you actually care about, which is the filename, while ignoring an encoding choice that was never yours to make.
const canon = (s) => s.normalize('NFC');
expect(canon(download.suggestedFilename())).toBe(canon('Résumé_2026.pdf'));
A wrong filename still fails. An accent spelled differently by the filesystem no longer does.
Reaching for that helper everywhere is tempting, and it is where this stops being a test hygiene topic and starts being a product one. Some of the mismatches your tests trip over are real defects, and normalizing inside the assertion hides them.
Consider a unique constraint on an email column. In PostgreSQL, the default collations are deterministic, which means equality is a byte comparison after encoding.
SELECT 'café' = E'cafe\u0301'; -- false
INSERT INTO users (email) VALUES ('josé@example.com');
INSERT INTO users (email) VALUES (E'jose\u0301@example.com');
-- Both succeed. UNIQUE (email) never fires.
Two accounts now exist for what every human involved will read as one address. Password resets go to one of them. Support searches find one of them. The audit log shows both. That is a duplicate account vulnerability, and a test that normalized before asserting would have sailed straight past it.
The same shape appears in search. A record seeded in one form is invisible to a query typed in the other, so "user creates a record then searches for it" fails as a test and, more importantly, fails for a real user who created their record on a phone and searched for it on a laptop.
The rule that resolves the tension is straightforward: normalize at the boundary where text enters the system, not at the boundary where you assert on it.
If the application normalizes user input on the way in, then everything downstream stores, compares, and indexes a single form, and your tests can assert strictly with no helper at all. If it doesn't, that is a defect worth filing rather than smoothing over. Which means the interesting test isn't the one that compares two names. It's the one that checks the boundary is doing its job.
test('the API normalizes names on write', async ({ request }) => {
const decomposed = 'Jose\u0301'; // what a phone keyboard might send
const created = await request.post('/api/users', { data: { name: decomposed } });
const { id } = await created.json();
const { name } = await (await request.get(`/api/users/${id}`)).json();
// Not "does it look right" — is it stored in the one form we committed to?
expect(name).toBe(name.normalize('NFC'));
expect(name).toBe('José');
});
Two assertions, two different jobs. The first pins the encoding contract. The second pins the value. Together they fail loudly if someone removes the normalization step, which is precisely the regression a normalizing assertion helper would have concealed.
Where the application can't be changed, PostgreSQL 12 and later can enforce the same idea at the storage layer with a nondeterministic collation, which treats canonically equivalent strings as equal for both comparison and uniqueness.
CREATE COLLATION email_ci (provider = icu, locale = 'und', deterministic = false);
ALTER TABLE users ALTER COLUMN email TYPE text COLLATE email_ci;
-- The second INSERT above now raises a unique violation.
It comes at a cost, since nondeterministic collations rule out some index optimisations, but it turns a silent duplicate into an error at the point it happens.
None of this needs a dedicated test suite. It needs a decomposed string in the places where text crosses a boundary.
Unicode normalization is a small thing that behaves like a big one because it breaks the assumption underneath every string assertion, which is that two things that look the same are the same. Once that assumption goes, failures stop being readable, and unreadable failures get reruns instead of investigations.
The useful habit isn't sprinkling normalize() through the suite. It's deciding which form your system stores, enforcing that decision at the point text arrives, and writing a test that says so out loud. After that, a failure with an invisible diff means what it should mean: something upstream stopped honouring the contract, and it is worth a look rather than a retry.