Get URL parameters in Cypress Vue Component test

103 Views Asked by At

To test an i18n language selection Vue component I want to check if the logic works that decides what language to set. To write a Cypress test to see if the language stored in localeStorage is chosen over the lang parameter provided through an URL, I need to get the URL parameter, how do I check this?

code example:

LocaleSwitcher.js

<script setup>
import { getCurrentInstance } from 'vue'
import { useBusinessInfoStore } from '../../stores/businessInfo.js'

const { proxy } = getCurrentInstance()
const businessInfoStore = useBusinessInfoStore ()

function getAvailableLocales () {
  //put languages available for current app online
  return businessInfoStore.availableLanguages.filter(value => proxy.$i18n.availableLocales.includes(value));
}

function setLocale (appLocale) {
  let appLanguage

  if (appLocale) {
    appLanguage = appLocale
  } else if (typeof localStorage.currentLanguage !== 'undefined' && getAvailableLocales().includes(localStorage.currentLanguage)) {
    //if it has a local language stored use this one
    appLanguage = localStorage.currentLanguage
  } else {
    //get value of url param 'lang'
    const urlLang = (new URL(location.href).searchParams.get('lang') || '').toLowerCase()

    //get browser lang or return default lang
    const userAgentLang = ((navigator.language || navigator.languages[0]).split('-')[0] || import.meta.env.VITE_DEFAULT_LANGUAGE).toLowerCase()

    //if there is a selected lang and it is 2 char long
    if (urlLang !== null && urlLang.length === 2) {
      if (getAvailableLocales().includes(urlLang)) {
        appLanguage = urlLang
      } else if (getAvailableLocales().includes(userAgentLang)) {
        appLanguage = userAgentLang
      }
      //if no language is set or available use the standaard
    } else if (getAvailableLocales().includes(userAgentLang)) {
      appLanguage = userAgentLang
    } else {
      //if no language is set or available use the standaard
      appLanguage = import.meta.env.VITE_DEFAULT_LANGUAGE
    }
  }
  //set the language and store it
  proxy.$i18n.locale = appLanguage
  localStorage.currentLanguage = appLanguage
}

setLocale()
</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-' + businessInfoStore.titleTextColor"> | </span>
        <!-- :data-current-locale is for Cypress testing purpuses -->
        <a @click="setLocale(locale)" :data-testid="locale.toUpperCase()" :data-current-locale="$i18n.locale === locale ? 'true' : 'false'" :class="[{ 'has-text-weight-bold' : ($i18n.locale === locale)}, 'has-text-' +  businessInfoStore.linkTextColor]">
          {{ locale.toUpperCase() }}
        </a>
      </span>
    </span>
  </div>
</template>

__test__/LocaleSwitcher.js

import LocaleSwitcher from '../LocaleSwitcher.vue'
import { createI18n } from 'vue-i18n'
import { mount } from '@cypress/vue'
import en from '../../locales/en.json'
import de from '../../locales/de.json'
import nl from '../../locales/nl.json'

describe('Test the LocaleSwitcher Languages selected',() => {

  beforeEach(() => {
    const availableMessages = { en, de, nl }

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

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

  it('Click first lang and check if ?LANG= is set correct', () => {
    cy.get('[data-testid="DE"]').click()
    cy.get('[data-current-locale="true"]').should('have.length', 1)
    cy.get('[data-current-locale="true"]').should('contain', 'DE')

    cy.location().should((loc) =>  {
      expect(loc.search).to.contain('lang=de') <---- this is the url of the window not the component
    })
  })
})
2

There are 2 best solutions below

14
VonC On BEST ANSWER

Cypress tests typically run in a test runner environment, not directly in the context where the URL's query string can be manipulated as part of the component's initialization process.
And since you are working within a Vue Component test and Cypress's ecosystem, direct manipulation of window.location or mocking it in a way that affects the Vue component's behavior on initialization might not be straightforward or effective due to the test runner's isolated environment (see this question for illustration).

You could try instead adjusting your component to make it more testable under these conditions: introduce a prop to the component that allows you to pass the initial URL or language parameter directly. That change makes the component's dependency on the global location.href more explicit and allows you to control it during testing.

Modify your LocaleSwitcher.js to accept a prop for the initial URL or directly the lang parameter. If the prop is provided, use it; otherwise, default to location.href.

props: {
  initialUrl: String
},
...
const url = this.initialUrl ? new URL(this.initialUrl) : new URL(location.href);
const urlLang = url.searchParams.get('lang') || '').toLowerCase();
...

Pass the simulated URL or language parameter when mounting the component in your test:

// Mount component with the new i18n object and initialUrl prop
cy.mount(LocaleSwitcher, {
  propsData: {
    initialUrl: 'http://example.com/?lang=de' // Simulate URL with query string
  },
  global: {
    plugins: [i18n]
  }
})

That should allow you to simulate different URL conditions by passing various initialUrl prop values in your tests, facilitating the testing of component behavior based on URL parameters without relying on the actual browser environment or Cypress's cy.location().

But... it requires modifying the component's implementation for testing purposes, which might not always be desirable.
Still, it does offer a controlled way to test behavior influenced by global objects like location.href within the constraints of a component testing setup.


Reading "Cypress Environment Variables", you could also try using baseUrl with dynamic paths. If your component's behavior is influenced by the path or query parameters in the URL, you can use Cypress's baseUrl configuration to set a base URL for your tests, and then navigate to specific paths or URLs in your tests:

But as you noted, cy.visit() is typically used in end-to-end (e2e) testing scenarios within Cypress, where the objective is to test the application in a manner that closely mimics real-world user interactions within a browser environment. Using cy.visit() implies loading a whole page (either from a local development server or a live URL), rather than mounting a single Vue component in isolation.

Since you want to test the LocaleSwitcher component in isolation, the use of cy.visit() is not applicable for component-level testing. Plus, you wish to limit modifications to the component.

Mocking window.location directly in Cypress component tests can be complex due to the way the testing environment is sandboxed and managed.

Another possibility involves exploring Cypress plugins or writing custom commands that might help simulate or mock the necessary environment or properties for your tests. That could include custom commands that simulate setting URL parameters or manipulating the browser's localStorage and navigator properties before mounting your component.
That extension is typically done in the commands.js file located in the cypress/support directory.

As noted by Dave.Tufford in the comments, cy.on('window:before:load', (win) => {}) only fires for e2e tests, i.e when using cy.visit()., and for component tests, the component is mounted into the test runner window, not into an isolated iframe.

A possible option to simulate the window.location or specifically the URL and its search parameters in a component test is to mock or overwrite the global JavaScript objects or functions your component uses to read these parameters: try and encapsulate the logic for getting URL parameters within a function or method that can be easily mocked or stubbed in our tests.

First, make sure your component or its underlying logic uses a specific function to obtain URL parameters, making it easier to mock or override this behavior in tests.

// Inside your Vue component or a utility module
function getUrlParam(param) {
  return new URL(window.location.href).searchParams.get(param);
}

// And in your component, use it like this
const urlLang = getUrlParam('lang') || '';

Now you can create a Cypress custom command that allows you to stub this getUrlParam function, making sure our component receives the mock URL parameters during tests.

Assuming you have already made getUrlParam a method that can be imported and used in your component:

// cypress/support/commands.js
import { getUrlParam } from '../../path/to/your/utils';

Cypress.Commands.add('stubUrlParam', (param, value) => {
  cy.stub(window, 'getUrlParam').callsFake((name) => {
    if (name === param) {
      return value;
    }
  });
});

Note: That assumes getUrlParam is accessible globally or through window, which might require adjusting how you define or import this function for it to be stubbable in the test context.

Use this custom command in your component tests to simulate different URL parameters:

describe('LocaleSwitcher Component Tests', () => {
  beforeEach(() => {
    cy.stubUrlParam('lang', 'de'); // Stub the 'lang' URL parameter to return 'de'
  });

  it('should use the lang parameter from the URL if present', () => {
    // Mount your component as usual
    cy.mount(LocaleSwitcher, {
      // Your usual mounting configuration
    });

    // Add your assertions to check if the component behaves as expected
    // For example, checking if the component rendered the language options correctly
    cy.get('[data-testid="localeSwitcher"]').should('contain', 'DE');
  });
});

"getUrlParam is accessible globally or through window"

How do I export a function form a <script setup>, or attach it to the window?

To export a function from a <script setup> so it can be imported and used in other components or tests, you generally would not do this directly from the <script setup> tag because its primary use case is for component internals and setup.
As a workaround, you can define functions or variables that should be globally accessible in a separate JavaScript or Vue file using standard export syntax and then import them into your <script setup> component.

For instance, create a separate useUrlParams.js file:

// useUrlParams.js

export function getUrlParam(param) {
  return new URL(window.location.href).searchParams.get(param);
}

And import it in your <script setup> Vue component:

<script setup>
import { getUrlParam } from './useUrlParams';

const lang = getUrlParam('lang');
</script>

If you really need to attach a function to the window object from within a <script setup>, which is generally not recommended due to potential namespace pollution and the risk of overwriting existing properties, you can do so directly:

<script setup>
function getUrlParam(param) {
  return new URL(window.location.href).searchParams.get(param);
}

window.getUrlParam = getUrlParam;
</script>

That makes getUrlParam globally accessible as window.getUrlParam.
Be cautious with this approach, as attaching too many properties to the window object can lead to harder-to-maintain code and potential conflicts.

3
Aladin Spaz On

Stubbing window.location

There is a useful blog here Stub The Unstubbable which deals with stubbing window.location which is not directly stub-able (protected property). This blog uses Component testing as it's example.

The gist is to wrap window.location in a wrapper object to which the stub is applied.

This is how I implemented it for your LanguageSwitcher component.

wrapper object Location.js

export const Location = {
  get href() {
    return window.location.href     // normally just return the href
  } 
}

LanguageSwitcher.vue

<script setup>
...
import {Location} from './Location'
...
function setLocale (appLocale) {
  ...

    //get value of url param 'lang'
    window.Location = Location     // NOTE capital "L" for wrapper

    const urlLang = (new URL(Location.href).searchParams.get('lang') || '').toLowerCase()

LanguageSwitcher.cy.js

// stubbing the wrapper "Location" instead of the native "location" 
Cypress.sinon.stub(window.Location, 'href').get(() => {
  return `${window.location.href}&lang=de`
})

cy.get('[data-testid="DE"]').click()

// checks that URL param gets written to localstorage
cy.getAllLocalStorage().then(allLocalstorage => {
  cy.location().should((loc) =>  {
    expect(allLocalstorage).to.deep.equal({
      [loc.origin]: {
        currentLanguage: 'de',
      },
    })
  })
})

enter image description here

While this works, I have a funny feeling about "spoofing" the location - is there some way the real-world app changes the actual window.location - trying to get my head around the data flow.