Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

remove setComputed #331

Closed
eddyerburgh opened this issue Jan 5, 2018 · 32 comments
Closed

remove setComputed #331

eddyerburgh opened this issue Jan 5, 2018 · 32 comments

Comments

@eddyerburgh
Copy link
Member

The setComputed method changes the value of computed properties permanently.

That means the instance is in a state that is impossible to reach in production. This could lead to bugs that slip through tests because a component behaves differently in tests versus production.

What do people think?

@lmiller1990
Copy link
Member

lmiller1990 commented Jan 5, 2018

Is using setComputed different to using the computed options object passed to shallow?

One of my main use cases I feel is might be related is when I have computed functions like this:

all () {
  return this.$store.state.members.all
},

allIds () {
  return this.$store.state.members.allIds
},

then I need to do

import { createLocalVue, shallow } from 'vue-test-utils'
import Vuex from 'vuex'

const localVue = createLocalVue()
localVue.use(Vuex)
let store

beforeEach(() => {
  store = new Vuex.Store({
    state: {
      members: {
        all: {}, allIds: []
      }
    }
  }
})

// write some test to see if a component is rendered by looping `allIds`, 
// and another to see what happens if there if `allIds` is empty...

Which is a huge amount of boilerplate. So I normally do:

const wrapper = shallow(Foo, {
  computed: {
     all: {},
     allIds: []
})

which saves making the Vuex store. I believe if you are just doing data tests, which involved seeing how the UI looks based on some state, you should not use an actual store, but simply computed and setComputed, since a store is an implementation detail, which is not something we should be testing.

Do you have an alternative in mind that would allow for a computed value to be changed? Or is setComputed a bad pattern in general? Does setData stuff the same problem?

@Alex-Sokolov
Copy link
Contributor

I think better persist setComputed

For example, if we have 3 computed variables: a, b, c
a depend on some properties
b depend on some other properties
c depend on a and b.

When we write tests for c with setComputed we can just "switch" results of a and b and assert that result c is correct. If we didn't have setComputed we need to change (maybe) a huge amount of properties, which change a and b and later check c

@Austio
Copy link
Contributor

Austio commented Jan 7, 2018

Was some good discussion in this thread a while back. #55 (comment)

@LinusBorg
Copy link
Member

LinusBorg commented Jan 8, 2018

@Alex-Sokolov I don't think you should test a computed prop like that in a component unit test, it's essentially testing implementation details instead of testing the result of the component.

If on a rare ofccation a computed prop is so complicated that they need to be tested individually, do that outside of a component unit test and test the function on its own.

As I agree with the risks @eddyerburgh mentions (I brought them up, in fact), I vote to remove it.

@lmiller1990
Copy link
Member

If removed, will we still be able to set computed by passing it to the constructor options? eg:

shallow(Foo, {
  computed: {
    someVal: () => true
  }
})

?

@eddyerburgh
Copy link
Member Author

Yes @lmiller1990

@lmiller1990
Copy link
Member

Ok then I am happy to see setComputed leave.

yukihirop added a commit to yukihirop/onepage that referenced this issue Jan 27, 2018
「namespace: trueなstore」と「そうでないstore」のどちら側に定義されたcomputedなのか
を意識できておらずハマった。

この場合、itemsInCartという算術プロパティ(computed)は、rootStoreに定義されたプロパティである。
mockedStoreではmockできないので、別で渡している。

```
const mockedStore = {
      shoppingModule: {
        namespaced: true,
        getters: {
          cartProducts: jest.fn().mockReturnValue([
              {
                name: 'a',
                price: 1,
                quantity: 10
              },
              {
                name: 'b',
                price: 2,
                quantity: 20
              },
              {
                name: 'c',
                price: 3,
                quantity: 30
              },
            ]),
        },
      },
    }
    console.log(mockedStore)
    const store = new Vuex.Store(mockedStore)
    // 今現在、itemsInCartは名前空間で別れていないので、namespaced: trueなmockedStoreではmockできない
    // TODO: 今後いい方法を考える
    const wrapper = shallow(NavBar, { store, localVue, mocks: { $route }, computed: { itemsInCart: jest.fn().mockReturnValue(10)}})
```

下記リンクが解決に繋がった

[参考]
vuejs/vue-test-utils#331
@jonnyparris
Copy link

@LinusBorg 'Complicated' computed variables in my case are fairly simple methods but they can rely on a fairly long chain of dependencies on other computed variables. Testing them separately would be overkill.

It's not clear how the original concern of setComputed providing a permanent mock is not addressed by clear docs as opposed to removing it completely. It seems like there's only downsides to this.

@yx296
Copy link

yx296 commented Mar 14, 2018

Don't remove setComputed! Sad!

Suppose I have the following component:

import { mapState } from 'vuex';
import externalDependency from '...';

export default {
  name: 'Foo',
  computed: {
    ...mapState(['bar'])
  },
  watch: {
    bar () {
     externalDependency.doThing(this.bar);
    }
  }
}

When testing, I want to ensure that externalDependency.doThing() is called with bar (which comes from the vuex state) like so:

it('should call externalDependency.doThing with bar', () => {
  const wrapper = mount(Foo);
  const spy = jest.spyOn(externalDependency, 'doThing');

  wrapper.setComputed({bar: 'baz'});

  expect(spy).toHaveBeenCalledWith('baz');
});

@eddyerburgh
Copy link
Member Author

eddyerburgh commented Mar 15, 2018

Mocking computed is dangerous, because you can put your component into a state that it can't be in during production. The concern with setComputed is that it encourages users to mock the computed value.

You could achieve the same result by overwriting the computed option when you mount the component:

const wrapper = mount(Foo, {
  computed: {
    bar() {
      return 'baz'
    }
  }
})

@jonnyparris
Copy link

jonnyparris commented Mar 15, 2018 via email

@LinusBorg
Copy link
Member

No, it's overwriting internals of the component to return something different than the compued property returned.

That's a bit different from replacing the computed property with a stub, especially since that has to happen during mount, while setComputed could be done during "runtime", dynamically creating impossible state combinations that may introduce hard to trace bugs into your tests.

statically replacing the computed property upfront is much more transparent.

@jonnyparris
Copy link

Fair enough, thanks for clarifying.
I'm still pro-choice for developers to decide when's best to use setComputed vs not. It feels like this was introduced for valid crowd-sourced reasons and then removed without really addressing the those initial motivations.

@yx296
Copy link

yx296 commented Mar 15, 2018

I've tried a lot of messing around with overwriting the computed option at mount, but none of it seems to trigger the watcher. I think it's because we specifically need to test that the computed property changes after the mount ---- we are getting the computed property from VueX state which can change after the component is mounted with its initial state from the Vuex store. Also, why have a setData and take out setComputed?

@lmiller1990
Copy link
Member

@yx296 I think the difference between setComputed and setData is setComputed changes the component internals (not clear exactly how) which leads to a state that otherwise wouldn't happen in production, but in the case of setData, it simply assigned a new variable (eg, this.foo = 'bar') which is how vm.data behaves normally.

@LinusBorg
Copy link
Member

@lmiller1990 exactly

@yx296
Copy link

yx296 commented Mar 20, 2018

So there is anyway else besides setComputed to mock changing a computed property after the initial mount? Without having to mock out the entire VueX store? I think this is why setComputed be should left in--- a unit test for a Vue component watcher shouldn't have to involve mocking out the entire VueX Store.

@eddyerburgh

@jorySsense
Copy link

yah i need to mock a computed value after mount.

@lmiller1990
Copy link
Member

lmiller1990 commented Apr 17, 2018

@jorySsense @yx296 can you explain what you are trying to achieve (including the context)? Maybe there is a better way to do so. What situation causes you to what to change a computed value after mount in a unit test?

@DennisSchmidt
Copy link

Example component:

export default {
  computed: {
    isDone: function () {
      return this.$store.getters['isDone']
    }
  },
  watch: {
    isDone: function () {
      if (this.isDone) {
        // do something
      }
    }
  }
}

I could not come up with a solution/setup to trigger the watcher.

Already mounting the component with the computed value set to true will not trigger the watcher.

For example:

wrapper = mount(MyComponent, {
  store,
  localVue,
  computed: {
    isDone: function () { return true }
  }
})

does not work, because it does not trigger the watcher.

@jorySsense
Copy link

Exactly, If i haven't fully mocked my store and don't want to dispatch commits to trigger updates. The setComputed was extremely usefull.

@jorySsense
Copy link

@lmiller1990 , I dont understand your point about production. If you're mocking methods, whats the difference?

@lmiller1990
Copy link
Member

lmiller1990 commented Apr 17, 2018

Thanks for the example @DennisSchmidt . This might help @jorySsense and @yx296 as well.

There are two things to test here:

  1. the isDone property of computed
  • does isDone return the correct values from $store.getters['isDone']
  1. the isDone handler in watch
  • when this handler triggers, does // do something happen?

I assume you are okay to test the first one. Let's talk about the second thing on the list.

You are using setComputed to

  1. change isDone, to ...
  2. ... trigger the watch handler, to...
  3. ... test if the watch handler is working.

But what you really want to do is point 3 (and only point 3). There are some ways to do so, without using setComputed (an external dependency, that is not related to what we want to unit test We want to test that the logic inside of watch is working).

To do this, one technique that I find useful is using call to invoke the method. The first argument of call is the context, or this value. You can then pass whatever values you like, to test the watcher.

Here is an example:

import Component from './component.vue'

describe('watch', () => {
  it('does something', () => {
    const localThis = {
      someValue: 'foo'
    }

    Component.watch.isDone.call(localThis)
    // assertion that something was done
  })
})

This is pretty cool. You don't even need to bother with shallow or mount, if your // do something doesn't have any impact on the markup.

Here is a more solid example. Let's say your watch did something like this:

data() {
  return {
    count: 0
  }
},

watch: {
  isDone: function() {
    this.count += 1
  }
}

When isDone triggers, you want to increase count by 1. You could test like this:

import Compnent from './component.vue'
it('increases', () => {
  const localThis = { count: 0 }
  Component.watch.isDone.call(localThis) 
  // note we did not use render. we are doing export default in the component, so we just get a plain old JavaScript object to play with.

  expect(localThis.count).toBe(1)
})

Cool, right? Super compact, no shallow, no render, nothing else but exactly what you to test.

You probably will have a situation that actually does impact the markup though. Here is how to handle that situation:

<template>
  <div>{{ count }}</div>
</template>

<script>
export default {
  data() {
    return { count: 0 }
  }
},

watch: {
  isDone: function() {
    this.count += 1
  }
}
</script>

Ok, now we need a shallow so we can assert against the markup.

it('increases', () => {
  const wrapper = shallow(Component)

  wrapper.vm.$options.watch.isDone.call(wrapper.vm) // we can access the components `this` from `wrapper.vm`

  expect(wrapper.text()).toBe("1")
})

Great! We could trigger it, without knowing or caring about where or what isDone is.

The main takeaway here is unit tests should be isolated. If one method relies on another to trigger something in order to run the test, it is not a unit test anymore.

I made you a full working example of using call on watchers here. Please see HelloWorld.vue and HelloWorld.spec.js. I used the new vue-cli v3, so you can run the tests with npm run test.

I hope this helps. Let me know if you didn't understand something, or have some input.

@jorySsense
Copy link

Thats awesome! thank you

@jshor
Copy link

jshor commented Jul 6, 2018

@lmiller1990 @eddyerburgh that takes care of testing the watcher, but suppose you want to directly use the variable from the state?

Here's an example:

<template>
  <div>
    <span class="foo foo--present" v-if="foo">Foo is present</span>
    <span class="foo foo--hidden" v-else>Foo is not present</span>
  </div>
</template>

<script>
import { mapState } from 'vuex'

export default {
  name: 'MyFoo',
  computed: mapState(['foo'])
}
</script>

A unit test for this would look like:

import MyFoo from './MyFoo'

describe('MyFoo', () => {
  it('should show foo--present', () => {
    wrapper.setComputed({ foo: true })'

    expect(wrapper.find('foo--present').exists()).toBe(true)
    expect(wrapper.find('foo--hidden').exists()).toBe(true)
  })
})

Seems like a perfectly legitimate case for setComputed(). What would be an alternative?

@lmiller1990
Copy link
Member

lmiller1990 commented Jul 6, 2018

@jshor you can still use the computed mounting option:

describe('MyFoo', () => {
  it('should show foo--present', () => {
    const wrapper = shallowMount(Foo, { 
      computed: { 
        foo() { return true }
      }
    })

    expect(wrapper.find('foo--present').exists()).toBe(true)
    expect(wrapper.find('foo--hidden').exists()).toBe(true)
  })
})

You can scroll through this thread and read about why this is different to setComputed.


A good rule of thumb is try to declare the state of you component up front, when you mount/shallowMount it. It can help make your tests more clear. This is just my personal preference though, based on my own experience. Hopefully this can help you test your Vue app!

PS: if you are using setComputed in an effort to make your code more DRY, and use the same wrapper for each unit test, consider using a factory function to create a fresh instance each time. You can read about that technique in the Vue cookbook.

@jshor
Copy link

jshor commented Jul 6, 2018

@lmiller1990 that will work, but restricts you to having to shallow or mount in each individual test for ones where a computed property value changes.

What if I had several unit tests that varied based on the value of the computed property, but everything else stayed the same? In that case I would no longer be able to write a one-liner that changed the property in that particular unit test--I would instead have to re-create the wrapper each time, and not in a beforeEach as I would normally. This can be a serious headache for components that have lots of unit tests.

@jshor
Copy link

jshor commented Jul 6, 2018

An example to my above comment regarding the utilization of the beforeEach hook for creating the wrapper (it's brief, but you can imagine how using computed in shallow can get out of hand quickly):

describe('MyFoo', () => {
  let wrapper

  beforeEach(() => {
    const wrapper = shallowMount(Foo)
  })

  it('should show foo--present', () => {
    wrapper.setComputed({ foo: true })
    expect(wrapper.find('foo--present').exists()).toBe(true)
    expect(wrapper.find('foo--hidden').exists()).toBe(false)
  })

  it('should hide foo--present', () => {
    wrapper.setComputed({ foo: false })
    expect(wrapper.find('foo--present').exists()).toBe(false)
    expect(wrapper.find('foo--hidden').exists()).toBe(true)
  })
})

the equivalent when using the computed property in shallowMount would be:

describe('MyFoo', () => {
  it('should show foo--present', () => {
    const wrapper = shallowMount(Foo, {
      computed: {
        foo () { return true }
      }
    })
    expect(wrapper.find('foo--present').exists()).toBe(true)
    expect(wrapper.find('foo--hidden').exists()).toBe(false)
  })

  it('should hide foo--present', () => {
    const wrapper = shallowMount(Foo, {
      computed: {
        foo () { return false }
      }
    })
    expect(wrapper.find('foo--present').exists()).toBe(false)
    expect(wrapper.find('foo--hidden').exists()).toBe(true)
  })
})

@lmiller1990
Copy link
Member

lmiller1990 commented Jul 6, 2018

@jshor You can use a factory function like here.

In your case, it might be something like this:

const factory = (computed = {}) => shallowMount(Foo, { computed })

describe('MyFoo', () => {
  it('should show foo--present', () => {
    const wrapper = factory({ foo: () => true })

    expect(wrapper.find('foo--present').exists()).toBe(true)
    expect(wrapper.find('foo--hidden').exists()).toBe(false)
  })

  it('should hide foo--present', () => {
    const wrapper = factory({ foo: () => true })

    expect(wrapper.find('foo--present').exists()).toBe(false)
    expect(wrapper.find('foo--hidden').exists()).toBe(true)
  })
})

You can customize the arguments to factory depending on what state you want the component to start in, including adding data, props etc to the factory. Now you don't need the beforeEach anymore.

Neither the style you presented (using beforeEach and setComputed) or the factory function pattern is more or less readable, in my opinion, both are good. The main difference is the factory pattern lets you avoid setComputed.

The reason removing setComputed is proposed is because it does something you cannot normally do in a Vue component, which is writing to a computed property directly. For example, you will never write MyFoo.foo = true, because foo is a Vuex getter, and you cannot write to getters directly. That would mean the test is doing something different to a real, production component.

The reason passing a computed option to the mounting method is different is because you are simulating a Vuex getter that already has a value when the component is rendered, which is a realistic scenario in a Vue app.

@dobromir-hristov
Copy link
Contributor

dobromir-hristov commented Jul 12, 2018

I must say I came here to see how to trigger a watch after I have mocked out a Vuex getter. I wrote an issue about this some time ago. #456 but that didnt go anywhere.
The watch.func.call method looks kind of stupid as you are manually triggering the watcher, not the "component" but its better than nothing :)

@lmiller1990
Copy link
Member

I think manually triggering it is okay - you want to test the logic in the watcher, not to see if the watcher updates, usually. You already know the watcher will trigger, since that is part of Vue.

Maybe an e2e test would be a good place to see if the component is behaving correctly when the component triggers the watcher.

Each to their own, though. Each app and team is different, I'm definitely guilty of writing a lot of unit tests by not enough e2e tests.

@ilearnio
Copy link

ilearnio commented Sep 19, 2018

For those like me who desperately needs this feature, you can use this helper function. It does the same what wrapper.setComputed does, the only difference is in syntax (used like so setComputed(wrapper, computed)). The code is taken from wrapper.setComputed.

/**
 * Applies hard-coded computed properties to a Vue instance.
 * The code took from a deprecated `wrapper.setComputed`.
 * @see https://github.com/vuejs/vue-test-utils/issues/331
 */
export default function setComputed (wrapper, computed) {
  if (!wrapper.isVueInstance()) {
    throw new Error('setComputed() can only be called on a Vue instance')
  }

  Object.keys(computed).forEach(key => {
    if (!wrapper.vm._computedWatchers[key]) {
      throw new Error(
        'setComputed() was passed a value that does not exist as a computed ' +
        'property on the Vue instance. Property ' + key + ' does not exist ' +
        'on the Vue instance'
      )
    }
    wrapper.vm._computedWatchers[key].value = computed[key]
    wrapper.vm._computedWatchers[key].getter = () => computed[key]
  })

  wrapper.vm._watchers.forEach(watcher => {
    watcher.run()
  })
}

For those who use Vue <= v2.1, see the contents of wrapper.setComputed function, you can find the implementation suitable for your version there

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests