Skip to content

A Vue-flavored interpretation of Yoni Goldberg's "JavaScript & Node.js Testing Best Practices"

Notifications You must be signed in to change notification settings

tomosterlund/vue-testing-best-practices

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

4 Commits
Β 
Β 
Β 
Β 

Repository files navigation

Vue testing best practices

This is a collection of best practices for testing Vue components. All examples illustrate usage of Vue test utils, but the principles should apply to other testing libraries as well.

Most of these best practices are inspired by JavaScript Testing Best Practices by renowned tech author Yoni Goldberg, whose book has a whooping 22k stars on GitHub. The goal of this document is to take some of the ideas from his book, and apply in a Vue-setting.

Table of Contents

Section 1: General code hygiene

1. The Golden Rule: Design for lean testing

From Yoni Goldberg:

Testing code is not production-code - Design it to be short, dead-simple, flat, and delightful to work with. One should look at a test and get the intent instantly.

See, our minds are already occupied with our main job - the production code. There is no ' headspace' for additional complexity. Should we try to squeeze yet another sus-system into our poor brain it will slow the team down which works against the reason we do testing. Practically this is where many teams just abandon testing.


πŸ‘ Doing It Right Example: a short, intent-revealing test

import { shallowMount } from '@vue/test-utils'

describe('Product', () => {
  describe('adding a product to the cart', () => {
    it('should add product with quantity 1 to the cart, when stock is greater than 0', async () => {
      const wrapper = shallowMount(Product, {
        props: {
          stock: 1,
        }
      })

      await wrapper.find('[data-test-id="cart-button"]').trigger('click')

      expect(wrapper.emitted('add-to-cart')).toEqual([[{ quantity: 1 }]])
    })
  })
})

2. Structure tests by the AAA pattern

Yoni Goldberg says:

βœ… Do: Structure your tests with 3 well-separated sections Arrange, Act & Assert (AAA). Following this structure guarantees that the reader spends no brain-CPU on understanding the test plan:

1st A - Arrange: All the setup code to bring the system to the scenario the test aims to simulate. This might include instantiating the unit under test constructor, adding DB records, mocking/stubbing on objects, and any other preparation code

2nd A - Act: Execute the unit under test. Usually 1 line of code

3rd A - Assert: Ensure that the received value satisfies the expectation. Usually 1 line of code


πŸ‘ Doing It Right Example: a well-structured AAA test

import { shallowMount } from '@vue/test-utils'

describe('Product', () => {
  describe('adding a product to the cart', () => {
    it('should add product with quantity 1 to the cart, when stock is greater than 0', async () => {
      // Arrange
      const wrapper = shallowMount(Product, {
        props: {
          stock: 1,
        }
      })

      // Act
      await wrapper.find('[data-test-id="cart-button"]').trigger('click')

      // Assert
      expect(wrapper.emitted('add-to-cart')).toEqual([[{ quantity: 1 }]])
    })
  })
})

πŸ‘Ž Anti-pattern example: No separation. Harder to interpret.

import { shallowMount } from '@vue/test-utils'

describe('Product', () => {
  describe('adding a product to the cart', () => {
    it('should add product with quantity 1 to the cart, when stock is greater than 0', async () => {
      const wrapper = shallowMount(Product, {
        props: {
          stock: 1,
        }
      })
      await wrapper.find('[data-test-id="cart-button"]').trigger('click')
      expect(wrapper.emitted('add-to-cart')).toEqual([[{ quantity: 1 }]])
    })
  })
})

3. For larger arrange-sections, use utility-methods for mounting the component

βœ… Do:

Write utility-methods for mounting the component, every time the arrange-section becomes too bloated. An intent-revealing function name, will be much faster to understand, than reading the entire setup code. These utility methods can be reused across multiple tests in a test-suite.

❌ Otherwise:

Even if you adhere to the AAA-pattern, your tests can quickly become unreadable when the setup section becomes bloated. If your arrange-section becomes too lengthy, this causes two significant problems:

  • A person reading the test, cannot effortlessly grasp the intent. They have to spend brain-CPU on understanding the test plan.
  • The setup cannot be reused between tests.

πŸ‘ Doing It Right Example: arranging the test in a utility-method

// cart-spec-utils.ts
export const whenCartHasNoItems = props => {
  return shallowMount(Cart, {
    props: {
      ...props,
      items: [],
      isSpecialOfferActive: false,
    },
    global: {
      mocks: {
        $store: {
          getters: {
            'cart/total': 0,
          }
        }
      }
    }
  })
}
// cart.spec.ts
import { whenCartHasNoItems } from './cart-spec-utils'

describe('Cart', () => {
  describe('when cart has no items', () => {
    it('should show a message saying the cart is empty', () => {
      // Arrange
      const wrapper = whenCartHasNoItems()

      // Act
      // ...

      // Assert
      // ...
    })
  })
})

πŸ‘Ž Anti-pattern example: large arrange-section. The intent is harder to grasp.

describe('Cart', () => {
  describe('when cart has no items', () => {
    it('should show a message saying the cart is empty', () => {
      // Arrange
      const wrapper = shallowMount(Cart, {
        props: {
          items: [],
          isSpecialOfferActive: false,
        },
        global: {
          mocks: {
            $store: {
              getters: {
                'cart/total': 0,
              }
            }
          }
        }
      })

      // Act
      // ...

      // Assert
      // ...
    })
  })
})

4. Query HTML elements based on attributes that are unlikely to change

βœ… Do:

Query HTML-elements based on attributes that will not change, even if other things in the implementation do. For example, you could settle on always using data-test-id.

❌ Otherwise:

Tests might fail, even though functionality stayed the same, but someone threw out a CSS class that was no longer needed for styling.

πŸ’‘ Tip:

Use a utility method in your project, for querying elements based on data-test-id. This will make it easier to change the attribute in the future if needed, and prevent people from having to debug tests due to misspelling your data attribute. For example:

// test-utils.ts
export const testId = testId => {
  return `[data-test-id="${testId}"]`
}
// cart.spec.ts
import { testId } from './test-utils'

describe('Cart', () => {
  describe('when cart has no items', () => {
    it('should show a message saying the cart is empty', () => {
      // Arrange
      const wrapper = shallowMount(Cart)

      // Act
      // ...

      // Assert
      expect(wrapper.find(testId('empty-cart-message')).exists()).toBe(true)
    })
  })
})

Section 2: Black-box testing

βœ… Do:

Test the external APIs of the component. This is what is often referred to as black-box testing. In comparison to testing a class with public methods, figuring out what these APIs are might not be as straight forward. However, a (probably not conclusive) list of APIs that you can test would be:

  • User interaction with DOM elements
  • Props
  • Custom events
  • Global state, defined and set outside of component
  • Side effects: things that have consequences outside the component
  • Effect of other APIs on the DOM

Avoid testing component internals, commonly referred to as implementation details.

❌ Otherwise:

Your tests will be very fragile and break easily. Refactoring & renaming will be a pain. Though functionality is still fine, your tests will sometimes fail, slowing down the team.

5. Test user interaction with DOM elements

βœ… Do:

Test user interactions with buttons & different inputs. Whenever you see a @click, @change or @input in your templates, you probably enable some user behavior that can be tested.

❌ Otherwise:

The wanted consequences of user interactions might break, without you noticing it.

πŸ‘ Doing It Right Example: testing the effect of interacting with a button

describe('Cart', () => {
  describe('Moving on to checkout', () => {
    it('should move on to checkout when clicking the "Checkout button"', async () => {
      const wrapper = shallowMount(Cart)
      const checkoutButton = wrapper.find('[data-test-id="checkout-button"]')

      await checkoutButton.trigger('click')

      expect(wrapper.emitted().checkout).toBeTruthy()
    })
  })
})

6. Test outcomes of different prop values

βœ… Do:

Test that your component implements the desired behavior, depending on the values you pass as props. For example, one might pass a prop isInteractionDisabled to a component "ProductListing", and expect that the component disabled some interactive behavior, when this prop is set to true. If you enjoy writing parametrized tests, this is a prime candidate for doing so.

❌ Otherwise:

Another developer might come along and change something in the implementation, breaking the desired effect of your props. Another scenario, which happens more often than one might think: someone might misunderstand the intent of the prop and misuse it, since are no tests to display the use case of it.

πŸ‘ Doing It Right Example: testing the effect of different prop values

describe('ProductListing', () => {
  describe('Interaction with listing', () => {
    it('should not open the product details when clicking on a product image, if interaction is disabled', () => {
      const wrapper = shallowMount(ProductListing, {
        props: {
          isInteractionDisabled: true,
        }
      })
      const imageComponent = wrapper.findComponent(ProductListingImage)

      imageComponent.vm.$emit('open-details', { id: 1 })

      expect(wrapper.vm.$router.push).not.toHaveBeenCalled()
    })
  })
})

7. Test API to child components (receiving custom events)

βœ… Do:

Test that your component reacts the way you would expect it to, on input from a child component. For example, a component under test: "ProductListing", might receive an event "open-details" from a child component, and as a response, you want to route to a different page.

❌ Otherwise:

Same consequences as in point 5: Your handling of input might break, not being noticed by anyone, until a customer comes along and complains.

πŸ‘ Doing It Right Example: testing the effect of a custom event from a child component

describe('ProductListing', () => {
  describe('Interaction with listing', () => {
    it('should open the product details when clicking on a product image', () => {
      const wrapper = shallowMount(ProductListing, {
        global: {
          mocks: {
            $router: {
              push: jest.fn(),
            }
          }
        }
      })
      const imageComponent = wrapper.findComponent(ProductListingImage)

      imageComponent.vm.$emit('open-details', { id: 1 })

      expect(wrapper.vm.$router.push).toHaveBeenCalledWith('/product-details/1')
    })
  })
})

8. Test API to parent components (emitting custom events)

βœ… Do:

Test that your component emits the correct events, when you want it to. For example, a component under test: "CheckoutPayment", might emit an event "payment-successful" to its parent component "Checkout", when the payment was successful.

❌ Otherwise:

Parent components that implement your component under test, and depend on its API, are more likely to break when refactoring.

πŸ‘ Doing It Right Example: testing that an event is emitted

describe('CheckoutPayment', () => {
  describe('Payment', () => {
    it('should emit a "payment-successful" event when the payment is successful', async () => {
      const wrapper = shallowMount(CheckoutPayment)
      const paymentComponent = wrapper.findComponent(Payment)

      await paymentComponent.vm.$emit('payment-successful')

      expect(wrapper.emitted('payment-successful')).toBeTruthy()
    })
  })
})

9. Test effect of global state on component

βœ… Do:

Test that your component reacts the way you would expect it to when given a certain global state. In Vue, state from Pinia or Vuex would the most common thing to test.

πŸ‘ Doing It Right Example: testing the effect of global state on a component

describe('Cart', () => {
  describe('Displaying items that a customer has selected', () => {
    it('should show a message saying the cart is empty, when the cart is empty', () => {
      const wrapper = shallowMount(ProductListing, {
        global: {
          mocks: {
            $store: {
              getters: {
                'cart/items': [],
              }
            }
          }
        }
      })

      expect(wrapper.find(testId('empty-cart-message')).exists()).toBe(true)
    })
  })
})

10. Test side effects

βœ… Do:

Test that your component has the desired side effects. For example, you might have a globally available TrackingService object, whose method purchaseCanceled should be called when a user cancels their purchase.

❌ Otherwise:

Other components or services that depend on your component, might break without anyone taking notice. Might cause annoying debugging sessions, because you observe the bug in one place, but the error takes place somewhere else.

πŸ‘ Doing It Right Example: testing side effects

describe('Checkout', () => {
  describe('Canceling the purchase', () => {
    it('should notify the tracking service when the purchase is canceled', async () => {
      const wrapper = shallowMount(Checkout, {
        global: {
          mocks: {
            $trackingService: {
              purchaseCanceled: jest.fn(),
            }
          }
        }
      })
      const checkoutComponent = wrapper.findComponent(CheckoutPayment)
      const cancelSpy = jest.spyOn(wrapper.vm.$trackingService, 'purchaseCanceled')

      await checkoutComponent.vm.$emit('purchase-canceled')

      expect(cancelSpy).toHaveBeenCalled()
    })
  })
})

11. Test effects on DOM

βœ… Do:

Test that interactions with any of the component APIs, result in the desired effect on the DOM. For example, given different prop values, should the DOM react in a certain way? Or: if a dialog is initially hidden on mounting the component, should it be shown as a reaction to a certain user input?

❌ Otherwise:

Customer: "The product listing is broken." Dev or PM: "Can you be more specific" Customer: "When I go to the product listing, and hover over a product image, I don't get the popup with all the info like I used to"

πŸ‘ Doing It Right Example: testing the effect on the DOM

describe('ProductListing', () => {
  describe('Interaction with listing', () => {
    it('should show the product details when hovering over a product image', async () => {
      const wrapper = shallowMount(ProductListing)
      const imageElement = wrapper.find('[data-test-id="product-image"]')

      await imageElement.vm.$emit('mouseenter')

      expect(wrapper.find(testId('product-details')).exists()).toBe(true)
    })
  })
})

12. Do not test the resulting local state

βœ… Do:

Avoid testing what the resulting local state of a component is, after triggering some kind of event. For example: you have a component called "CustomerData" displaying an address form, and a checkbox with the label "Add alternative delivery address". When checking this checkbox, a data property hasAlternativeAddress is set to true. This, in turn, leads to a second address form being displayed.

❌ Otherwise:

You are testing implementation details, which might make your tests fail, though everything works.

πŸ‘Ž Anti-pattern example: Testing the resulting local state

describe('CustomerData', () => {
  describe('Adding an alternative delivery address', () => {
    it('should set hasAlternativeAddress to true, when the checkbox is checked', async () => {
      const wrapper = shallowMount(CustomerData)
      const checkbox = wrapper.find('[data-test-id="alternative-address-checkbox"]')

      await checkbox.trigger('click')

      expect(wrapper.vm.hasAlternativeAddress).toBe(true)
    })
  })
})

πŸ‘ Doing It Right Example: Testing the resulting DOM

describe('CustomerData', () => {
  describe('Adding an alternative delivery address', () => {
    it('should show the alternative address form, when the checkbox is checked', async () => {
      const wrapper = shallowMount(CustomerData)
      const checkbox = wrapper.find('[data-test-id="alternative-address-checkbox"]')

      await checkbox.trigger('click')
      await wrapper.vm.$nextTick()

      expect(wrapper.find(testId('alternative-address-form')).exists()).toBe(true)
    })
  })
})

13. Do not invoke component methods, unless this is how the component is implemented

βœ… Do:

Trigger the event, which would invoke the method, instead of invoking the method directly. For example, in a component "Cart" you might have a method goToCheckout. The expected result of invoking this method is that this.$router.push should have been called. Also here, there is a good way, and a bad way to test it.

❌ Otherwise:

Same as in point 12: You are testing implementation details, which will make your tests fragile.

πŸ‘Ž Anti-pattern example: invoking component methods programmatically

describe('Cart', () => {
  describe('Moving on to checkout', () => {
    it('should move on to checkout when calling the "goToCheckout" method', async () => {
      const wrapper = shallowMount(Cart)

      await wrapper.vm.goToCheckout()

      expect(wrapper.vm.$router.push).toHaveBeenCalledWith('/checkout')
    })
  })
})

πŸ‘ Doing It Right Example: triggering the event, which would invoke the method

describe('Cart', () => {
  describe('Moving on to checkout', () => {
    it('should move on to checkout when clicking the "Checkout button"', async () => {
      const wrapper = shallowMount(Cart)
      const checkoutButton = wrapper.find('[data-test-id="checkout-button"]')

      await checkoutButton.trigger('click')

      expect(wrapper.vm.$router.push).toHaveBeenCalledWith('/checkout')
    })
  })
})

About

A Vue-flavored interpretation of Yoni Goldberg's "JavaScript & Node.js Testing Best Practices"

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published