Nicolas Le Borgne

Développeur

Des tests VueJS propres

Le 21 juillet 2021

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 🙂.

© 2021 Nicolas Le Borgne