Vue Fes Japan 2018 の 1年間単体テストを書き続けた現場から送る Vue Component のテスト https://vuefes.jp/
ྑ(.01FQBCP *OD7VF'FT+BQBOؒ୯ମςετΛॻ͖ଓ͚ͨݱ͔ΒૹΔ7VF$PNQPOFOUͷςετ
View Slide
IUUQTUTVDIJLB[VOFU(.0ϖύϘ&$ࣄۀ෦ςΫχΧϧϦʔυྑ!UTVDIJLB[V୲αʔϏεΧϥʔϛʔϦϐʔτ
$PNQPOFOUͷςετॻ͍͍ͯΔਓ
Α͘ฉ͘6*ͷςετมߋ͕ൃੜ͘͢͠ίετʹݟ߹Θͳ͍ΜͩΑͶେࣄͳϩδοΫ$PNQPOFOUͷ֎ʹग़ͯ͠ςετ͍ͯ͠Δ͔Β͍Βͳ͍͔ͳͱ
ͦ͏ݴ͔ͬͯͩΒ͔ͬ͠Γͱಈ࡞֬ೝ͠Α͏ςετ͕ͳ͍ͱҙਤ͠ͳ͍มߋ͕ى͖ͳ͍͔ෆ҆ʜ
ָͰ͖Δͱ͜Ζָͯ͠ෆ҆ղফ͍͖͍ͯͨ͠
ͦͷͨΊʹݱͰ͍ͬͯΔ͜ͱ
w$PNQPOFOUͷԿΛςετ͢Δͷ͔ʁwͲ͏ͬͯςετ͢Δʁw-JGFDZDMFฤw1SPQT7VFY4UBUFฤw6TFS*OUFSBDUJPOฤ"HFOEB
ԿΛςετ͢Δʁ
֎෦͔ΒݟͨৼΔ͍Λςετ͢Δ
DPNQPOFOUͷςετͦ͏Ͱͳ͍ςετͱߟ͑ํҰॹ
!UXBEB2ϓϥΠϕʔτϝιουͷϢχοτςετॻ͔ͳ͍ͷʁϓϥΠϕʔτϝιουͷϢχοτςετॻ͔ͳ͍ͷʁ2"!*5IUUQTRBBUNBSLJUDPKQR"͘·ͱΊΔͱɺϓϥΠϕʔτͳϝιουͷςετΛॻ͘ඞཁແ͍ͱߟ͍͑ͯ·͢ɻதུϓϥΠϕʔτϝιου࣮ͷৄࡉͰ͋ΓɺࣗಈςετͷλʔήοτͱͳΔʮ֎෦͔ΒݟͨৼΔ͍ʯͰ͋Γ·ͤΜɻ
৫ʹςετΛॻ͘จԽΛ͔ࠜͤΔઓུͱઓज़IUUQTTQFBLFSEFDLDPNUXBEBTUSBUFHZBOEUBDUJDTPGCVJMEJOHBVUPNBUFEUFTUJOHDVMUVSFJOUPPSHBOJ[BUJPO TMJEF
$PNQPOFOUͰ͍͏ͱʁ
1VCMJD*OUFSGBDF$PNQPOFOU-JGFDZDMF7VFY4UBUF1SPQT6TFS*OUFSBDUJPO
0VUQVU7VFY"DUJPO&WFOU)5.-$44$PNQPOFOU
7VFY"DUJPO&WFOU)5.-$44$PNQPOFOU-JGFDZDMF7VFY4UBUF1SPQT6TFS*OUFSBDUJPO7VF/:$$PNQPOFOU5FTUTXJUI7VFKT.BUU0$POOFMM:PV5VCFIUUQTXXXZPVUVCFDPNXBUDI W0*QG855IS,ςετλʔήοτ
NPVOUPSTIBMMPXNPVOU$PNQPOFOUNPVOU$IJME$PNQPOFOU$PNQPOFOU$IJME$PNQPOFOUTIBMMPXNPVOUParentParent
wجຊతʹNPVOUwʮ֎෦͔ΒΈͨৼΔ͍ʯwʮઃܭͷՄಈҬΛ֬อʯwରDPNQPOFOUwSPVUFS͔ΒಡΈࠐΉ͍ΘΏΔ1BHF$PNQPOFOUwෳࡶͳPSڞ௨Ͱ༻͢Δ$PNQPOFOUw1BHF$PNQPOFOUͰΧόʔ͕໘ࠓͷϓϩδΣΫτͰ
Ͳ͏ͬͯςετ͢Δʁ
+FTU.PDIB,BSNB7VF5FTU6UJMT*TUBOCVM$IBJQPXFSBTTFSU4JOPOΑ͘͏ςετπʔϧͷׂΛཧ͠Α͏
NPDLϥΠϒϥϦςεςΟϯάϑϨʔϜϫʔΫςετϥϯφʔ࣮ߦڥ"TTFSUJPO࣮ϒϥβ KTEPN,BSNB.PDIB+FTU$PWFSBHF4JOPO*TUBOCVM$PNQPOFOU5FTU6UJMJUZ7VF5FTU6UJMT$IBJQPXFSBTTFSU
NPDLϥΠϒϥϦςεςΟϯάϑϨʔϜϫʔΫςετϥϯφʔ࣮ߦڥ"TTFSUJPO࣮ϒϥβ KTEPN,BSNB.PDIB+FTU$PWFSBHF4JOPO$IBJQPXFSBTTFSU*TUBOCVM$PNQPOFOU5FTU6UJMJUZ7VF5FTU6UJMT࣮ϒϥβ͕ඞཁͳͱ͖࣮ϒϥβ͕ෆཁͳͱ͖
-JGFDZDMFʹର͢Δςετίʔυ
-JGFDZDMFͷςετ7VFY"DUJPO&WFOU)5.-$44$PNQPOFOU-JGFDZDMF
wΑ͘ॲཧ͕ॻ͔ΕΔMJGFDZDMFIPPLwDSFBUFEwNPVOUFEw/VYUͳͲ443͍ͯ͠Δ߹wBTZOD%BUBwGFUDI-JGFDZDMFͷςετ
࣮ྫ-JGFDZDMFcreated () {this.loadPosts()},methods: {...mapActions(['fetchPosts']),async loadPosts () {this.message = 'ಡΈࠐΈத...'try {await this.fetchPosts()} finally {this.message = ‘'}}},.FUIPEݺͼग़͠
࣮ྫ-JGFDZDMFcreated () {this.loadPosts()},methods: {...mapActions(['fetchPosts']),async loadPosts () {this.message = 'ಡΈࠐΈத...'try {await this.fetchPosts()} finally {this.message = ‘'}}},දࣔมߋ
࣮ྫ-JGFDZDMFcreated () {this.loadPosts()},methods: {...mapActions(['fetchPosts']),async loadPosts () {this.message = 'ಡΈࠐΈத...'try {await this.fetchPosts()} finally {this.message = ‘'}}},"DUJPOEJTQBUDI
ςετྫ-JGFDZDMF// ϩʔΧϧͳVueίϯετϥΫλʹVuexΛΠϯετʔϧconst localVue = createLocalVue()localVue.use(Vuex)beforeEach(() => {// fetchPostsΞΫγϣϯͷಈ࡞֬ೝͷͨΊͷVuexपΓͷઃఆactions = {fetchPosts: sinon.stub() // fetchPostsΞΫγϣϯͷϞοΫ}store = new Vuex.Store({actions})wrapper = mount(Posts, {store, localVue })})͓ܾ·Γ
ςετྫ-JGFDZDMF// ϩʔΧϧͳVueίϯετϥΫλʹVuexΛΠϯετʔϧconst localVue = createLocalVue()localVue.use(Vuex)beforeEach(() => {// fetchPostsΞΫγϣϯͷಈ࡞֬ೝͷͨΊͷVuexपΓͷઃఆactions = {fetchPosts: sinon.stub() // fetchPostsΞΫγϣϯͷϞοΫ}store = new Vuex.Store({actions})wrapper = mount(Posts, {store, localVue })})TUPSFͷϞοΫΛ࡞
ςετྫ-JGFDZDMFit('loadingදࣔ͞ΕΔ͜ͱ', async () => {expect(wrapper.text()).to.contain('ಡΈࠐΈத...')})it('fetchPosts͕dispatch͞ΕΔ͜ͱ', () => {sinon.assert.calledOnce(actions.fetchPosts)})it('dispatchޭ͢Δͱɺloadingද͕ࣔফ͑Δ͜ͱ', async () => {actions.fetchPosts.resolves()await flushPromises()expect(wrapper.text()).not.to.contain('ಡΈࠐΈத...')})
w࣮ࡍʹɺදࣔʹඞཁͳσʔλऔಘͷͨΊʹEJTQBUDI͢Δ͙Β͍w͜ΕΛςετͯ͠ಘΒΕΔ҆৺ײ͍wଞͷςετΛॻ͍ͨ΄͏͕Α͍w-JGFDZDMFIPPLNPDLԽͯ͠ɺଞͷςετͷअຐʹͳΒͳ͍Α͏ʹ͢Δͷͭͷख-JGFDZDMFͷςετΛͯ͠Έͯ
1SPQT7VFY4UBUFʹର͢Δςετίʔυ
1SPQT7VFY4UBUFͷςετ)5.-$44$PNQPOFOU7VFY4UBUF1SPQT
࣮ྫදࣔ·ͩߘ͋Γ·ͤΜλΠτϧ༰{{post.title}}{{post.body}}݅ͷͱ͖දࣔΛม͑Δ
ςετྫ୯७ͳBTTFSUdescribe('ߘ͕0݅ͷ߹', () => {beforeEach(() => wrapper.setProps({posts: []})it('0݅ͷද͕ࣔ͞Ε͍ͯΔ͜ͱ', () => {expect(wrapper.text()).to.contain('·ͩߘ͋Γ·ͤΜ')})})describe('ߘ͕1݅Ҏ্ͷ߹', () => {const post = {id: 1, title: ‘λΠτϧ’, body: ‘body’}beforeEach(() => wrapper.setProps({posts: [post]})it('ߘͷ༰͕දࣔ͞Ε͍ͯΔ͜ͱ', () => {expect(wrapper.text()).to.contain(post.title)})})
w FYQFDU XSBQQFSUFYU UPDPOUBJO b·ͩߘ͋Γ·ͤΜ`wදࣔมΘͬͨΒɺςετ͢w FYQFDU XSBQQFSUFYU UPDPOUBJO QPTUYYw Ͳ͜ʹදࣔ͞Ε͍ͯΔʁͬ͘͟Γ͗͢͠wࡉ͔ͨ͘͠Βϝϯςେม୯७ͳBTTFSUͷͭΒ͞ˠ͜ΕΒΛ͍͍ײ͡ʹΓ͍ͨ
4OBQTIPU5FTUJOH
NPDLϥΠϒϥϦςεςΟϯάϑϨʔϜϫʔΫςετϥϯφʔ࣮ߦڥ"TTFSUJPO࣮ϒϥβ KTEPN,BSNB.PDIB+FTU$PWFSBHF4JOPO$IBJQPXFSBTTFSU*TUBOCVM$PNQPOFOU5FTU6UJMJUZ7VF5FTU6UJMT͜ΕͩͱՄೳ
w DPNQPOFOUͷ%0.ʢTOBQTIPUʣΛൺֱ͢Δw ίʔυमਖ਼લͷ%0.Λظͱͯ͠ɺࠓͷ%0.ͱൺֱ4OBQTIPU5FTUJOH
w ͕ࠩͳ͚ΕTVDDFTTw ͕ࠩ͋ΕҰGBJMw ։ൃऀ͕ࠩΛ֬ೝw ҙਤతͳมߋͰ͋Ε มߋޙͷ%0.Λ࣍ͷظʹ͏Α͏ʹ VQEBUF͢Δ4OBQTIPU5FTUJOH
w %0.ͷมߋ͍͍ײ͡ʹςετͰ͖ΔΑ͏ʹͳΔw DPNQPOFOUɺ)5.-$44w $44ͷςετ͍ͨ͠4OBQTIPU5FTUJOHͷͭΒ͞
7JTVBM5FTUJOH
w 4OBQTIPU5FTUJOHͷը૾൛w ը૾ͷࠩݕग़πʔϧ͕ผʹඞཁw ৭ʑͳํ๏͕͋Δ7JTVBM5FTUJOH
ࢲ͕࠾༻ͨ͠࠷ߴͷ7JTVBM5FTUJOHͷํ๏ʙ1SPQT7VFY4UBUFฤʙ
w QSPQT7VFYTUBUFΛ༩͑ͯදࣔύλʔϯΛ֬ೝͰ͖Δw DPNQPOFOU୯ҐͳͷͰΤοδέʔεʹରԠԽw ؆୯ʹTDSFFOTIPU͕ͱΕΔw ࠓ[JTVJΛ͍ͬͯΔ4UPSZCPPLͱIUUQTTUPSZCPPLTWVFOFUMJGZDPN
4UPSZCPPLͰQSPQTΛ͢storiesOf('propsΛड͚औΔcomponent', module).add('ۭͷͱ͖', () => ({components: { PostsByProps },template: '',data: () => ({posts: []})})).add('ߘ͕͋Δͱ͖', () => ({components: { PostsByProps },template: '',data: () => ({posts: [{id: 1, title: ‘title', body: 'ϘσΟ'}]})}))
4UPSZCPPLͰ7VFY4UBUFΛ͢storiesOf('vuex storeΛड͚औΔcomponentྫ', module).add('ۭͷͱ͖', () => ({components: { PostsByStore },template: '',store: new Vuex({state: () => ({posts: []})})})).add('ߘ͕͋Δͱ͖', () => ({components: { PostsByStore },template: '',store: new Vuex({state: () => ({posts: [{id: 1, title: 'λΠτϧ1', body: 'ϘσΟ'}]})})}))
w7VFYʹґଘ͍ͯ͠ΔDPNQPOFOUTUPSFΛ४උ͢Δͷ͕໘w͚ͩͲɺ7JTVBM5FTU͍ͨ͠$PNQPOFOUw1SFTFOUBUJPOBMͱ$POUBJOFS$PNQPOFOUʹ͚Δw൚༻తͳϞοΫTUPSFΛ४උͯ͠ɺશͯͷDPNQPOFOUͰ͍ճ͢wࠓͷॴɺͬͪ͜ͰͬͯΔ4UPSZCPPLॻ͍ͯΈͯ
ݱͷίʔυimport { state, getters, mutations, actions } from ‘@/store'function createStubStore () {const store = new Vuex.Store({state, getters, mutations, actions})sinon.stub(store, 'dispatch').resolves()store.state.posts = [{id: 1, title: 'λΠτϧ1', body: 'ϘσΟ'}]// ࣮ࡍ͜͜ͷstate४උ͕͍ͬͺ͍͋Δreturn store}const emptyPostsStore = createStubStore()emptyPostsStore.state.posts = []storiesOf('Posts', module).add('ۭͷͱ͖', () => ({components: { Posts },template: '',store: emptyPostsStore,}))ຊͷTUPSFΛϕʔεʹEJTQBUDINFUIPEͱTUBUFΛϞοΫԽ͢Δ
ݱͷίʔυimport { state, getters, mutations, actions } from ‘@/store'function createStubStore () {const store = new Vuex.Store({state, getters, mutations, actions})sinon.stub(store, 'dispatch').resolves()store.state.posts = [{id: 1, title: 'λΠτϧ1', body: 'ϘσΟ'}]// ࣮ࡍ͜͜ͷstate४උ͕͍ͬͺ͍͋Δreturn store}const emptyPostsStore = createStubStore()emptyPostsStore.state.posts = []storiesOf('Posts', module).add('ۭͷͱ͖', () => ({components: { Posts },template: '',store: emptyPostsStore,}))൚༻తͳTUPSFΛඍௐͯ͠TUPSZ
w7JTVBM5FTUJOHΛ։ൃϑϩʔʹࡌͤΔͷʹඞཁͳػೳΛඋ͍͑ͯΔπʔϧwը૾ͷࠩநग़wࠩͷSFQPSUΛIUNMʹग़ྗͯ͠ɺTΞοϓϩʔυw(JUIVCͷ௨SFHTVJUͱ
w$PNQPOFOUΛमਖ਼ͯ͠GFBUVSFCSBODIQVTITUPSZCPPLSFHTVJUͷ։ൃϑϩʔIPPLQVTI
JO$*TUPSZCPPLSFHTVJUͷ։ൃϑϩʔTDSFFOTIPUCZ[JTVJذݩͷը૾DPNQBSFGFBUVSFCSBODIը૾DPNNFOUSFHTVJU
TUPSZCPPLSFHTVJUͷ։ൃϑϩʔ
TUPSZCPPLSFHTVJUͷ։ൃϑϩʔ#FGPSF"GUFSࠩ13"QQSPWF
wDPNQPOFOU୯ҐͰͷςετ͕Մೳw։ൃϑϩʔ͕ීஈͷ։ൃͱҰॹwTUPSZCPPL͕؆୯wσβΠφ͞Μฤूͯ͘͠ΕΔwແྉw༗ঈπʔϧ͍ͬͺ͍͋Δ࠷ߴϙΠϯτ
wϦϑΝΫλ࣌ػೳՃ࣌ɺͱͯ҆৺wϨϏϡʔґཔϨϏϡʔͷෛՙ͕Լ͕Δw7JTVBM5FTUͷ݁Ռ͚ͩͰͳ͘ɺͦΕͱผʹ$*Ͱ13͝ͱʹTUPSZCPPLσϓϩΠwϨϏϡʔ࣌ʹຖճDIFDLPVUɺಈ࡞֬ೝ༻ͷڥ͕ෆཁ7JTVBM5FTUͬͯΈͯ
wΫϩεϒϥβରԠwTUPSZCPPLͷTDSFFOTIPUQVQQFUFFSw༗ྉαʔϏεݕ౼͜Ε͔ΒͷվળϙΠϯτ
6TFS*OUFSBDUJPOʹର͢Δςετίʔυ
6TFS*OUFSBDUJPO7VFY"DUJPO&WFOU)5.-$44$PNQPOFOU6TFS*OUFSBDUJPO
wϑΥʔϜೖྗwϘλϯΫϦοΫwεΫϩʔϧw%%wεϫΠϓwϐϯνΠϯɺΞτwFUD6TFS*OUFSBDUJPOͱ
wϑΥʔϜೖྗwϘλϯΫϦοΫwεΫϩʔϧw%%wεϫΠϓwϐϯνΠϯɺΞτwFUD6TFS*OUFSBDUJPOͱςετқςετқߴͬͪ͜ͷΈΛςετ͍ͯ͠Δ
࣮ྫ6TFS*OUFSBDUJPO৽ن࡞λΠτϧv-validate="'required'">{{ errors.first('title') }}༰v-validate="'required'">{{ errors.first('body') }}อଘJOQVUUFYUY#VUUPOYγϯϓϧͳGPSN
࣮ྫ6TFS*OUFSBDUJPO৽ن࡞λΠτϧv-validate="'required'">{{ errors.first('title') }}༰v-validate="'required'">{{ errors.first('body') }}อଘWFFWBMJEBUFͰඞਢνΣοΫ
࣮ྫ6TFS*OUFSBDUJPOexport default {data: () => ({ post: { title: '', body: '' } }),methods: {async save () {const isValid = await this.$validator.validate()if (isValid) {await this.$store.dispatch('createPost', { post: this.post })this.$router.push({ path: '/' })}}}}WBMJEBUFͯ͠BDUJPOEJTQBUDI
ςετྫXSBQQFS४උ// ϩʔΧϧͳVueίϯετϥΫλʹVuex/VeeValidateΛΠϯετʔϧconst localVue = createLocalVue()localVue.use(Vuex)localVue.use(VeeValidate)beforeEach(() => {// Vue RouterͷϞοΫઃఆ$router = { push: sinon.stub() }// createPostΞΫγϣϯͷಈ࡞֬ೝͷͨΊͷVuexपΓͷઃఆactions = { createPost: sinon.stub() }store = new Vuex.Store({actions})wrapper = mount(NewPost, { mocks: { $router }, store,localVue, sync: false })})TUPSFͷϞοΫΛ࡞NPVOU
ςετྫೖྗͯ͠DMJDLͨ͠ͱ͖describe('ೖྗͯ͠อଘϘλϯΛclickͨ͠ͱ͖', () => {beforeEach(async () => {wrapper.find('#title').setValue('title')wrapper.find('#body').setValue('body')wrapper.find('#create-button').trigger('click')await flushPromises()})it('ೖྗ͞Εͨ༰Ͱaction͕dispatch͞ΕΔ͜ͱ', () => {sinon.assert.calledWithMatch(actions.createPost, {},{ post: { title: 'title', body: 'body' } })})})ೖྗޙʹϘλϯDMJDLͷͱ͖EJTQBUDI͞ΕΔ
ςετྫೖྗͤͣʹDMJDLͨ͠ͱ͖describe('ະೖྗͰอଘϘλϯΛclickͨ͠ͱ͖', () => {beforeEach(async () => {wrapper.find('#create-button').trigger('click')await flushPromises()})it('validationΤϥʔ͕දࣔ͞ΕΔ͜ͱ', () => {expect(wrapper.text()).to.contain('The title field is required.')})it('action͕dispatch͞Εͳ͍͜ͱ', () => {sinon.assert.notCalled(actions.createPost)})}) ະೖྗͰϘλϯDMJDLͷͱ͖WBMJEBUJPOΤϥʔදࣔEJTQBUDI͞Εͳ͍
ςετྫೖྗͤͣʹDMJDLͨ͠ͱ͖describe('ະೖྗͰอଘϘλϯΛclickͨ͠ͱ͖', () => {beforeEach(async () => {wrapper.find('#create-button').trigger('click')await flushPromises()})it('validationΤϥʔ͕දࣔ͞ΕΔ͜ͱ', () => {expect(wrapper.text()).to.contain('The title field is required.')})it('action͕dispatch͞Εͳ͍͜ͱ', () => {sinon.assert.notCalled(actions.createPost)})}) ද͕ࣔมΘͬͨ෦Λ୯७ͳBTTFSUͯ͠Δ
wྫ͑ WBMJEBUJPOΤϥʔৄࡉΛ։͘ด͡Δ Ϙλϯԡͯ͠Ϟʔμϧ։͘FUDwཁςετͷதͰɺTOBQTIPUΛࡱΓ͍ͨ6TFS*OUFSBDUJPOޙͷը૾ΛࡱΓ͍ͨwrapper.find('#create-button').trigger('click')screenshot(‘ະೖྗͰͷclickޙ.png’)
wLBSNBOJHIUNBSFwLBSNBͰOJHIUNBSF FMFDUSPO্ͰςετΛ࣮ߦͰ͖ΔͷwTDSFFOTIPU"1*Λఏڙͯ͘͠ΕΔwը૾͕ࡱΕͨΒTUPSZCPPLͷը૾ͱҰॹʹSFHTVJUͷϑϩʔʹࡌͤΔ5FTUதʹTDSFFOTIPUࡱΔʹ
w؆୯ͳ6TFS*OUFSBDUJPOͷςετେม͡Όͳ͍wॏཁͳ'PSN͚ͩͰ͓ͬͯ͘ͱ͍͍wͦΕҎ֎ఘΊ؊৺w6TFS*OUFSBDUJPOޙͷTDSFFOTIPUͱͬͯɺ7JTVBM5FTUͬͺΓศར6TFS*OUFSBDUJPOͷςετͯ͠Έͯ
·ͱΊ
7VFY"DUJPO&WFOU)5.-$44$PNQPOFOU-JGFDZDMF7VFY4UBUF1SPQT6TFS*OUFSBDUJPOςετλʔήοτ
͜͜·Ͱհ͖ͯͨ͠৭ʑͳςετΛݱͰ͖ͯ͠·ͨͦ͠ͷ݁ՌɺҰ൪࠷ߴͳςετ
)5.-$44$PNQPOFOU7VFY4UBUF1SPQT6TFS*OUFSBDUJPO͜ͷ෦ͷ7JTVBM5FTU
wTUPSZCPPLͰͷʮ1SPQT7VFY4UBUFˠදࣔʯͷςετ͚ͩͰ͋Δͱ҆৺wʮ6TFS*OUFSBDUJPOˠදࣔʯͷςετ͋Δͱߋʹ҆৺ͷ෯͕͕Δw෭࡞༻ͱͯ͠ϨϏϡʔͷෛՙܰݮw͜Ε͕ඇৗʹେ͖͍7JTVBM5FTU࠷ߴ
ָͰ͖Δͱ͜Ζָͯ͠ෆ҆Λղফָͭͭ͘͠͠։ൃ͍͖ͯ͠·͠ΐ͏