AI News Hub Logo

AI News Hub

The Cypress i18n Mistake: Testing Words Instead of Meaning - i18next is your partner

DEV Community
Sebastian Clavijo Suero

A Better Way to Test Multilanguage Apps in Cypress Table of contents ACT 1: EXPOSITION The Problem With Copy-Pasting Translations What if Cypress Could Be Polyglot? Let Me Introduce You to i18next, If You Haven’t Met It Yet Where Should the Translations Live? Creating Small cy.initI18n() and cy.t() Commands What About Fallbacks? Running the Same Test Across Multiple Languages A Small Example With a Language Switcher: cy.changeLanguage() Should We Use Translated Text to Click Buttons? When Exact Text Actually Matters A Healthier Mental Model Interpolation: When Translations Need Dynamic Values Namespaces: When One Translation File Is Not Enough i18next Can Do More Than Just Interpolation ACT 3: RESOLUTION   There is a very common situation that appears when testing applications that support multiple languages. At first, everything looks innocent. cy.contains('Login').click() cy.contains('Welcome back').should('be.visible') Easy enough, right? Then, one day, the Product team says: “We are going international!” Spanish. So now you write: cy.contains('Iniciar sesión').click() cy.contains('Bienvenido de nuevo').should('be.visible') Easy enough! Then French arrives. Portuguese. German. Mandarin? After that someone from UX changes "Welcome back" to "Good to see you again". And of course, the Spanish translator decides that "Bienvenido de nuevo" should actually be "Qué bueno verte de nuevo". So your Cypress test fails, and then you ask yourself the existential automation question: Is my test failing because the application is broken, or because somebody "improved" the copy? And that, my QA friend, is where the fun begins. There are some articles about internationalization in Cypress, but surprisingly, not that many. And as a multicultural and multilingual person, I have always been interested in how, a well-designed multilingual website, can connect with people across languages, cultures, and contexts, and in doing so, create a much bigger impact.   The problem is not testing multilingual applications. Obviously, we absolutely should test them. The problem (and big mistake) starts when we copy-paste translated text directly into our Cypress tests and pretend that this is a stable strategy. This could be a typical test for creating a new project while also validating the messages shown to the user: cy.contains('Nuevo proyecto').click() cy.contains('Nombre del proyecto').type('Mi proyecto Cypress') cy.contains('Crear proyecto').click() cy.contains('Proyecto creado correctamente') .should('be.visible') This looks simple! Are we testing that the login flow works? Maybe all of the above? Do not get me wrong. Using cy.contains() with visible text is not evil at all. Actually, I like it a lot when it is used with intention. It makes tests readable, and in many cases, it reflects how users experience the application. But in a multilingual app, hardcoded translated strings can quickly become a maintenance nightmare. The test is no longer expressing the meaning of the user journey. It is expressing one particular version of the translated words at one particular moment in time. In other words: We are testing words instead of meaning.   A very common Cypress approach for testing the login user journey would look something like this: cy.visit('/login?lng=es') cy.contains('Iniciar sesión').click() cy.contains('Bienvenido de nuevo').should('be.visible') This works. The moment the Spanish translation changes, the test breaks, even if the application is working perfectly. Of course, sometimes this is exactly what we want. If the purpose of the test is to validate a very specific legal disclaimer, marketing text, warning message, or compliance-related copy, then yes, the exact words matter. But for most application flows, the exact words are not the real behavior. The app loads in Spanish. The login button is translated. The user can log in. The welcome message is shown in the selected language. That is the meaning. The translated string is only the visible representation of that meaning. So the real question is: How can Cypress verify the meaning without copy-pasting every translated word?   Imagine this. Instead of telling Cypress to find one exact Spanish sentence, we ask Cypress to find whatever welcome means in Spanish, and then we assert that the application shows it. Something like this: cy.t('auth.welcome', { lng: 'es' }).then((text) => { cy.contains(text) .should('be.visible') }) A command like cy.t('auth.welcome', { lng: 'es' }) would get the Spanish translation for the auth welcome message, and then we can use that value in the assertion. Now the test is not responsible for knowing the final translated sentence. The test only knows the meaning, and that, after all, is what is actually relevant for the test. The key: auth.welcome represents the meaning. The language: es represents the locale we want. And the translation system takes care of the rest. Definitely that would be a much better contract. The test would no longer be saying find "Bienvenido de nuevo", but find whatever auth.welcome means in Spanish.   If your application already uses i18next, then you probably already have the concepts we need. But, at a very basic level, i18next gives us two important things: A way to initialize the translation system. A way to resolve a translation key into actual text. The initialization happens with the i18next.init() method. This method receives a configuration object where we define things like the current language, fallback language, and translation resources: i18next.init(options, callback) // returns a Promise Something like this: i18next.init({ lng: 'es', // Current language fallbackLng: 'en', // Fallback language to use if message key not found resources: { en: { // English translations translation: { // "translation" is the default namespace (we will see namespaces later) auth: { login: 'Login', // Message auth.login in 'en' welcome: 'Welcome back' // Message auth.welcome in 'en' } } }, es: { // Spanish translations translation: { auth: { login: 'Iniciar sesión', // Message auth.login in 'en' welcome: 'Bienvenido de nuevo' // Message auth.welcome in 'en' } } } } }) The callback function can be used for example to inform in the console if something went wrong loading: i18next.init({ lng: 'es', fallbackLng: 'en', ... }, (err, t) => { if (err) return console.log('Something went wrong loading', err); t('key'); // -> same as i18next.t }); Then we can use the i18next.t() method to resolve a translation key into the corresponding text. In simple terms, the method t() receives a message key and an optional configuration object: i18next.t(key, options) For example: i18next.t('auth.welcome', { lng: 'es' }) So the idea is very simple: Let Cypress use the same translation mechanism that the application might already use.   Writing all translations directly inside i18next.init() could be very ugly, specially if we have a very large number of messages. Something like the .init example we used above may be fine for a tiny demo, but in a real application, it makes much more sense for translations to live in separate files. For example: // src/locales/en.json - English file { "auth": { "login": "Login", "welcome": "Welcome back" } } // src/locales/es.json - Spanish file { "auth": { "login": "Iniciar sesión", "welcome": "Bienvenido de nuevo" } } This is cleaner for a few reasons: First, maintenance: Translations change often. Keeping them in JSON files makes them easier to update without touching test logic. Second, abstraction: The test does not need to know every word in every language. It only needs to know the key that represents the meaning. Third, it works like a dictionary. To adding a third, fourth, or fifth language becomes much easier, and your tests can still work like a charm. You are basically saying "For this language, this key means this sentence." And that is exactly what we want. A translation file is our dictionary. Cypress should NOT become the dictionary.   Now let’s make this easier to use inside Cypress, of course, using custom commands! We can create two commands: cy.initI18n() to initialize the translation system. cy.t() to translate a key. They something like this: // cypress/support/commands.js import i18next from 'i18next' // Do not forget "npm install i18next" first :) import enMsgs from '../../src/locales/en.json' // English "dictionary" import esMsgs from '../../src/locales/es.json' // The Spanish one // cy.init() command will receive the exact same arguments as i18next.init() Cypress.Commands.add('initI18n', (options = {}, callback) => { return cy.wrap( i18next.init({ lng: 'en', // Use English as the default language if no language is provided fallbackLng: 'en', // The fallback language will be English resources: { en: { translation: enMsgs }, // Provide the English translations es: { translation: esMsgs }, // And the Spanish translations }, ...options, // Pass along any other options supported by i18next.init() }, callback), { log: false } // Do not show the wrap() command in the Cypress Log ) }) / cy.t() command will receive the exact same arguments as i18next.t() Cypress.Commands.add('t', (key, options = {}) => { // Get the translation for the provided key in the currently configured // language, and apply any i18next options if provided. return cy.wrap(i18next.t(key, options), { log: false }) }) Now our tests can use this: describe('Login page', () => { beforeEach(() => { // Initialize our polyglot Cypress system and set Spanish as current language cy.initI18n({ lng: 'es' }) cy.visit('/login?lng=es') }) it('Shows the login experience in Spanish', () => { cy.t('auth.login').then((loginText) => { cy.contains(loginText).click() }) cy.t('auth.welcome').then((welcomeText) => { cy.contains(welcomeText).should('be.visible') }) }) }) This is much better! And if the Spanish translation changes from "welcome": "Bienvenido de nuevo" to "welcome": "Qué bueno verte de nuevo", then no drama. We change the message in the corresponding translation file, and the test itself does not need to change because the meaning did not change. Only the wording changed, and wording is exactly what translation files are supposed to manage.   Another nice thing about using i18next is that we can also use its language fallback behavior. A fallback is basically what happens when the translation you asked for does not exist in the selected language. For example, when we initialize our message system like this: cy.initI18n({ lng: 'es', // Selected language is Spanish fallbackLng: 'en' // Fallback language is English }) This means that if the Spanish translation is missing, try English. This can be useful when the application itself behaves this way. And that last sentence is important: Your Cypress configuration should reflect your application behavior. If your app falls back to English, your Cypress translation helper should also fall back to English. If your app does not allow missing translations, your test should probably be stricter. We can also resolve the key using a specific language directly from the cy.t() command, regardless of the language currently initialized: cy.t('auth.forgotPassword', { lng: 'es' }) Or use a default value when the key cannot be resolved: // Passing as a second argument cy.t('my.key', 'This is the default value'); // Or within the options object cy.t('auth.forgotPassword', { defaultValue: 'Forgot password?' }) Or even better, instead of hardcoding a default value in the test, you can provide an array of fallback keys as the first argument. If the first key cannot be resolved, i18next will try the next one, and so on: cy.t(['auth.forgotPassword', 'common.help']); The important part is that our custom cy.t() command does not need to know every possible option. It simply passes the options to i18next.t(), because it has been defined like this: Cypress.Commands.add('t', (key, options = {}) => { return cy.wrap(i18next.t(key, options), { log: false }) }) We can use the power of i18next without reinventing it inside Cypress, which is always nice. Because reinventing things inside Cypress tests is how many horror stories begin.   Once we have this setup, testing multiple languages becomes much cleaner. Check out this code: // Supported languages const languages = ['en', 'es', 'fr'] // Iterate over all supported languages languages.forEach((lng) => { describe(`Login page in ${lng}`, () => { beforeEach(() => { cy.initI18n({ lng }) cy.visit(`/login?lng=${lng}`) }) it(`Shows the translated login experience for language ${lng}`, () => { cy.t('auth.login').then((loginText) => { cy.contains(loginText).click() }) cy.t('auth.welcome').then((welcomeText) => { cy.contains(welcomeText).should('be.visible') }) }) }) }) We are not duplicating the same test with different hardcoded strings. Instead we are expressing the real intention: For each supported language, the login page should render the expected translated content. It is cleaner. And most importantly, that is easier to maintain when your application inevitably keeps changing. Because it will. Applications always change.   To change the current language in i18next without re-initializing, we can use the i18next.changeLanguage() method. This method is designed to switch languages at runtime and will automatically trigger a re-render of components if you are using bindings like react-i18next. i18next.changeLanguage(lng, callback) // returns a Promise We can use i18next.changeLanguage() through a new Cypress custom command, cy.changeLanguage(): // cy.changeLanguage() command will receive the exact same arguments as i18next.changeLanguage() Cypress.Commands.add('changeLanguage', (lng, callback) => { return cy.wrap( i18next.changeLanguage(lng, callback), { log: false } ) }) Then running the same tests across multiple languages would be something like this: const languages = ['en', 'es', 'fr'] before(() => { cy.initI18n({ lng: 'en' }) // Initialize i18next once, using English by default }) languages.forEach((lng) => { beforeEach(() => { cy.changeLanguage(lng) cy.visit(`/login?lng=${lng}`) }) it(``Shows the translated login experience for language ${lng}`e`, () => { cy.t('auth.login').then((loginText) => { cy.contains(loginText).click() }) cy.t('auth.welcome').then((welcomeText) => { cy.contains(welcomeText).should('be.visible') }) }) }) No need to execute the full cy.initI18n() process for each language. We initialize it once in the before() hook, and then we simply switch the current language in the beforeEach() hook before each test.   Well... It depends. I know, I know. That is the classic "let me sit comfortably on the fence" answer. But hear me out. If your intention is to verify that the translated button text appears on the page, then using the translated value makes sense: cy.t('auth.login').then((loginText) => { cy.contains(loginText).should('be.visible') }) But if your intention is simply to interact with the login button and continue the test flow, then a stable selector like data-cy or data-testid, IMO, is usually a better option: cy.get('[data-cy="login-button"]').click() This is the distinction I personally like: Use selectors to interact with the elements in the DOM. Use translations to verify the localized user experience. In this case: cy.get('[data-cy="login-button"]').click() cy.t('auth.welcome').then((welcomeText) => { cy.contains(welcomeText).should('be.visible') }) the test does not depend on the button text to perform the click. But it still verifies that the expected translated welcome message appears. That is a nice balance. And as we know, balance is usually where maintainable test automation lives. Somewhere between chaos and overengineering. Remember: Do not hide bad selectors behind i18n!   Now, before someone sharpens their keyboard in the comments, let me clarify something. I never said: never assert exact translated text. That would be too extreme. And extreme rules in testing usually age like milk. There are cases where exact text absolutely matters: Legal disclaimers Error messages with regulatory requirements Payment warnings Accessibility instructions Medical, financial, or security-related messages Marketing copy that must be approved Any text where the exact wording is the actual requirement In those cases, exact matching is not only valid, it is absolutely a must. If this is the approved legal text: const expectedLegalText = 'By continuing, you agree to the Terms and Conditions.' Then we should explicitly verify the full text: cy.get('[data-cy="legal-disclaimer"]') .should('have.text', expectedLegalText) This test is clearly saying and the exact wording matters here. And if you still want that text to come from the translation system, you can combine both ideas: cy.t('legal.termsAndConditions').then((expectedLegalText) => { cy.get('[data-cy="legal-disclaimer"]') .should('have.text', expectedLegalText) }) The important part is the intention. There is a big difference.   A multilingual Cypress test should not try to prove every word in every language on every screen. That sounds heroic, but it usually becomes slow, noisy, and painful to maintain. A better multilingual test answers questions like: Did the application load the expected language? Did the important user-facing messages come from the right translation keys? Can the user complete the main flow in that language? Does the app still behave correctly when the locale changes? That is much more valuable than copy-pasting 200 translated strings into a spec file and hoping nobody touches them. Because they will be touched. They always are!   So far, our translations have been static, but real applications love dynamic text. For example: 'Welcome, Sebastian' or Invoice 1234 was created successfully In i18next, this is usually handled with interpolation. That means the translation has placeholders, and we provide the values later. For example: { "auth": { "welcomeUser": "Welcome, {{name}}" }, "invoice": { "created": "Invoice {{invoiceId}} was created successfully" } } Then in Cypress, we do not need to change our custom command. Since cy.t() already passes the options object directly to i18next.t(), any interpolation values we provide are handled by i18next automatically. cy.t('auth.welcomeUser', { name: 'Sebastian' }).then((text) => { cy.contains(text).should('be.visible') }) That resolves to: Welcome, Sebastian And you can also pass the desired language along with the interpolation values: cy.t('invoice.created', { invoiceId: 1234, lng: 'es' }).then((text) => { cy.contains(text).should('be.visible') }) It would resolve to whatever the Spanish translation for invoice.created is, with {{invoiceId}} replaced by 1234. It is also possible to provide positional values if your app also uses them: { "checkout": { "step": "Step {{0}} of {{1}}" } } Then: cy.t('checkout.step', { 0: 2, 1: 5 }).then((text) => { cy.contains(text).should('be.visible') }) That resolves to: Step 2 of 5 The nice part is that our cy.t() command does not need to know if the translation is static, interpolated, simple, or complex. It simply delegates to i18next. Use the translation library for translation logic. Use Cypress for testing. Everyone stays in their lane.   As applications grow, one giant translation file can become painful. Very painful. For that i18next supports namespaces. A namespace lets you split translations by area, domain, or feature. For example: src/locales/en/auth.json src/locales/en/common.json src/locales/es/auth.json src/locales/es/common.json English auth.json: { "login": "Login", "welcome": "Welcome back" } English common.json: { "save": "Save", "cancel": "Cancel" } Then we can initialize our 'polyglot Cypress' like this: import enAuth from '../../src/locales/en/auth.json' import enCommon from '../../src/locales/en/common.json' import esAuth from '../../src/locales/es/auth.json' import esCommon from '../../src/locales/es/common.json' cy.init({ lng: en, resources: { en: { auth: enAuth, // namespace auth (English) common: enCommon, // namespace common (English) }, es: { auth: esAuth, // namespace auth (Spanish) common: esCommon, // namespace common (Spanish) } } // This tells which namespace to by default when not provided one in .t() defaultNS: 'common', // It is also good practice to list all available namespaces ns: ['auth', 'common'] }) Here, auth is the default namespace, that means: cy.t('login') will look in the auth namespace by default. But if we want something from common namespace instead, we will do this: cy.t('save', { ns: 'common' }).then((text) => { cy.contains(text).should('be.visible') }) Or use the namespace prefix format: cy.t('common:save').then((text) => { cy.contains(text).should('be.visible') }) Although both approaches can work, personally, I like using the namespace prefix style: cy.t('save', { ns: 'common' }) But the most important thing is consistency. Pick one style. Future you will be grateful... probably. 😉   Interpolation is only one part of what i18next can do. It also supports other features that can be useful in real applications, such as: Formatting: useful for numbers, and values that need locale-specific formatting. Plurals: when text changes depending on quantity, like "1 item" vs "5 items". Context: useful when the translation changes depending on additional context, such as tone, audience, or grammatical differences. And I will repeat one more time: The nice thing is that our Cypress command does not need special logic for each one, since we pass the options directly to i18next.t()! 😉 If you want to become an i18next master and explore all its options and methods (there are a lot!) check the official site: https://www.i18next.com/   Testing multilingual applications is not just about running the same test in different languages. It is about deciding what your test should actually care about. If your test hardcodes every translated string, you may end up with a suite that fails every time copy changes, even when the product works perfectly. But if your test uses the same i18next translation keys as the application, your assertions become closer to the real meaning of the product. The key idea is simple: Test the meaning, not the hardcoded words. Use stable selectors for interactions. Use interpolation, namespaces, fallbacks, plurals, formatting, and context through i18next, not through custom Cypress reinventions. That small shift will make your multilingual Cypress tests cleaner, less repetitive, and much easier to maintain. So, the next time you are about to copy-paste a Spanish, French, Portuguese, German, or Klingon translation into your Cypress spec, pause for a second and ask yourself: Am I testing the product behavior, or am I just testing today’s wording? Because in multilingual testing, words change. Meaning should not. Cheers! I'd love to hear from you! Please don't forget to follow me, leave a comment, or a reaction if you found this article useful or insightful. ❤️ 🦄 🤯 🙌 🔥 👉 My LinkedIn: linkedin.com/in/sebastianclavijosuero github.com/sclavijosuero YouTube channel: youtube.com/@SebastianClavijoSuero If you are feeling especially generous and enjoy my articles, you can buy me a coffee or contribute to a training session. In both cases, my brain will definitely thank you for it! ☕😄