quasarframework / quasar-testing

Testing Harness App Extensions for the Quasar Framework 2.0+
https://testing.quasar.dev
MIT License
179 stars 66 forks source link

Add a proper way to `mount` a component with `v-model` in `quasar-app-extension-testing-e2e-cypress` #385

Open FelixNumworks opened 1 month ago

FelixNumworks commented 1 month ago

This is a feature request

Problem when mounting components with a v-model

When using cy.mount in my component tests, I have difficulty correctly mounting the component with a v-model binding.

My old way of doing it was like this:

cy.mount(MyComponent, {
  props: {
    modelValue: myModel,
  },
});

The issue is that while the model is passed as a prop, the onUpdate:modelValue event isn't configured, causing unexpected behavior with Vue's reactivity system.

Expected solution

I'd like the mount comand to allow me to do this easily:

cy.mount(MyComponent, {
  props: {
     ...
  },
  models: {
    modelValue: myModel
  }
});

Example:

Take this simple DoubleCounter.vue component

<template>
  <button data-cy="mutateButton" @click="mutateModel">Increment by mutating model</button>
  <button data-cy="replaceButton" @click="replaceModel">Increment by replacing model</button>
  <p data-cy="count">{{ myModel.count }}</p>
</template>

<script setup lang="ts">
import { PropType } from 'vue';

const myModel = defineModel({
  type: Object as PropType<{ count: number }>,
  required: true,
});

function replaceModel() {
  myModel.value = { count: myModel.value.count + 1 };
}

function mutateModel() {
  myModel.value.count += 1;
}
</script>

Clicking the mutateButton works as expected, but once the replaceButton is clicked, the component becomes buggy — it updates the model, but the DOM doesn't reflect the changes.

it('doesnt work when mounting the component with a model props', () => {
  const model = { count: 0 };
  cy.mount(DoubleCounter, {
    props: {
      modelValue: model,
    },
  });

  cy.dataCy('mutateButton').click();
  cy.dataCy('count').should('have.text', '1');

  cy.dataCy('replaceButton').click();
  cy.dataCy('count').should('have.text', '2');

  cy.dataCy('mutateButton').click();
  cy.dataCy('count').should('have.text', '3'); //  <----- FAILS 😢
});

Current solution

As per vue-test-utils documention, I can manually set the onUpdate:modelValue. Unfortunately, cy.mount doesn't provide direct access to the VueWrapper, so I had to find a workaround.

This is what I came up with:

it('does work when mounting the component with a model props and an update prop', () => {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  let componentWrapper: any;

  const model = { count: 0 };
  cy.mount(DoubleCounter, {
    props: {
      modelValue: model,
      'onUpdate:modelValue': (updatedModel: { count: number }) => {
        componentWrapper?.setProps({ modelValue: updatedModel });
      },
    },
  }).then((component) => {
    componentWrapper = component.wrapper;
  });

  cy.dataCy('mutateButton').click();
  cy.dataCy('count').should('have.text', '1');

  cy.dataCy('replaceButton').click();
  cy.dataCy('count').should('have.text', '2');

  cy.dataCy('mutateButton').click();
  cy.dataCy('count').should('have.text', '3'); // <---- WORKS 🥳
});

This is tedious and could be impemented in the mount command directly.

Thank you :)

FelixNumworks commented 1 month ago

Here is my overwritten mount command:

Cypress.Commands.overwrite('mount', (originalFn, component, mountOptions) => {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  let componentWrapper: any;

  if (mountOptions?.props && component.emits) {
    for (const emit of component.emits as string[]) {
      if (!emit.startsWith('update:')) continue;
      const modelName = emit.replace('update:', '');
      if (!mountOptions.props[modelName]) continue;
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      mountOptions.props[`onUpdate:${modelName}`] = (updatedModel: any) => {
        componentWrapper?.setProps({ [modelName]: updatedModel });
      };
    }
  }

  return originalFn(component, mountOptions).then(({ wrapper, component }) => {
    componentWrapper = wrapper;
    return { wrapper, component };
  });
});