Cypress test for Vue 3 to check i18n language changer

266 Views Asked by At

In my Vue 3 app users can make pages for themselves that are multi-lingual. They can decide what languages will be available on their page. The available languages are loaded from an API.

The languages are loaded in an Pinia store and the LanguageSwitcher.vue component loads a default language based on browser settings, language variable in de URL, default locale and the available languages. All this warrants rigorous Cypress tests, one of them being making sure that the language shown as selected on the page is in fact the language selected. Yet how do I get the current selected language from i18n in Cypress?

My LanguageSwitcher.vue component (without the logic that sets the initial language)

</script>
<template>
  <div data-testid="localeSwitcher">
    <span v-if="getAvailableLocales().length > 1">
      <span v-for="(locale, i) in getAvailableLocales()" :key="`locale-${locale}`">
        <span v-if="i != 0" class="has-text-warning-dark"> | </span>
        <a  @click="setLocale(locale)" :class="[{ 'has-text-weight-bold' : ($i18n.locale === locale)}, 'has-text-warning-dark']">
          {{ locale.toUpperCase() }}
        </a>
      </span>
    </span>
  </div>
</template>

My test that loads the i18n and should check the current language (no clue how to do that though)

__test__/LanguageSwitcher.cy.js

import LanguageSwitcher from '../items/LanguageSwitcher.vue'
import { createI18n } from 'vue-i18n'
import { mount } from '@cypress/vue'
import en from '../../locales/en.json'
import ru from '../../locales/ru.json'
import ro from '../../locales/ro.json'

describe('Test the LocaleSwitcher Languages selected',() => {
  let i18n       //<---thought if I declare it here maybe the test could access it
  beforeEach(() => {
    //list alle the available languages
    const availableMessages = { ru, en, ro }

    //load available languages
    i18n = createI18n({
      legacy: false,
      fallbackLocale: ro,
      locale: ro,
      globalInjection: true,
      messages: availableMessages
    })

    //mount component with the new i18n object
    cy.mount(LocaleSwitcher, { global: { plugins : [ i18n ]}})
  })


  it('Check if bold language is active language', () => {
    i18n.locale = 'ru'                    //<--- like so, but no
    //check of all links shown are unique
    cy.get('*[class^="has-text-weight-bold"]').should('have.length', 1)
    cy.get('*[class^="has-text-weight-bold"]').each(($a) => {
      expect($a.text()).to.have.string('RU')
    })
  })
})

I tried to find where the global.plugins.i18n is, but all my searches come up empty. So how can I access the i18n in the currently mounted component in Cypress and read the i18n.locale?

packages.json

{
  "name": "web-shop",
  "version": "0.0.0",
  "private": true,
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview",
    "test:e2e": "start-server-and-test preview http://localhost:4173 'cypress run --e2e'",
    "test:e2e:dev": "start-server-and-test 'vite dev --port 4173' http://localhost:4173 'cypress open --e2e'",
    "test:unit": "cypress run --component",
    "test:unit:dev": "cypress open --component",
    "css-deploy": "npm run css-build && npm run css-postcss",
    "css-build": "node-sass --omit-source-map-url src/assets/_sass/main.scss src/assets/css/main.css",
    "css-postcss": "postcss --use autoprefixer --output src/assets/css/main.css src/assets/css/main.css",
    "css-watch": "npm run css-build -- --watch",
    "deploy": "npm run css-deploy",
    "start": "npm-run-all --parallel css-watch"
  },
  "dependencies": {
    "bulma": "^0.9.4",
    "fork-awesome": "^1.2.0",
    "pinia": "^2.1.4",
    "vue": "^3.3.4",
    "vue-i18n": "^9"
  },
  "devDependencies": {
    "@cypress/vue": "^6.0.0",
    "@npmcli/fs": "latest",
    "@vitejs/plugin-vue": "^4.2.3",
    "@vitejs/plugin-vue-jsx": "^3.0.1",
    "cypress": "^13.2.0",
    "node-sass": "latest",
    "start-server-and-test": "^2.0.0",
    "vite": "^4.4.6"
  }
}

cypress.config.js

const { defineConfig } = require('cypress')

module.exports = defineConfig({
  defaultCommandTimeout: 10000,
  e2e: {
    specPattern: 'cypress/e2e/**/*.{cy,spec}.{js,jsx,ts,tsx}',
    baseUrl: 'http://localhost:4173'
  },
  component: {
    specPattern: 'src/**/__tests__/*.{cy,spec}.{js,ts,jsx,tsx}',
    devServer: {
      framework: 'vue',
      bundler: 'vite'
    }
  }
})

cypress/support/components.js

import './commands'
import { mount } from 'cypress/vue'
import { createPinia,  setActivePinia } from 'pinia'
import { useBusinessInfoStore } from '../../src/stores/businessInfo.js'
import { createI18n } from 'vue-i18n'
import en from '../../src/locales/en.json'
import de from '../../src/locales/de.json'
import nl from '../../src/locales/nl.json'

let pinia = createPinia();
setActivePinia(pinia);

//list alle the available languages
const availableMessages = { en, de, nl }

const i18n = createI18n({
  legacy: false,
  fallbackLocale: en,
  locale: nl,
  globalInjection: true,
  messages: availableMessages
})

Cypress.Commands.add('mount', (component, options = {}) => {
  // Setup options object
  options.global = options.global || {}
  options.global.stubs = options.global.stubs || {}
  options.global.stubs['transition'] = false
  options.global.components = options.global.components || {}
  options.global.plugins = options.global.plugins || []

  /* Add any global plugins */

  //check if array is empty, if not assume there is already an i18n object there
  if (options.global.plugins[0] == undefined ) {
    //for testing the language selector this needs to be inserted in that test
    options.global.plugins.push(i18n)
  }
  options.global.plugins.push(pinia)

  return mount(component, options)
})
2

There are 2 best solutions below

11
On

Cypress operates outside the Vue application's context.
That means, directly accessing Vue component's internal state or instances (like your i18n plugin instance) is not straightforward.

One way to make your components more testable is to use data attributes to store state information that you can easily access in tests. For instance, you could modify your LanguageSwitcher.vue component to include a data attribute that reflects the current locale:

<a @click="setLocale(locale)" :data-current-locale="$i18n.locale === locale ? 'true' : 'false'" :class="[{'has-text-weight-bold': ($i18n.locale === locale)}, 'has-text-warning-dark']">
  {{ locale.toUpperCase() }}
</a>

In your Cypress test, you can then select the element with the correct attribute:

it('Check if bold language is active language', () => {
  // Assuming you have a way to change the language to 'ru'
  // For example, by simulating a click on the language switcher

  // After changing the language, check that the correct language is now active
  cy.get('[data-current-locale="true"]').should('have.length', 1);
  cy.get('[data-current-locale="true"]').should('contain', 'RU');
});

Since you are testing a UI component, the most end-to-end way to test it would be to simulate user interaction to change the language and then check if the UI reflects this change. That means triggering clicks on the language switcher links and verifying the active language is highlighted as expected:

it('Check if bold language is active language by UI interaction', () => {
  // Example: Click on the language switcher for Russian
  cy.contains('RU').click();

  // Now check if 'RU' is highlighted as the active language
  cy.get('*[class^="has-text-weight-bold"]').should('have.length', 1);
  cy.get('*[class^="has-text-weight-bold"]').should('contain', 'RU');
});

Even though Vue CLI is not actively developed anymore, it is still a popular tool for scaffolding Vue.js projects.

If you are working on a project that was scaffolded with Vue CLI and uses the @vue/cli-plugin-e2e-cypress for E2E testing, you can use vue add e2e-cypress to set up Cypress in your project, including default configuration and sample tests.
If not, for integrating Cypress in a Vite-based project, you would manually install Cypress and configure it, as Vite does not have a plugin system like Vue CLI.

Given your application uses Vue I18n and Pinia, your tests need to interact with the UI to change languages and assert the active language. While direct access to Vue's reactivity system or plugins like Vue I18n within Cypress tests is not straightforward, you can manipulate and assert UI state changes as a user would.

You could write a Cypress test for your LanguageSwitcher.vue component, assuming it renders language options and highlights the active language, to simulate user interaction to change the language and then to verify that the UI updates accordingly:

A LanguageSwitcher.cy.js would be:

describe('Language Switcher', () => {
  beforeEach(() => {
    // Visit the page where your LanguageSwitcher component is rendered
    cy.visit('/'); // Adjust the URL based on your application's routing

    // The following setup assumes your application already loads available languages
    // and sets a default language either from browser settings, URL params, or a default locale
  });

  it('switches language and updates the highlight', () => {
    // Example: Switch to Russian ('RU')
    // Assumes language options are rendered as clickable elements (e.g., buttons or links)
    cy.contains('RU').click();

    // Assert that 'RU' is now highlighted as the active language
    // That assumes you have a CSS class that highlights the active language
    cy.get('[data-testid="localeSwitcher"]')
      .find('.has-text-weight-bold')
      .should('contain', 'RU');

    // Optionally, verify that the page content updates to reflect the new language
    // That step depends on how your application renders localized content
    // cy.contains('Some content in Russian');
  });
});

Testing more intricate behaviors related to Vue I18n and Pinia (like loading languages from an API) would involve setting up mock responses or initial states before running your tests. That could be done using Cypress commands to mock API calls or manipulate the browser's local storage and session storage where initial states might be stored.


Must say that I find it strange though that somehow the mounted Vue component can access the i18n object

In a Vue application, components interact with the i18n instance through the Vue instance they are part of. That is made possible by the Vue plugin system, which allows global properties or functionalities, like i18n for internationalization, to be accessible in all components via this.$i18n or a similar mechanism depending on the Vue version and the i18n library setup.

However, when testing with Cypress, you are operating in a different context. Cypress runs outside of your Vue application, manipulating the application through the browser's DOM as a user might. Cypress tests do not have direct access to the internal JavaScript scope of your Vue components. That separation ensures tests mimic user interactions as closely as possible, rather than interacting with the application's internal state or instances directly.

Given this separation, to test language changes or the effects of interacting with the i18n object, you would typically:

  • Trigger UI Changes: Use Cypress to interact with UI elements that, in turn, cause the Vue application to update its state (including the active language).
  • Observe Effects on the DOM: After triggering a change, use Cypress assertions to check for expected changes in the DOM. That could involve checking for text content changes, class changes, or other attributes that indicate the current state of the i18n language selection.

For example, after simulating a click on a language switcher button with Cypress, you would check for visible UI changes that confirm the language has indeed been switched, rather than trying to access the i18n object directly from the test.

Your tests accurately reflect how a user would experience and interact with the application, which is the main goal of using Cypress for end-to-end testing.

However, as noted by Chloe

It's only correct if you are running e2e tests.
For component tests, the Vue instance is added to the Cypress global for convenience.

  • E2E (End-to-End) Tests: These tests simulate real user scenarios from start to finish. E2E tests interact with the application as it runs in a browser, clicking through interfaces, loading pages, etc., without direct access to the underlying JavaScript state or Vue instances. Cypress is traditionally known for these types of tests.

  • Component Tests: These are more granular than E2E tests, focusing on individual components in isolation. Component testing frameworks typically provide a way to mount individual components in a testing environment, allowing for direct interaction with their props, events, and state. Cypress introduced component testing capabilities that enable this level of granularity, blending the lines between traditional unit testing tools and Cypress's E2E testing strengths.

When running component tests with Cypress, the testing environment is configured to allow more direct interaction with the Vue component instances. That includes access to the Vue instance itself, which can be especially useful for manipulating or asserting against specific states or behaviors, like those provided by the i18n plugin.

Example of accessing i18n in Cypress component tests:

// Assuming Cypress has been set up for component testing with Vue
cy.mount(MyComponent).then((wrapper) => {
  // Access the Vue instance directly
  const vm = wrapper.vm;

  // Manipulate the i18n locale directly for testing
  vm.$i18n.locale = 'fr';

  // Assert changes in the component as a result
  cy.get('[data-testid="localeSwitcher"]').should('contain', 'FR');
});

That assumes that cy.mount is part of Cypress's component testing API, which mounts the Vue component within the Cypress test runner, allowing for direct interaction with the component's Vue instance.


I added the imports and cypresss.config.js (this one I didn't touch, it was made by Vue). I will try if it works if without the beforeEach(). really don't understand how it is possible that this should work but doesn't... maybe the cy.mount(LanguageSwitcher, { global: { plugins : [ i18n ]}}) line is wrong?

Verify that your import statements for cy.mount, your component, and the i18n plugin are correct. Make sure all imports (LanguageSwitcher, createI18n, etc.) are correctly resolved in your test files. Even though you mentioned not touching cypress.config.js, it is important to make sure it is properly configured for component testing, including setting up the Vue version and any necessary plugins:

const { defineConfig } = require('cypress');

module.exports = defineConfig({
  e2e: {
    // Your e2e config here
  },
  component: {
    // Component testing specific configuration
    setupNodeEvents(on, config) {
      // Implement node event listeners here
    },
    framework: 'vue', // or 'vue3' depending on your setup
    bundler: 'webpack', // or 'vite' or another bundler you are using
  },
});

The use of beforeEach for setting up your tests should not, in itself, be an issue. However, making sure that any global setup that needs to happen before each test is correctly configured is important. If moving the cy.mount out of beforeEach makes a difference, it could be related to how the test lifecycle interacts with your component's state or the i18n plugin's initialization. Make sure the i18n instance is properly instantiated and passed to your component during the mount process.

import { createI18n } from 'vue-i18n'; // Make sure this import is correct

const i18n = createI18n({
  // Configuration for your i18n instance
  // Include locales and messages here
});

cy.mount(LanguageSwitcher, {
  global: {
    plugins: [i18n],
  },
});
14
On

The locale property is accessed from Cypress.vue.$i18n.locale.

There is an example in the Cypress github repo here cypress/npm/vue/cypress/component/advanced/i18n /spec.js

An example spec close to your requirement is

/// <reference types="cypress" />
import TranslatedMessageI18nBlock from './TranslatedI18nMessage.vue'
import { createI18n } from 'vue-i18n'
import { mount } from '@cypress/vue'
import messages from './translations.json'

describe('VueI18n', () => {
  describe('with i18n block', () => {

    it('shows HelloWorld for all locale', () => {

      const i18n = createI18n({ 
        locale: 'en', 
        messages 
      })
      mount(TranslatedMessageI18nBlock, { global: { plugins: [i18n] } })

      cy.then(() => Cypress.vue.$i18n.locale = 'ru')
      cy.contains('Привет мир')

      cy.then(() => Cypress.vue.$i18n.locale = 'ja')
      cy.contains('こんにちは、世界')
      
      cy.then(() => Cypress.vue.$i18n.locale = 'fa')
      cy.contains('سلام دنیا')
            
      cy.then(() => Cypress.vue.$i18n.locale = 'en')
      cy.contains('hello world!')
    })
  })
})

Note, I'm setting the locale inside a .then() callback so that the changes are sequential in the command queue.

Otherwise, the test will have fired the last locale update ('en') before the queue starts running, and the first cy.contains() will fail.

enter image description here


@cypress/vue source where globals are exposed

Ref cypress/npm/vue/src/index.ts

// mount the component using VTU and return the wrapper in Cypress.VueWrapper
const wrapper = VTUmount(componentOptions, { attachTo: componentNode, ...options })

Cypress.vueWrapper = wrapper as VueWrapper<ComponentPublicInstance>
Cypress.vue = wrapper.vm as ComponentPublicInstance

package.json to compare to your own

{
  "name": "vue-project",
  "version": "0.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview",
    "test:e2e": "start-server-and-test preview http://localhost:4173 'cypress run --e2e'",
    "test:e2e:dev": "start-server-and-test 'vite dev --port 4173' http://localhost:4173 'cypress open --e2e'",
    "test:unit": "cypress run --component",
    "test:unit:dev": "cypress open --component"
  },
  "devDependencies": {
    "@cypress/vue": "^6.0.0",
    "vue": "^3.4.15",
    "@vitejs/plugin-vue": "^5.0.3",
    "cypress": "^13.6.3",
    "start-server-and-test": "^2.0.3",
    "vite": "^5.0.11",
    "vue-i18n": "^9.9.1"
  }
}