Des tests VueJS propres
Il y a quelque temps, j'ai suivi une formation "Advanced Unit Testing" sur Pluralsight. Mark Seeman y présente un pattern nommé "Fixture Object". Le but est simplement d'encapsuler les actions nous permettant d'écrire, exécuter un test. Je le trouvais assez peu utile, jusqu'à présent j'ai surtout écrit des tests dans des formats xUnit, avec une classe de tests me permettant de définir des attributs et méthode privée pour obtenir un test propre. Avec VueJS et Jest, on change de modèle, et son usage me semble évident. Je vous propose de l'implémenter en partant d'une suite de test brute de pomme 🍏.
Le terme "fixture"
J'ai souvent rencontré ce terme pour définir les données en entrée du test. Gerard Meszaros en parle d'une manière plus générique :
In xUnit, a test fixture is all the things we need to have in place in order to run a test and expect a particular outcome. Some people call this the test context. http://xunitpatterns.com/test%20fixture%20-%20xUnit.html
Le point de départ
Des tests sur un composant Vue permettant de scroller vers le haut. Les différentes attentes sont retrouvées dans les noms des tests :
- Given scroll on bottom, When mounting, Then it shows
- Given scroll on top, When mounting, Then it does not show
- Given scroll on top and mounted component, When scrolling down, Then it shows
- Given scroll on bottom and mounted component, When scrolling up, Then it does not show
- Given scroll on bottom and mounted component, When clicking, Then it scroll to top
Le problème, c'est que cela ne transpire pas vraiment quand on regarde le corps des tests 😕 :
/**
* @jest-environment jsdom
*/
import { mount } from '@vue/test-utils'
import ScrollToTop from '~/components/ScrollToTop.vue'
import Vue from 'vue'
const TOP = 0
const BOTTOM = 1000
const ScrollableMixin = {
scrollY: TOP,
scrollTo (options) {
this.scrollY = options.top
this.dispatchEvent(new Event('scroll'))
}
}
describe('ScrollToTop', () => {
test(`Given scroll on bottom
When mounting
Then it shows`, async () => {
const scrollableWindow = Object.assign(window, ScrollableMixin)
scrollableWindow.scrollY = BOTTOM
const wrapper = mount(ScrollToTop, {
mocks: {
window: scrollableWindow
}
})
await Vue.nextTick()
expect(wrapper.find('div').exists()).toBeTruthy()
})
test(`Given scroll on top
When mounting
Then it does not show`, async () => {
const scrollableWindow = Object.assign(window, ScrollableMixin)
scrollableWindow.scrollY = TOP
const wrapper = mount(ScrollToTop, {
mocks: {
window: scrollableWindow
}
})
await Vue.nextTick()
expect(wrapper.find('div').exists()).toBeFalsy()
})
test(`Given scroll on top and mounted component
When scrolling down
Then it shows`, async () => {
const scrollableWindow = Object.assign(window, ScrollableMixin)
scrollableWindow.scrollY = TOP
const wrapper = mount(ScrollToTop, {
mocks: {
window: scrollableWindow
}
})
await Vue.nextTick()
scrollableWindow.scrollTo({ top: BOTTOM })
await Vue.nextTick()
expect(wrapper.find('div').exists()).toBeTruthy()
})
test(`Given scroll on bottom and mounted component
When scrolling up
Then it does not show`, async () => {
const scrollableWindow = Object.assign(window, ScrollableMixin)
scrollableWindow.scrollY = BOTTOM
const wrapper = mount(ScrollToTop, {
mocks: {
window: scrollableWindow
}
})
await Vue.nextTick()
scrollableWindow.scrollTo({ top: TOP })
await Vue.nextTick()
expect(wrapper.find('div').exists()).toBeFalsy()
})
test(`Given scroll on bottom and mounted component
When clicking
Then it scroll to top`, async () => {
const scrollableWindow = Object.assign(window, ScrollableMixin)
scrollableWindow.scrollY = BOTTOM
const wrapper = mount(ScrollToTop, {
mocks: {
window: scrollableWindow
}
})
await Vue.nextTick()
wrapper.trigger('click')
await Vue.nextTick()
expect(scrollableWindow.scrollY).toEqual(TOP)
})
})
On va donc simplement venir envelopper les portions de code "secondaires" pour les rendre plus expressives, pour nous les humains 🙃.
Given
On commence par introduire notre objet de contexte, avant chaque test, nous partons d'un contexte tout neuf :
let context = null
describe('ScrollToTop', () => {
beforeEach(() => {
context = new ScrollToTopFixture()
})
// ...
})
class ScrollToTopFixture {
constructor () {
}
}
Puis on ajoute nos méthodes représentant les préconditions, tout en cherchant à y aller progressivement : on retourne les objets window
et wrapper
, ainsi le reste du test peut continuer de s'en servir et on reste dans le vert, pas de big-bang ici, on reste méthodique ! 🟢
describe('ScrollToTop', () => {
// ...
test(`Given scroll on bottom
When mounting
Then it shows`, async () => {
const scrollableWindow = context.givenScrollOnBottom()
// ...
})
test(`Given scroll on top
When mounting
Then it does not show`, async () => {
const scrollableWindow = context.givenScrollOnTop()
// ...
})
test(`Given scroll on top and mounted component
When scrolling down
Then it shows`, async () => {
const scrollableWindow = context.givenScrollOnTop()
const wrapper = await context.givenMounted()
// ...
})
test(`Given scroll on bottom and mounted component
When scrolling up
Then it does not show`, async () => {
const scrollableWindow = context.givenScrollOnBottom()
const wrapper = await context.givenMounted()
// ...
})
test(`Given scroll on bottom and mounted component
When clicking
Then it scroll to top`, async () => {
const scrollableWindow = context.givenScrollOnBottom()
const wrapper = await context.givenMounted()
// ...
})
})
class ScrollToTopFixture {
constructor () {
this.wrapper = null
this.window = Object.assign(window, ScrollableMixin)
}
givenScrollOnTop () {
this.window.scrollY = TOP
return this.window
}
givenScrollOnBottom () {
this.window.scrollY = BOTTOM
return this.window
}
async givenMounted () {
this.wrapper = mount(ScrollToTop, {
mocks: {
window: this.window
}
})
await Vue.nextTick()
return this.wrapper
}
// ...
}
When
Toujours pareil ici, on vient envelopper nos logiques "When", tout en retournant le wrapper
si besoin, pour nous permettre de rester en zone verte 🙂
describe('ScrollToTop', () => {
// ...
test(`Given scroll on bottom
When mounting
Then it shows`, async () => {
// ...
const wrapper = await context.whenMounting()
// ...
})
test(`Given scroll on top
When mounting
Then it does not show`, async () => {
// ...
const wrapper = await context.whenMounting()
// ...
})
test(`Given scroll on top and mounted component
When scrolling down
Then it shows`, async () => {
// ...
await context.whenScrollingDown()
// ...
})
test(`Given scroll on bottom and mounted component
When scrolling up
Then it does not show`, async () => {
// ...
await context.whenScrollingUp()
// ...
})
test(`Given scroll on bottom and mounted component
When clicking
Then it scroll to top`, async () => {
// ...
await context.whenClicking()
// ...
})
})
class ScrollToTopFixture {
// ...
async whenMounting () {
return this.givenMounted()
}
async whenClicking () {
context.wrapper.trigger('click')
await Vue.nextTick()
}
async whenScrollingDown () {
this.window.scrollTo({ top: BOTTOM })
await Vue.nextTick()
}
async whenScrollingUp () {
this.window.scrollTo({ top: TOP })
await Vue.nextTick()
}
}
Then
Et jamais deux sans trois, on continue avec les "Then" ! Ici on pourra se contenter de fournir des accesseurs pour rendre nos assertions plus verbeuses.
describe('ScrollToTop', () => {
// ...
test(`Given scroll on bottom
When mounting
Then it shows`, async () => {
// ...
expect(context.isShown()).toBeTruthy()
})
test(`Given scroll on top
When mounting
Then it does not show`, async () => {
// ...
expect(context.isShown()).toBeFalsy()
})
test(`Given scroll on top and mounted component
When scrolling down
Then it shows`, async () => {
// ...
expect(context.isShown()).toBeTruthy()
})
test(`Given scroll on bottom and mounted component
When scrolling up
Then it does not show`, async () => {
// ...
expect(context.isShown()).toBeFalsy()
})
test(`Given scroll on bottom and mounted component
When clicking
Then it scroll to top`, async () => {
// ...
expect(context.getScrollY()).toEqual(TOP)
})
})
class ScrollToTopFixture {
// ...
isShown () {
return this.wrapper.find('div').exists()
}
getScrollY () {
return this.window.scrollY
}
}
Le point d'arrivée
Après une petite étape de refactoring pour "fermer" notre objet de contexte, on arrive au résultat suivant :
/**
* @jest-environment jsdom
*/
import { mount } from '@vue/test-utils'
import ScrollToTop from '~/components/ScrollToTop.vue'
import Vue from 'vue'
const TOP = 0
const BOTTOM = 1000
let context = null
describe('ScrollToTop', () => {
beforeEach(() => {
context = new ScrollToTopFixture()
})
test(`Given scroll on bottom
When mounting
Then it shows`, async () => {
context.givenScrollOnBottom()
await context.whenMounting()
expect(context.isShown()).toBeTruthy()
})
test(`Given scroll on top
When mounting
Then it does not show`, async () => {
context.givenScrollOnTop()
await context.whenMounting()
expect(context.isShown()).toBeFalsy()
})
test(`Given scroll on top and mounted component
When scrolling down
Then it shows`, async () => {
context.givenScrollOnTop()
await context.givenMounted()
await context.whenScrollingDown()
expect(context.isShown()).toBeTruthy()
})
test(`Given scroll on bottom and mounted component
When scrolling up
Then it does not show`, async () => {
context.givenScrollOnBottom()
await context.givenMounted()
await context.whenScrollingUp()
expect(context.isShown()).toBeFalsy()
})
test(`Given scroll on bottom and mounted component
When clicking
Then it scroll to top`, async () => {
context.givenScrollOnBottom()
await context.givenMounted()
await context.whenClicking()
expect(context.getScrollY()).toEqual(TOP)
})
})
class ScrollToTopFixture {
constructor () {
this.wrapper = null
this.window = Object.assign(window, ScrollableMixin)
}
givenScrollOnTop () {
this.window.scrollY = TOP
}
givenScrollOnBottom () {
this.window.scrollY = BOTTOM
}
async givenMounted () {
this.wrapper = mount(ScrollToTop, {
mocks: {
window: this.window
}
})
await Vue.nextTick()
}
async whenMounting () {
this.givenMounted()
}
async whenClicking () {
context.wrapper.trigger('click')
await Vue.nextTick()
}
async whenScrollingDown () {
this.window.scrollTo({ top: BOTTOM })
await Vue.nextTick()
}
async whenScrollingUp () {
this.window.scrollTo({ top: TOP })
await Vue.nextTick()
}
isShown () {
return this.wrapper.find('div').exists()
}
getScrollY () {
return this.window.scrollY
}
}
const ScrollableMixin = {
scrollY: TOP,
scrollTo (options) {
this.scrollY = options.top
this.dispatchEvent(new Event('scroll'))
}
}
Conclusion
Écrire des tests pour un framework front orienté composant nous impose d'écrire du code en lien avec le framework, pour monter les composants, gérer l'aspect asynchrone des rendus etc ... Ces portions de code nuisent à la lisibilité, à la compréhension du test.
Si dans un format xUnit, des builders et quelques méthodes privées suffisent à rendre les tests expressifs, c'est un peu différent pour le cas de Jest. Le pattern Fixture Object tel que présenté par Mark Seeman semble être une solution qui tient la route, les tests sont lisibles et simples à étendre 🙂.