3

I am getting "TypeError: Cannot add property myData, object is not extensible" on setData

Hello.vue

<template>
    <div  v-if="isEditable" id="myEditDiv">
        <button type="button"> Edit </button>
    </div>
</template>
<script lang="ts">
import { computed, defineComponent, reactive} from "vue"
export default defineComponent({
    setup() {
        const myObject = {myName:"", myNumber:""}

        let myData = reactive({myObject})
        const isEditable = computed(() => {
            return myData.myObject.myName.startsWith('DNU') ? false : true
        })

        return { 
            isEditable   
        }
    }
})
</script>

Hello.spec.ts

import { shallowMount } from '@vue/test-utils'
import Hello from '@/components/Hello.vue'
import { reactive } from 'vue'

describe('Hello.vue Test', () => {

    it('is isEditable returns FALSE if NAME starts with DNU', async () => {

        const myObject = {myName:"DNU Bad Name", myNumber:"12345"}
        let myData = reactive({myObject})
        const wrapper = shallowMount(Hello)

        await wrapper.setData({'myData' : myData})

        expect(wrapper.vm.isEditable).toBe(false)
    })  
  })

I also tried to see if that DIV is visible by: expect(wrapper.find('#myEditDiv').exists()).toBe(false)

still same error. I might be completely off the path, so any help would be appreciated.

2 Answers 2

1

Update

This is possible several different ways. There's two issues that need to be addressed.

  1. The variable has to be made available. You can use vue's expose function in setup (but getting the value is really messy: wrapper.__app._container._vnode.component.subTree.component.exposed😱) or just include it in the return object (accessible through wrapper.vm).

  2. change how you mutate the data in the test.

your test has

const myObject = {myName:"DNU Bad Name", myNumber:"12345"}
let myData = reactive({myObject})
const wrapper = shallowMount(Hello)
await wrapper.setData({'myData' : myData})

even if setData was able to override the internal, it would not work.

the problem is that the setup function has this

let myData = reactive({ myObject });
const isEditable = computed(() => {
  return myData.myObject.myName.startsWith("DNU") ? false : true;
});

where editable is using a computed generated from that instance of myData. If you override myData with a separate reactive, the computed will still continue to use the old one. You need to replace the contents of the reactive and not the reactive itself

To update the entire content of the reactive, you can use:

Object.assign(myReactive, myNewData)

you can make that a method in your component, or just run that from the test. If you update any value within the reactive (like myData.myObject) you can skip the Object.asign

Here are several versions of how you can test it.

Component:

<template>
  <div v-if="isEditable" id="myEditDiv">
    <button type="button">Edit</button>
  </div>
</template>

<script>
import { computed, defineComponent, reactive } from "vue";

export default defineComponent({
  setup(_, { expose }) {
    const myObject = { myName: "", myNumber: "" };

    let myData = reactive({ myObject });

    const isEditable = computed(() => {
      return myData.myObject.myName.startsWith("DNU") ? false : true;
    });

    const updateMyData = (data) => Object.assign(myData, data);

    expose({ updateMyData });

    return {
      isEditable,
      updateMyData,
      myData
    };
  },
});
</script>

the test

import { shallowMount } from "@vue/test-utils";
import MyComponent from "@/components/MyComponent.vue";

const data = { myObject: { myName: "DNU Bad Name" } };

describe("MyComponent.vue", () => {
  it.only("sanity test", async () => {
    const wrapper = shallowMount(MyComponent);
    expect(wrapper.vm.isEditable).toBe(true);
  });

  it.only("myData", async () => {
    const wrapper = shallowMount(MyComponent);
    Object.assign(wrapper.vm.myData, data);
    expect(wrapper.vm.isEditable).toBe(false);
  });

  it.only("myData", async () => {
    const wrapper = shallowMount(MyComponent);
    wrapper.vm.myData.myObject = data.myObject;
    expect(wrapper.vm.isEditable).toBe(false);
  });

  it.only("updateMyData method via return", async () => {
    const wrapper = shallowMount(MyComponent);
    wrapper.vm.updateMyData(data);
    expect(wrapper.vm.isEditable).toBe(false);
  });

  it.only("updateMyData method via expose🙄", async () => {
    const wrapper = shallowMount(MyComponent);
    wrapper.__app._container._vnode.component.subTree.component.exposed.updateMyData(
      data
    );
    expect(wrapper.vm.isEditable).toBe(false);
  });
});


It is not possible through setData

from the docs:

setData

Updates component internal data.

Signature:

setData(data: Record<string, any>): Promise<void>

Details:

setData does not allow setting new properties that are not defined in the component.

Also, notice that setData does not modify composition API setup() data.

It seems that updating internals with composition API is incompatible with setData. See the method name setData, refers to this.data and was likely kept in the vue test utils mostly for backwards compatibility.

I suspect the theory is that it's bad practice anyway to test, what would be considered, an implementation detail and the component test should focus on validating inputs an outputs only. Fundamentally though, this is a technical issue, because the setup function doesn't expose the refs and reactives created in the setup.

Sign up to request clarification or add additional context in comments.

7 Comments

Thank you Daniel for your comments. Am i implementing my use case incorrectly then? In my case "myData" is loaded with an API call and there are 3/4 more conditions that could make isEditable = false. hypothetically i would like to write a test case, where i pass the object and test whether isEditable is calculated correctly or not. my intentions are not to mutate the internals per say, but to write a test case that calculates isEditable flag correctly based on the "myData" attributes
What I try do is separate UI from BL(business logic) which the Composition API makes much easier. Instead of having these in your component, you can create a utility .ts that handles that logic. You can put all your computed and watch in there and set this up as a singleton or a factory. Then your component can still pull in the utility to handle the interaction, but the utility you created can be tested easier since it doesn't even need vue-test-utils so you can write all the tests with jest or mocha.
I thought so too at first but moving this flag calculation in a composable .ts file would need me passing a lot of parameters on top of myData, which is not very readable outside the component itself. If i were to test with the expose API you mentioned above, how would i pass the data from the test class?
Hard to say without knowing the exact details. I'd say that's a further benefit of separating it, because it allows finer control of test scenarios. Sometimes instead of passing a variable directly, there's a chain of computeds and watches that can be bypassed. Instead you get to just send the data in any variation you want to test. But you can also pass ref/reactive/computed directly into the composable if that helps.
I have added pseudo code of detailed implementation of my use case above. any advice?
|
1

There is a MUCH easier way to do this.....

Put your composables in a separate file Test the composables stand alone.

Here is the vue file:

<template>
  <div>
    <div>value: {{ counter }}</div>
    <div>isEven: {{ isEven }}</div>
    <button type="button" @click="increment">Increment</button>
  </div>
</template>

<script setup lang='ts'>
import {sampleComposable} from "./sample.composable";

const {isEven, counter, increment} = sampleComposable();
</script>

Here is the composable:

import {computed, ref} from 'vue';

export function sampleComputed() {
  const counter = ref(0);
  function increment() {
    counter.value++;
  }
  const isEven = computed(() => counter.value % 2 === 0);

  return {counter, increment, isEven};
}

Here is the test:

import {sampleComposable} from "./sample.composable";

describe('sample', () => {

  it('simple', () => {
    const computed = sampleComposable();
    expect(computed.counter.value).toEqual(0);
    expect(computed.isEven.value).toEqual(true);
    computed.increment();
    expect(computed.counter.value).toEqual(1);
    expect(computed.isEven.value).toEqual(false);
    computed.increment();
    expect(computed.counter.value).toEqual(2);
    expect(computed.isEven.value).toEqual(true);
  })
});

This just 'works'. You don't have to deal w/ mounting components or any other stuff, you are JUST TESTING JAVASCRIPT. It's faster and much cleaner. It seems silly to test the template anyway.

One way to make this easier to test is to put all of your dependencies as arguments to the function. For instance, pass in the props so it's easy to just put in dummy values as need. Same for emits.

You can tests watches as well. You just need to flush the promise after setting the value that is being watched:

    composable.someWatchedThing.value = 6.5;
    await flushPromises();

Here is my flushPromises (which I found here):

export function flushPromises() {
  return new Promise(process.nextTick);
}

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.