championswimmer / vuex-module-decorators

TypeScript/ES7 Decorators to create Vuex modules declaratively
https://championswimmer.in/vuex-module-decorators/
MIT License
1.8k stars 170 forks source link

How to test components using getModule with vue-test-util #112

Open bethcarslaw opened 5 years ago

bethcarslaw commented 5 years ago

How would you go about stubbing actions and other methods inside of your store when using the getModule functionality inside of a component for testing?

MyComponent.vue

<template>
  <div>
    {{ this.exampleStore.someData  }}
    <button v-on:click="handleClick()">Do An Action</button>
    <LoadingIcon
      :v-if="this.exampleStore.isLoading"
    ></LoadingIcon>
  </div>
</template>

<script lang="ts">
// imports.....

@Component({
  components: {
    LoadingIcon
  }
})
export default class MyComponent extends Vue {
  private exampleStore: ExampleStore = getModule(ExampleStore)

  private created() {
   this.exampleStore.fetchSomeData()
  }

  private handleClick() {
    this.exampleStore.fetchSomeData()
  }
}
</script>

ExampleStore.ts

// imports...
@Module({ dynamic: true, namespaced: true, store, name: 'exampleStore' })
export default class ExampleStore extends VuexModule {
  public someData: any = ''

  @Action({ commit: 'someMutation')}
  public async fetchSomeData() {
  // async stuff

   return data
  }

  @Mutation
  public someMutation(payload: any) {
    return this.someData = payload
  }
}

Test

const localVue = createLocalVue()
localVue.use(Vuex)
let store: Vuex.Store<any>

beforeEach(() => {
  store = new Vuex.Store({})
})

describe('test component', () => {
  it('should have the correct html structure', () => {
    const component = shallowMount(MyComponent, {
     store, localVue
    } as any)
    expect(component).toMatchSnapshot()
  })
})

In the above example I would need to stub the fetchSomeData action

pjo336 commented 5 years ago

Did you ever figure anything out? The syntax/usage of dynamic modules is terrific, but there is 0 info on how to test (not even tests in the "real world" examples)

bethcarslaw commented 5 years ago

@pjo336 I didn't. I was able to get my store running inside of my tests so I could commit dummy data to the mutations. This isn't ideal and it'd be much better to be able to stub the store methods completely. I also had to use nock to mock the endpoints being called by my actions.

gring2 commented 5 years ago

If you commit dummy data like @dalecarslaw does, it remains, even if test case is finished. So. you must clear data manually in like beforeEach function.

ziazon commented 5 years ago

Any updates on this? running into the same issue and I would rather not use store functionality to test my component behavior...

ziazon commented 5 years ago

I got mine to work by using store.hotUpdate() to "swap" the store in following tests.

pjo336 commented 5 years ago

Can you show an example test? Never even had heard of that method

Javalbert commented 5 years ago

Here is an example of using store.hotUpdate():

import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex, { Store, ActionTree } from 'vuex';
import { getModule } from 'vuex-module-decorators';
import FooModule from '@/foo-module.ts';
import FooComponent from '@/components/FooComponent.vue';

describe('FooComponent', () => {
  const localVue = createLocalVue();
  localVue.use(Vuex);

  let actions: ActionTree<ThisType<any>, any>;
  let store: Store<unknown>;

  beforeEach(() => {
    actions = {
      setName: jest.fn(),
    };
    store = new Vuex.Store({
      modules: {
        fooModule: FooModule,
      },
    });
    getModule(FooModule, store); // required
  });

  it('`setName` getter was called', () => {
    store.hotUpdate({
      modules: {
        fooModule: {
          namespaced: true,
          actions,
        },
      },
    });
    const wrapper = shallowMount(FooComponent, {
      localVue,
    });

    const button = wrapper.find('button');
    button.trigger('click');

    expect(actions.setName).toHaveBeenCalled();
  });
});
ziazon commented 5 years ago

sorry for the delay! been a bit busy :/

so lets assume you use getModule(FooModule, this.$store) from inside a component computed property like

get fooModule() {
  return getModule(FooModule, this.$store)
}

then all you have to do is override fooModule in your computed attributes when creating your wrapper, i.e.

    const wrapper = shallowMount(FooComponent, {
      computed: {
        fooModule: () => ({}),
      },
      localVue,
    });

You can also return your own mock version of the store if you like. personally I don't like testing store logic in my components so I override it with an empty object, and mock the other getters that I use to access the store getters, and methods used to access the store mutations and actions.

alecgibson commented 4 years ago

The alternative to this pattern is let's say you have a singleton:

// foo.ts
export default getModule(Foo, store);

...which you call in your component:

import Foo from './foo';

export default class Component extends Vue {
  doSomething() {
    let someArg;
    // Complex logic
    Foo.doSomething(someArg);
  }
}

...then in tests you could stub the singleton:

describe('Component', () => {
  let component;

  beforeEach(() => {
    sinon.stub(Foo, 'doSomething').callsFake(() => {});
    component = shallowMount(Component);
  });

  afterEach(() => {
    sinon.restore();
  });

  it('does something complex', () => {
    component.vm.doSomething();
    expect(Foo.doSomething).to.have.been.calledWith('abc');
  });
});
souphuhn commented 4 years ago

@alecgibson I was very excited to test your proposal, since that is exactly my pattern. But sadly this didnot work for me :( sinon.stub(..) is not stubbing/mocking my StoreModule and the test is still suing the original StoreModule :(

@jubairsaidi I dont have the computed property in my mock options... :(

alecgibson commented 4 years ago

@souphuhn I've since changed away from this pattern. We're now adding the store singleton to the Vue prototype:

Vue.prototype.$foo = getModule(Foo, store);

...which means that in the components, you can just mock it using mocks:

const component = shallowMount(Component, {
  $foo: {doSomething: sinon.spy()},
});
souphuhn commented 4 years ago

@alecgibson Thank you so much for the fast reply. Firstly, your approach of Vue.prototype.$foo = getModule(Foo, store) seems nice. I've adopted my code for this and it works great.

Secondly I am still struggling of mocking this global property this.$foo: I have added the mock to shallowMount mock options as

const mocks = { $foo: myMockedFoo };  
const component = shallowMount(Component, {mocks});

My component is accessing someData of FooModule like

get store() : FooModule {
  return this.$foo;

get someData (): any {
  return this.store.someData;
}

In this way, my component is accessing correctly someData when I ran the test without my mockedModule inside shallowMount. But when I ran my test with my mocked module, someData is suddenly undefined now. Something is still wrong of my mockedModule I guess. Or something else.. :( I used the module syntax

const myMockedFoo  = {
  state: {
    someData: 'mockedValue'
  }
}
alecgibson commented 4 years ago

Shouldn't you have:

const myMockedFoo = {
  someData: 'mockedValue',
};

?

Pretty sure this would become obvious if you inspect the runtime value of this.$foo inside your component.

souphuhn commented 4 years ago

@alecgibson Thank you! It works like a charm

dgroh commented 4 years ago

Oh man, I love this pattern, I was using

export const overviewModule = getModule(OverviewModule);

which made my code untestable.

Now I'm using:

Vue.prototype.$overview = getModule(OverviewModule);

And it works great.

dgroh commented 4 years ago

My tests are working with this pattern, but something is still not correct the way I register the store, so that when I serve my application I get that $overview is undefined, here is my store index.ts:

import Vue from "vue";
import Vuex from "vuex";
import { OverviewState } from "./overview-module";

Vue.use(Vuex);

export interface RootState {
  overview: OverviewState;
}

// Declare empty store first, dynamically register all modules later.
export default new Vuex.Store<RootState>({});

Here is my module:

export interface OverviewState {
  items: GlossaryEntry[];
  filter: OverviewFilterOptions;
}

@Module({ dynamic: true, store, name: "overview" })
export class OverviewModule extends VuexModule implements OverviewState {
  public items: GlossaryEntry[] = [];
  public filter: OverviewFilterOptions = {
    contains: "",
    page: 0,
    pageSize: 20,
    desc: false
  };

  @Mutation
  private async UPDATE_OVERVIEW(filter: OverviewFilterOptions) {
    this.filter = Object.assign({}, filter);

    await overviewService
      .filter(this.filter)
      .then((response: Response<Overview>) => {
        this.items = Object.assign({}, response.data.items);
      });
  }

  @Action
  public async updateOverview(filter: OverviewFilterOptions) {
    this.UPDATE_OVERVIEW(filter);
  }
}

Vue.prototype.$overview = getModule(OverviewModule);

Could please someone help?

alecgibson commented 4 years ago

@dgroh it looks like you have a circular dependency here? You import store into OverviewModule, but store itself also has a dependency on OverviewModule.

I suspect if you remove the RootState interface (and its dependency on OverviewState, everything should work? Also, why do you even need RootState? Isn't the whole point of this library/pattern that you can access the store through these type-safe modules anyway?

dgroh commented 4 years ago

This was a good point. It "works" now, but when I use this.$overview in one specific component, my entire application breaks. I don't get why.

  private updateOverview(value: string) {
    this.$overview.updateOverview({ contains: value }); // this breaks my entire app
  }

This private updateOverview is an event, it only gets invoked on button click. So I don't understand why commenting out it brings everything to work again.

I use this.$overview.updateOverview in other components, too and it works, but only when I use in this specific one everything breaks. I assume this is something related with the app hooks.

image

Robin-Hoodie commented 4 years ago

Is there still not a better solution or any documentation related to this ? We're nearing 2021 and this issue is so far still the best source of documentation I can find on testing dynamic Vuex modules.

Robin-Hoodie commented 3 years ago

What works best for me:

Assuming you have a dynamic module declared as follows:

// @/store/modules/my-module.ts
import { getModule, Module, VuexModule } from "vuex-module-decorators";
import store from "@/store";

@Module({ name: "myModule", store, dynamic: true})
export class MyModule extends VuexModule { 
  // state, getters, mutations, actions
}

export const myModule = getModule(MyModule);

Importing this module in a component as follows

// @/components/MyComponent.vue
import { myModule } from "@/store/modules/my-module";

In a test I set up mocks as following

// @/components/__tests__/MyComponent.spec.ts
import { mocked } from "ts-jest";
import { myModule } from "@/store/modules/my-module";

jest.mock("@/store/modules/my-module")

// Provide mock typing. This does seem to wrongly assume that getters have also been mocked by jest.mock, but it does work nicely for actions and mutations
const myModuleMock = mocked(myModule);

// All mutations and actions are now mocked with default `jest.fn` (which just returns `undefined`)

// Mocking state (you should probably only access state through getters though)
myModuleMock.someStateProp = //Whatever I want to mock as a value

// Mocking a getter, Jest does not mock getters. @ts-expect-error required for suppresing TS2540 (can't assign to read-only prop)
// @ts-expect-error
myModuleMock.someGetter = //whatever I want to mock as a value
// Mocking a getter that returns a function
// @ts-expect-error
myModuleMock.someGetter =() => { /* whatever I want to mock as a return value */ }

// Mocking a mutation for test suite
myModuleMock.someMutation.mockImplementation(() => /* Some implementation */)
// Mocking a mutation with a specific implementation in one test
myModuleMock.someMutation.mockImplementationOnce(() => /* Some implementation */)

// Mocking an action for test suite, best to use promises as actions are async
myModuleMock.someAction.mockImplementation(() => Promise.resolve())
// Mocking an action with a specific implementation in one test
myModuleMock.someAction.mockImplementationOnce(() => Promise.resolve())
itspauloroberto commented 3 years ago

@Robin-Hoodie I tried to use this way but the getter keeps returning undefined to the component and then it gives an error because it tries to access a getter property and the getter is undefined.

FlorentinBurgeat commented 3 years ago

@Robin-Hoodie Thank you it's been a week i've been struggling with this lib! Weirdly, I didn't have any issue on mocking my getter. But, I can't change the value once the component mounted (but it kind be ok for unit testing)

Also, for people in future reading this and wanting to make an app with this lib and having call to actions/mutations/getters in other modules, don't try to do it with a static modules, it doesn't work. Make your store with dynamic modules and test it the way Robin Hoodie does it.

Also, here are some interesting links that helped me: A better doc for this lib A review of other libs wich gave me some example