vue.jsvuexvue-test-utils

Vuex Store keeps state somehow between tests


I have a strange problem with testing the frontend part of my project. I use Vue components for the frontend. The project is website for teachers to set appointments for the assistent(s) so the assistant can ready everything for class.

First let me explain the structure.

I have a component which lists all appointments by date. Every date is a seperate card and all the appointments for one date are rows on that card. Each row is a specific timeslot. Appointments can be added to the list either by clicking on a button at the top of the card or by clicking the row number.

So I created three components: AppointmentsList.Vue which gets the appointments from the backend and builds a list of the cards, AppointmentsCard.Vue, which receives the date and all the appointments for that date as props, and lastly AppointmentRow.Vue which show the detailsof the appointment in a row of the table. The state, appointments, requested dates and other data, is kept in a Vuex store.

I build the project using TDD, using Jest and Vue-test-utils for writing the tests. Mocks are used to simulate the responses of the backend. Tests showing the appointments with the cards and rows works fine. But when testing the buttons I encountered a strange problem.

In the following code I show you my tests, redacted for brevity. First everything is imported and then the responses of the backend for varieous endpoints are defined. Only the appointmentResponse is important. Note that two appointments are returned.

The function createStore builds a store out of the modules. I keep all state, getters and mutations in modules. Before each test is run, I create a new store and initialize the store with the data of the responses using the mutations of the store. After each test the jest mocks are cleared and the vue-test-utils wrapper is destroyed.

*AppointmentList.spec.js*

import {mount, createLocalVue} from '@vue/test-utils'
import flushPromises from 'flush-promises'
import AppointmentsList from '../../resources/js/components/AppointmentsList.vue'
import axios from 'axios'
import Vuex from 'vuex'
import appointmentsmodule from '../../resources/js/storemodules/appointmentsModule.js'
import classhoursmodule from '../../resources/js/storemodules/classhoursModule.js'
import classroomsmodule from '../../resources/js/storemodules/classroomsModule.js'
import experimentsmodule from '../../resources/js/storemodules/experimentsModule.js'
import locationsmodule from '../../resources/js/storemodules/locationsModule.js'
import subjectsmodule from '../../resources/js/storemodules/subjectsModule.js'
import usersmodule from '../../resources/js/storemodules/usersModule.js'

import Vue from 'vue'
import { wrap } from 'lodash'

let appointmentResponse={"status":200,"lines":2,"data":[{"id":1,"subject_id":1,"owner_id":1,"group_id":1,"appointment_at":"2021-12-29T00:00:00.000000Z","classhour_id":1,"classroom_id":1,"experiment_id":null,"short_name":"magnam","description":"Sit cum quae quae quo quo consequatur.","demo":"0","toa_preferred_id":null,"location_id":1,"created_at":"2022-01-04T15:50:26.000000Z","updated_at":"2022-01-04T15:50:26.000000Z"},{"id":2,"subject_id":2,"owner_id":1,"group_id":1,"appointment_at":"2021-12-28T00:00:00.000000Z","classhour_id":1,"classroom_id":1,"experiment_id":null,"short_name":"possimus","description":"Velit eos sed esse reprehenderit.","demo":"0","toa_preferred_id":null,"location_id":1,"created_at":"2022-01-04T15:50:26.000000Z","updated_at":"2022-01-04T15:50:26.000000Z"}]}
let userResponse={"status":200,"lines":3,"data":[{"id":1,"code":"Est","name":"Mr. Americo Mertz I","email":"user1@hetstreek.nl","actual_location":"1","registrar":"1","email_verified_at":"2022-01-04T15:50:26.000000Z","created_at":"2022-01-04T15:50:26.000000Z","updated_at":"2022-01-04T15:50:26.000000Z","department_location_id":null,"welcome_valid_until":null,"roles":[{"id":2,"name":"toa","guard_name":"web","created_at":"2022-01-04T15:50:24.000000Z","updated_at":"2022-01-04T15:50:24.000000Z","pivot":{"model_id":"1","role_id":"2","model_type":"App\\Models\\User"}},{"id":1,"name":"beheerder","guard_name":"web","created_at":"2022-01-04T15:50:22.000000Z","updated_at":"2022-01-04T15:50:22.000000Z","pivot":{"model_id":"1","role_id":"1","model_type":"App\\Models\\User"}}]},{"id":2,"code":"Est","name":"Mr. Americo Mertz I","email":"user2@hetstreek.nl","actual_location":"2","registrar":"1","email_verified_at":"2022-01-04T15:50:26.000000Z","created_at":"2022-01-04T15:50:26.000000Z","updated_at":"2022-01-04T15:50:26.000000Z","department_location_id":null,"welcome_valid_until":null,"roles":[{"id":3,"name":"docent","guard_name":"web","created_at":"2022-01-04T15:50:25.000000Z","updated_at":"2022-01-04T15:50:25.000000Z","pivot":{"model_id":"2","role_id":"3","model_type":"App\\Models\\User"}}]},{"id":3,"code":"Est","name":"Mr. Americo Mertz I","email":"user3@hetstreek.nl","actual_location":"1","registrar":"1","email_verified_at":"2022-01-04T15:50:26.000000Z","created_at":"2022-01-04T15:50:26.000000Z","updated_at":"2022-01-04T15:50:26.000000Z","department_location_id":"{\"department_id\":1,\"location_id\":1,\"updated_at\":\"2022-01-04T15:50:26.000000Z\",\"created_at\":\"2022-01-04T15:50:26.000000Z\",\"id\":1}","welcome_valid_until":null,"roles":[{"id":4,"name":"leerling","guard_name":"web","created_at":"2022-01-04T15:50:26.000000Z","updated_at":"2022-01-04T15:50:26.000000Z","pivot":{"model_id":"3","role_id":"4","model_type":"App\\Models\\User"}}]}]}
let classhourResponse={"status":200,"lines":2,"data":[{"id":1,"name":"9","starttime":"15:22","endtime":"12:56","location_id":"1","created_at":"2022-01-04T15:50:26.000000Z","updated_at":"2022-01-04T15:50:26.000000Z"},{"id":3,"name":"1","starttime":"21:47","endtime":"20:16","location_id":"1","created_at":"2022-01-04T15:50:26.000000Z","updated_at":"2022-01-04T15:50:26.000000Z"}]}
let classroomResponse={"status":200,"lines":2,"data":[{"id":1,"name":"non","number":"756","in_use":"1","student_accessible":"0","teacher_accessible":"1","location_id":"1","created_at":"2022-01-04T15:50:26.000000Z","updated_at":"2022-01-04T15:50:26.000000Z"},{"id":3,"name":"ut","number":"214","in_use":"1","student_accessible":"0","teacher_accessible":"1","location_id":"1","created_at":"2022-01-04T15:50:26.000000Z","updated_at":"2022-01-04T15:50:26.000000Z"}]}
let experimentResponse={"status":200,"lines":2,"data":[{"id":1,"name":"nam","description":"Aliquam nihil voluptas aut vel neque.","student_selectable":"0","user_id":"1","created_at":"2022-01-04T15:50:26.000000Z","updated_at":"2022-01-04T15:50:26.000000Z"},{"id":3,"name":"similique","description":"Exercitationem officiis excepturi aut veniam voluptatum.","student_selectable":"1","user_id":"1","created_at":"2022-01-04T15:50:26.000000Z","updated_at":"2022-01-04T15:50:26.000000Z"}]}
let locationResponse={"status":200,"lines":2,"data":[{"id":1,"name":"Omnis.","school_id":"1","created_at":"2022-01-04T15:50:26.000000Z","updated_at":"2022-01-04T15:50:26.000000Z"},{"id":2,"name":"Illo.","school_id":"1","created_at":"2022-01-04T15:50:26.000000Z","updated_at":"2022-01-04T15:50:26.000000Z"}]}
let schoolResponse={"status":200,"lines":1,"data":[{"id":1,"schoolname":"Jaydon Mante","domain":"hetstreek.nl","max_locations":"1","payed_at":null,"due_date":null,"active":"1","storage_folder":"jaydonmante_hetstreeknl","created_at":"2022-01-05T14:41:49.000000Z","updated_at":"2022-01-05T14:41:49.000000Z"}]}
let subjectResponse={"status":200,"lines":2,"data":[{"id":1,"code":"SK","description":"Quis.","block_for_days":"9","color":"A3A3A3","created_at":"2022-01-05T18:54:41.000000Z","updated_at":"2022-01-05T18:54:41.000000Z"},{"id":2,"code":"re","description":"Libero.","block_for_days":"3","color":"A3A3A3","created_at":"2022-01-05T18:54:41.000000Z","updated_at":"2022-01-05T18:54:41.000000Z"}]}

function createStore(){
    return {
        modules:{
            appointments:appointmentsmodule,
            classhours:classhoursmodule,
            classrooms:classroomsmodule,
            experiments:experimentsmodule,
            locations:locationsmodule,
            subjects: subjectsmodule,
            users: usersmodule
        }
    }
}
let wrapper
let store
beforeEach(()=>{
    const newStore=createStore()
    //define a new store for each test
    store=new Vuex.Store(newStore)
    //sets the range of dates for which the appointments need to be shown.
    let startdate = new Date(2021, 11, 27)
    let enddate = new Date(startdate)
    enddate.setDate(startdate.getDate()+3)
    //initialize the store
    store.commit('setAppointmentsPeriod',{startdate,enddate})
    store.commit('setSubjectFilter', [])
    store.commit('storeAppointments',[])
    store.commit('storeClasshours',classhourResponse.data)
    store.commit('storeClassrooms',classroomResponse.data)
    store.commit('storeExperiments',experimentResponse.data)
    store.commit('storeLocations',locationResponse.data)
    store.commit('storeSubjects',subjectResponse.data)
    store.commit('storeUsers',userResponse.data)
    //log to show the appointments are empty
    console.log(store.state.appointments.appointments)

    jest.clearAllMocks()
    
})

afterEach(()=>{
    jest.clearAllMocks
    wrapper.destroy()
})

jest.mock("axios")

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

When the component AppointmentsList is mounted in the test it detects the change of period from the store, which triggers a request to the backend and loads the appointments for that period.

The first test tests the button a top of the card. It triggers the button and verifies the modal to add an appointment is opened, fills in the form, triggers the submit button and verifies that the data is send to the backend, and the store is updated so now three appointments should be in the store. This works fine.

test('add button adds appointment to list', async ()=>{
        //set up the mock data.
        let classhour=1
        let appointmentToAdd={
            id:3,
            owner_id:1,
            experiment_id:null,
            short_name:"Schaduwpracticum",
            description:"Lichtkastje met voeding, kartonnetje, scherm, liniaal",
            group_id:1,
            subject_id:1,
            toa_preferred_id:1,
            appointment_at:"2021-12-27",
            classhour_id:classhour,
            classroom_id:1,
            demo:false,
            location_id:1,
            created_at:new Date(),
            updated_at:new Date()
        }
        //mock axios responses. Get returns the appointments, post returns the added appointment. 
        axios.get.mockResolvedValue({status:200 , data:appointmentResponse.data})
        axios.post.mockResolvedValue({status:200, data:{"status":200, "lines":1,"data":appointmentToAdd}})
        //appointment is added for this date.
        let checkDate=new Date(2021,11,27)
        
        //show all two appointments
        wrapper = mount(AppointmentsList, {store, localVue})
        await flushPromises()
        //verify two appointments in the store
        expect(store.state.appointments.appointments).toHaveLength(2)
        //find add button for first date
        //click buttons open add/edit modal with date field prefilled
        await wrapper.find('[data-cy="20211227"]').trigger('click')

        const wrappedAddAppointment=wrapper.findComponent({name:'add-appointment'})
        //check modal is opened with correct date
        expect(wrappedAddAppointment.vm.$props.modalState).toBe(true)
        expect(wrappedAddAppointment.vm.$data.appointment.appointment_at).toStrictEqual(checkDate)
        //fill in fields and click submit
        await wrappedAddAppointment.find('input[id="shortname"]').setValue(appointmentToAdd.short_name)
        await wrappedAddAppointment.find('input[id="description"]').setValue(appointmentToAdd.desc)
        await wrappedAddAppointment.find('input[id="teacher"]').setValue(1)
        await wrappedAddAppointment.find('input[id="group"]').setValue(appointmentToAdd.group_id)
        await wrappedAddAppointment.find('input[id="subject"]').setValue(appointmentToAdd.subject_id)
        await wrappedAddAppointment.find('input[id="preferredtoa"]').setValue(appointmentToAdd.toa_preferred_id)
        await wrappedAddAppointment.find('input[id="appointment_at"]').setValue(appointmentToAdd.appointment_at)
        await wrappedAddAppointment.find('input[id="classhour"]').setValue(appointmentToAdd.classhour_id)
        await wrappedAddAppointment.find('input[id="classroom"]').setValue(appointmentToAdd.classroom_id)
        await wrappedAddAppointment.find('input[id="demo"]').setChecked(false)
        await wrappedAddAppointment.find('button[name="save-button"]').trigger('click')
        //check axios post(/appointments) is called
        expect(axios.post).toHaveBeenCalledTimes(1)
        expect(axios.post.mock.calls[0][0]).toContain('/appointments')
        expect(axios.get).toHaveBeenCalledTimes(2)
        //verify the modal is closed
        expect(wrappedAddAppointment.vm.$props.modalState).toBe(false)
        //verify the appointment is added to the store
        expect(store.state.appointments.appointments).toHaveLength(3)
       
        //verify added appointment is in list
        expect(wrapper.text()).toContain(appointmentToAdd.short_name)
    })

The next test tests clicking the row number. It should also open the AddAppointment modal with date and classhour (=row number) prefilled. As adding the appointment is already tested, the test stops.

test('clicking classhour adds appointment to list', async ()=>{
        //setup ajax responses
        axios.get.mockResolvedValue({status:200 , data:appointmentResponse.data})
        axios.post.mockResolvedValue({status:200, data:{"status":200, "lines":1,"data":appointmentToAdd}})
        
        let checkDate=new Date(2021,11,27)
        console.log(store.state.appointments.appointments)
        //show all appointments
        const wrapper = mount(AppointmentsList, {store, localVue})
        await flushPromises()

        expect(axios.get).toHaveBeenCalledTimes(1)
-->     expect(store.state.appointments.appointments).toHaveLength(2)
        //test fails on line above. Added next two lines to check the appointments in the store and the return value of the axios call.
        console.log(store.state.appointments.appointments) //shows three appointments with the last one being the appointment added in the previous test
        console.log(axios.get.mock.results[0].value) //shows only two appointments returned as expected
        
        //find classhour button of date 27-dec-2021 and classhour 3
        await wrapper.find('[data-cy="20211227classhour3"]').trigger('click')
        
        const wrappedAddAppointment=wrapper.findComponent({name:'add-appointment'})
        
        //check modal is opened with correct date and classhour
        expect(wrappedAddAppointment.vm.$props.modalState).toBe(true)
        expect(wrappedAddAppointment.vm.$data.appointment.appointment_at).toStrictEqual(checkDate)
        expect(wrappedAddAppointment.vm.$data.appointment.classhour.id).toBe(3)
    })

This test fails at the line marked with an arrow. Jest reports not two appointments but three in the store. As I have rebuild the store between tests, this make no sense to me. The added appointment should no longer be in the store. The mock call clearly shows two appointments in the response. But it gets stranger even more. Instead of adding the two initial appointments to the store I decided to change the response to return no appointments. I changed the mockresponse to:

axios.get.mockResolvedValue({status:200 , data:{status:200, lines:0, data:[]} })

and the verification to

expect(store.state.appointments.appointments).toHaveLength(0) 

This test passes, so the added appointment from the first test is not retained. Can anyone help me shed some light on this?

Edit. Added the appointmentModule as requested in the comments.

const appointmentsmodule = {
    state(){
        return{
            appointments:[],
            appointmentPeriod:{
                startdate:null,
                enddate:null
            },
            subjectFilter:[],
        }
    },
    mutations:{
        storeAppointments(state, appointments){
            //console.log('storeAppointments', appointments)
            state.appointments = appointments
        },
        setAppointmentsPeriod(state, period){
            //console.log('setAppointmentsPeriod', period)
            
            state.appointmentPeriod = period
        },
        setSubjectFilter(state, filter){
            //console.log('set Filter', filter)
            
            state.subjectFilter= filter
        },
        addAppointment(state, appointmentToAdd){
            state.appointments.push(appointmentToAdd)
        }
    },
    getters:{
        getFilteredAppointments: state=>{
            //console.log('getting filtered appointments')
            if(state.subjectFilter.length>0){
                ////console.log('filtered')
                return state.appointments.filter(appointment=>{
                    /* //console.log(state.subjectFilter)
                    //console.log(appointment.subject_id)
                    //console.log(state.subjectFilter.includes(appointment.subject_id)) */
                    return state.subjectFilter.includes(appointment.subject_id)})
            }
            else{
                ////console.log('no filter')
                return state.appointments
            }
        },
        getAppointmentsPeriod(state){
            //console.log('getting  appointments period')
            
            return state.appointmentPeriod
        }
    }
}

export default appointmentsmodule

Solution

  • I solved the problem. When storing the response from the backend I replaced the array in the vuex.state with the new array, thereby breaking reactivity. When I use

    state.appointments.splice(0,Infinity, ...newAppointments)
    

    all the elements of the original array are replaced with the new elements. Reactivity is preserved and the tests passes