A Better Way to Test Multilanguage Apps in Cypress
Table of contents
- ACT 1: EXPOSITION
-
ACT 2: CONFRONTATION
- 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()andcy.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
ACT 1: EXPOSITION
There is a very common situation that appears when testing applications that support multiple languages.
At first, everything looks innocent.
You have an application in English, so you write something like this:
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!”
And suddenly your application also supports Spanish.
So now you write:
cy.contains('Iniciar sesión').click()
cy.contains('Bienvenido de nuevo').should('be.visible')
Easy enough!
Then French arrives.
Then Portuguese.
Then German.
And how about 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.
ACT 2: CONFRONTATION
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!
But what are we really testing?
Are we testing that the login flow works?
Are we testing that the _Spanish _translation exists?
Are we testing that the exact copy has not changed?
Are we testing that the translator did not open the thesaurus with too much confidence?
Maybe all of the above?
That is the problem.
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.
The Problem With Copy-Pasting Translations
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.
Until it does not! 🤔
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 behavior looks something like:
- 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?
What if Cypress Could Be Polyglot?
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.
Let Me Introduce You to i18next, If You Haven’t Met It Yet
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.
Where Should the Translations Live?
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 ask the dictionary.
Cypress should NOT become the dictionary.
Creating Small cy.initI18n() and cy.t() Commands
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.
What About Fallbacks?
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.
Running the Same Test Across Multiple Languages
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.
It is less code.
It is more scalable.
And most importantly, that is easier to maintain when your application inevitably keeps changing. Because it will.
Applications always change.
That is their favorite hobby.
A Small Example With a Language Switcher: cy.changeLanguage()
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.
Should We Use Translated Text to Click Buttons?
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!
When Exact Text Actually Matters
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.
But then make it intentional.
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.
The problem is not text assertions.
The problem is accidental text assertions.
There is a big difference.
A Healthier Mental Model
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!
Again... that is their favorite hobby. 😄
Interpolation: When Translations Need Dynamic Values
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.
Namespaces: When One Translation File Is Not Enough
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.
And stick to the plan!
Future you will be grateful... probably. 😉
i18next Can Do More Than Just Interpolation
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/
ACT 3: RESOLUTION
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 translation keys for localization assertions.
Use exact text only when exact text is truly the requirement.
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
👉 My projects and plugin repos: github.com/sclavijosuero
👉 You can also connect with me on my new 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! ☕😄

Top comments (0)