Skip to content

i18n-aware test selectors #2451

@lleifermann

Description

@lleifermann

Problem Description

Hey folks! I've been writing Vitest browser-mode tests for React components that use Lingui for i18n. I saw you already document a simple test wrapper that loads a catalog so translated strings actually render, that's great!

But it introduces a new problem: any copy change in my .po files can now break my tests.

Why data-testid isn't the answer

The obvious workaround is data-testid, but that defeats the purpose of accessible testing:

  • It's an invisible, meaningless attribute, a user can't see or interact with data-testid="save-btn". If the button loses its accessible name or role, your test still passes but the component is broken for real users.
  • It creates a parallel "test-only" contract that drifts from the actual UX contract. You end up testing that a div exists, not that a button exists.
  • You get zero accessibility coverage for free. You can ship a completely inaccessible component with a green test suite.

A concrete example

Here's a simple component:

// ProfileForm.tsx
import { Trans } from "@lingui/react/macro";

export function ProfileForm() {
  return (
    <form>
      <label htmlFor="name">
        <Trans>Full name</Trans>
      </label>
      <input id="name" type="text" />
      <button type="submit">
        <Trans>Update profile</Trans>
      </button>
    </form>
  );
}

And a well-written, accessible test:

// ProfileForm.test.tsx
it("should submit the form when clicking the save button", async () => {
  const screen = await render(<ProfileForm />);

  // ✅ Queries by role + accessible name — asserts real UX contract
  const saveButton = page.getByRole("button", { name: "Update profile" });
  await saveButton.click();

  // ... assert form submission
});

This test is correct, it validates what a real user (and assistive technology) sees.

But now a copywriter changes the .po file:

- msgstr "Update profile"
+ msgstr "Save changes"

Test breaks. Not because behavior changed, not because accessibility regressed, just because the label copy was updated. The test was asserting the right thing, but was coupled to a specific snapshot of the translation.

Proposed Solution

I can't shake the feeling that this is something that the i18n library should be concerned about.
Ideally lingui exposes a way to reference message descriptors in test selectors, so the expected string is resolved from the same catalog the component uses. Like this:

import { messages } from "./ProfileForm"; // or wherever descriptors live

it("should submit the form when clicking the save button", async () => {
  const screen = await render(<ProfileForm />);

  // ✅ Still asserts role + accessible name (real UX contract)
  // ✅ But the expected string comes from the catalog, not a hardcoded copy
  const saveButton = page.getByRole("button", {
    name: t(messages.updateProfile),
  });
  await saveButton.click();
});

Now when the .po file changes from "Update profile" → "Save changes", both the component and the test resolve the new string from the same source of truth. No test breakage, no loss of accessibility coverage.

I think Lingui could provide an opinionated testing utility for this. Additionally this would allow for some very sleek tests setup that generates test specs for each .po file! Automatically testing for each language the project defines, instead of hardcoding for a single language!

Alternatives Considered

Not applicable

Additional Context

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions