vuejs / vue-class-component

ES / TypeScript decorator for class-style Vue components.
MIT License
5.81k stars 429 forks source link

this.$t is not a function in a class component #505

Open Mikilll94 opened 3 years ago

Mikilll94 commented 3 years ago

Subject of the issue

I have the following component

<template>
  <div>{{someData}}</div>
</template>

<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';

@Component
export default class App extends Vue {
  private someData = this.$t("message");
}
</script>

and the following unit test

import { mount } from '@vue/test-utils'
import App from '@/App.vue'

describe('App.vue', () => {
  it('some unit test', () => {
    const wrapper = mount(App, {
      mocks: {
        $t: (key: string) => key
      }
    });
    expect(wrapper).toBeDefined();
  })
})

When running this unit test, it fails with the following error:

 FAIL  tests/unit/example.spec.ts
  HelloWorld.vue
    ✕ some unit test (22ms)

  ● HelloWorld.vue › some unit test

    TypeError: this.$t is not a function

       8 | @Component
       9 | export default class App extends Vue {
    > 10 |   private someData = this.$t("message");
         | ^
      11 | }
      12 | </script>
      13 | 

      at new App (src/App.vue:10:1)
      at collectDataFromConstructor (node_modules/vue-class-component/dist/vue-class-component.common.js:165:14)
      at VueComponent.data (node_modules/vue-class-component/dist/vue-class-component.common.js:227:14)
      at getData (node_modules/vue/dist/vue.runtime.common.dev.js:4732:17)
      at initData (node_modules/vue/dist/vue.runtime.common.dev.js:4689:7)
      at initState (node_modules/vue/dist/vue.runtime.common.dev.js:4628:5)
      at VueComponent.Vue._init (node_modules/vue/dist/vue.runtime.common.dev.js:4987:5)
      at new VueComponent (node_modules/vue/dist/vue.runtime.common.dev.js:5134:12)
      at createComponentInstanceForVnode (node_modules/vue/dist/vue.runtime.common.dev.js:3277:10)
      at init (node_modules/vue/dist/vue.runtime.common.dev.js:3108:45)
      at createComponent (node_modules/vue/dist/vue.runtime.common.dev.js:5958:9)
      at createElm (node_modules/vue/dist/vue.runtime.common.dev.js:5905:9)
      at VueComponent.patch [as __patch__] (node_modules/vue/dist/vue.runtime.common.dev.js:6455:7)
      at VueComponent.Vue._update (node_modules/vue/dist/vue.runtime.common.dev.js:3933:19)
      at VueComponent.updateComponent (node_modules/vue/dist/vue.runtime.common.dev.js:4054:10)
      at Watcher.get (node_modules/vue/dist/vue.runtime.common.dev.js:4465:25)
      at new Watcher (node_modules/vue/dist/vue.runtime.common.dev.js:4454:12)
      at mountComponent (node_modules/vue/dist/vue.runtime.common.dev.js:4061:3)
      at VueComponent.Object.<anonymous>.Vue.$mount (node_modules/vue/dist/vue.runtime.common.dev.js:8392:10)
      at mount (node_modules/@vue/test-utils/dist/vue-test-utils.js:13991:21)
      at Object.<anonymous> (tests/unit/example.spec.ts:6:21)

Steps to reproduce

Just run unit tests in this project: i18n-test.zip

Expected behaviour

Error this.$t is not a function should not occur. The $t function is mocked.

Actual behaviour

An error this.$t is not a function is thrown.

Additional context

If I convert someData to computed property, like this:

<template>
  <div>{{someData}}</div>
</template>

<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';

@Component
export default class App extends Vue {
  private get someData() {
    return this.$t("message");
  }
</script>

the error is gone.

Mikilll94 commented 3 years ago

@kaorun343 https://github.com/vuejs/vue-test-utils/issues/1767#issuecomment-761301172

As you can see this is rather a bug in vue-class-components than in vue-test-utils.

lmiller1990 commented 3 years ago

Does this work in a browser with the real $t? The problem is only when mocking $t?

Mikilll94 commented 3 years ago

@lmiller1990 In browser it is working. You can check this in my repro project. Only unit tests are failing.

lmiller1990 commented 3 years ago

I am guessing VCC does some transform first, then VTU applies the mock $t, at which point it is too late.

I would recommend using data or computed as a work-around in the meantime.

Are you interested in investigating this bug/making a PR?

Mikilll94 commented 3 years ago

@lmiller1990 In the meantime I was debugging this for a while. It seems that vue-class-component overrides the class constructor and transforms property initializers into data in Vue component. This is happening in this function: https://github.com/vuejs/vue-class-component/blob/16433462b40aefecc030919623f17b0ec9afe61c/src/data.ts#L5

If you debug vm variable you can see that when running unit tests, it is missing _i18n property. In the browser this property is present.

BTW. accessing this stuff in property initializers is a valid functionality of VCC. You check this here: https://github.com/vuejs/vue-class-component/issues/432 https://github.com/vuejs/vue-class-component/pull/434

lmiller1990 commented 3 years ago

Interesting. I suppose this makes sense. Class Component -> VCC transform -> VTU $mocks. By the time $mocks happens, it is too late and the component is using whatever this.$i18n was at the time of the transform. It expects $t to exist when VCC does it's transform, since it doesn't, boom.

I do not see any way we can fix this in VTU - whatever we do, it's going to be too late to get the mocked value in. Seems VTU v2 and the next version of class component exhibit the same bug for the same reason.

An alternative would be using a createLocalVue, where you install a mocked $i18n. A bit more boilerplate, but you could make a function. Does that make sense?

Mikilll94 commented 3 years ago

@lmiller1990

Interesting. I suppose this makes sense. Class Component -> VCC transform -> VTU $mocks. By the time $mocks happens, it is too late and the component is using whatever this.$i18n was at the time of the transform. It expects $t to exist when VCC does it's transform, since it doesn't, boom.

When the mocking exactly happens in vue-test-utils? (you can show this in the source code) Does mocking occur after creating a component? If yes, then this is problematic for class components, because this Component.prototype._init function is executed before creating a component. This function transforms the property initializers to the data object.

Could you check the VCC source code to see if there is any solution to this? Maybe this can be fixed in VCC source code?

An alternative would be using a createLocalVue, where you install a mocked $i18n. A bit more boilerplate, but you could make a function. Does that make sense?

Could you show an example?

lmiller1990 commented 3 years ago

The mocking happens here. createInstance is ultimately called when mount is called. The class component transform is applied far before this - it's all compiled to a regular object before it's even passed into mount.

I don't see a way to fix this by patching VCC.

Could you show an example

Looks like my idea doesn't work. I thought I could do something like this:

class MockPlugin {
  static install(Vue: any) {
    Vue.$t = (key: string) => key
  }
}

const localVue = createLocalVue()
localVue.use(MockPlugin)

describe('App.vue', () => {
  it('some unit test', () => {
    const wrapper = mount(App, {
      localVue
    });
    expect(wrapper).toBeDefined();
  })
})

But the result is the same. Hm. I think something else is at work here... I cannot even get this to work with the real i18n plugin.

lmiller1990 commented 3 years ago

Ok... this worked (webpack + mocha combo).

import { expect } from 'chai'
import Vue from 'vue'
import { mount, createLocalVue } from '@vue/test-utils'
import App from '@/App.vue'

class i18n {
  static install(Vue: any) {
    Vue.prototype.$t = (key: string) => key
  }
}

Vue.use(i18n)

describe('HelloWorld.vue', () => {
  it('renders props.msg when passed', () => {
    const wrapper = mount(App)
    console.log(wrapper.html())
  })
})

But this does not:

import { expect } from 'chai'
import Vue from 'vue'
import { mount, createLocalVue } from '@vue/test-utils'
import App from '@/App.vue'

class i18n {
  static install(Vue: any) {
    Vue.prototype.$t = (key: string) => key
  }
}

const localVue = createLocalVue()
localVue.use(i18n)

describe('HelloWorld.vue', () => {
  it('renders props.msg when passed', () => {
    const wrapper = mount(App, {
      localVue
    })
    console.log(wrapper.html())
  })
})

I guess when VCC does it's thing, Vue already has $t. It does not work with localVue - VCC does not know about localVue. I don't see any way to "tell" VCC which Vue instance to use, so this is likely out of the question.

If you dont't mind using a mutated global Vue for all your tests, you could do something like I've shown here (making a "fake" i18n plugin).

I am still not sure on how we can make mocks work with VCC. Let's experiment a bit more.

ktsn commented 3 years ago

A repro excluded the dep to vue-test-utils would be:

    @Component
    class Base extends Vue {
      test() {
        return 'base'
      }
    }

    @Component
    class Test extends Base {
      result = this.test()
    }

    const vm = new Test()
    expect(vm.result).to.equal('base')

    const Replaced = Vue.extend()
    Replaced.prototype.test = () => 'replaced'

    const replaced: Test = new (Replaced.extend({
      ...(Test as any).options,
    }))()
    expect(replaced.result).to.equal('replaced')

We could replace the prototype of the original constructor with the one of localVue in collectDataFromConstructor but I'm not very sure about it and if it is really a good idea. I'll need more time to take a look...

lmiller1990 commented 3 years ago

I thought about this too, but doesn't feel like a great solution.

We should also think about how this works in vue-class-component-next and vue-test-utils-next. vue-test-utils-next has the exact same problem... but does not use the localVue. We should think of a solution that will work there, too.

ygj6 commented 3 years ago

hope it helps you.

@Component
export default class App extends Vue {
  private someData();
  created() {
     this.someData = this.$t("message");
   }
}
Mikilll94 commented 3 years ago

@ktsn Please take a look at these comments: https://github.com/vuejs/vue-test-utils/issues/1767#issuecomment-767597984 https://github.com/vuejs/vue-test-utils/issues/1767#issuecomment-769596262