├── .gitignore
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea
2 | vue-testing-best-practices.iml
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Vue testing best practices
2 |
3 | This is a collection of best practices for testing Vue components. All examples illustrate usage
4 | of [Vue test utils](https://github.com/vuejs/test-utils), but the principles should apply to
5 | other testing libraries as well.
6 |
7 | Most of these best practices are inspired
8 | by [JavaScript Testing Best Practices](https://github.com/goldbergyoni/javascript-testing-best-practices)
9 | by renowned tech author Yoni Goldberg, whose book has a whooping 22k stars on GitHub. The goal of
10 | this document is to take some of the ideas from his book, and apply in a Vue-setting.
11 |
12 | ## `Table of Contents`
13 |
14 | #### [`Section 1: General code hygiene`](#section-1-general-code-hygiene-1)
15 |
16 | #### [`Section 2: Black box testing`](#section-2-black-box-testing-1)
17 |
18 | ## Section 1: General code hygiene
19 |
20 | ### 1. The Golden Rule: Design for lean testing
21 |
22 | From Yoni Goldberg:
23 |
24 | > Testing code is not production-code - Design it to be short, dead-simple, flat, and delightful to
25 | > work with. One should look at a test and get the intent instantly.
26 | >
27 | > See, our minds are already occupied with our main job - the production code. There is no '
28 | > headspace' for additional complexity.
29 | > Should we try to squeeze yet another sus-system into our poor brain it will slow the team down
30 | > which works against the reason we do testing. Practically this is where many teams just abandon
31 | > testing.
32 |
33 |
34 |
35 | ### 👏 Doing It Right Example: a short, intent-revealing test
36 |
37 | ```ts
38 | import { shallowMount } from '@vue/test-utils'
39 |
40 | describe('Product', () => {
41 | describe('adding a product to the cart', () => {
42 | it('should add product with quantity 1 to the cart, when stock is greater than 0', async () => {
43 | const wrapper = shallowMount(Product, {
44 | props: {
45 | stock: 1,
46 | }
47 | })
48 |
49 | await wrapper.find('[data-test-id="cart-button"]').trigger('click')
50 |
51 | expect(wrapper.emitted('add-to-cart')).toEqual([[{ quantity: 1 }]])
52 | })
53 | })
54 | })
55 |
56 | ```
57 |
58 | ### 2. Structure tests by the AAA pattern
59 |
60 | Yoni Goldberg says:
61 | > ✅ Do: Structure your tests with 3 well-separated sections Arrange, Act & Assert (AAA). Following
62 | > this structure guarantees that the reader spends no brain-CPU on understanding the test plan:
63 | >
64 | > 1st A - Arrange: All the setup code to bring the system to the scenario the test aims to simulate.
65 | > This might include instantiating the unit under test constructor, adding DB records,
66 | > mocking/stubbing on objects, and any other preparation code
67 | >
68 | > 2nd A - Act: Execute the unit under test. Usually 1 line of code
69 | >
70 | > 3rd A - Assert: Ensure that the received value satisfies the expectation. Usually 1 line of code
71 |
72 |
73 |
74 |
75 | ### 👏 Doing It Right Example: a well-structured AAA test
76 |
77 | ```ts
78 | import { shallowMount } from '@vue/test-utils'
79 |
80 | describe('Product', () => {
81 | describe('adding a product to the cart', () => {
82 | it('should add product with quantity 1 to the cart, when stock is greater than 0', async () => {
83 | // Arrange
84 | const wrapper = shallowMount(Product, {
85 | props: {
86 | stock: 1,
87 | }
88 | })
89 |
90 | // Act
91 | await wrapper.find('[data-test-id="cart-button"]').trigger('click')
92 |
93 | // Assert
94 | expect(wrapper.emitted('add-to-cart')).toEqual([[{ quantity: 1 }]])
95 | })
96 | })
97 | })
98 |
99 | ```
100 |
101 | ### 👎 Anti-pattern example: No separation. Harder to interpret.
102 |
103 | ```ts
104 | import { shallowMount } from '@vue/test-utils'
105 |
106 | describe('Product', () => {
107 | describe('adding a product to the cart', () => {
108 | it('should add product with quantity 1 to the cart, when stock is greater than 0', async () => {
109 | const wrapper = shallowMount(Product, {
110 | props: {
111 | stock: 1,
112 | }
113 | })
114 | await wrapper.find('[data-test-id="cart-button"]').trigger('click')
115 | expect(wrapper.emitted('add-to-cart')).toEqual([[{ quantity: 1 }]])
116 | })
117 | })
118 | })
119 |
120 | ```
121 |
122 | ### 3. For larger arrange-sections, use utility-methods for mounting the component
123 |
124 | ✅ Do:
125 |
126 | Write utility-methods for mounting the component, every time the arrange-section becomes too
127 | bloated. An intent-revealing function name, will be much faster to understand, than reading the
128 | entire setup code. These utility methods can be reused across multiple tests in a test-suite.
129 |
130 | ❌ Otherwise:
131 |
132 | Even if you adhere to the AAA-pattern, your tests can quickly become unreadable when the setup
133 | section becomes bloated. If your arrange-section becomes too lengthy, this causes two significant
134 | problems:
135 |
136 | - A person reading the test, cannot effortlessly grasp the intent. They have to spend brain-CPU
137 | on understanding the test plan.
138 | - The setup cannot be reused between tests.
139 |
140 | ### 👏 Doing It Right Example: arranging the test in a utility-method
141 |
142 | ```ts
143 | // cart-spec-utils.ts
144 | export const whenCartHasNoItems = props => {
145 | return shallowMount(Cart, {
146 | props: {
147 | ...props,
148 | items: [],
149 | isSpecialOfferActive: false,
150 | },
151 | global: {
152 | mocks: {
153 | $store: {
154 | getters: {
155 | 'cart/total': 0,
156 | }
157 | }
158 | }
159 | }
160 | })
161 | }
162 | ```
163 |
164 | ```ts
165 | // cart.spec.ts
166 | import { whenCartHasNoItems } from './cart-spec-utils'
167 |
168 | describe('Cart', () => {
169 | describe('when cart has no items', () => {
170 | it('should show a message saying the cart is empty', () => {
171 | // Arrange
172 | const wrapper = whenCartHasNoItems()
173 |
174 | // Act
175 | // ...
176 |
177 | // Assert
178 | // ...
179 | })
180 | })
181 | })
182 | ```
183 |
184 | ### 👎 Anti-pattern example: large arrange-section. The intent is harder to grasp.
185 |
186 | ```ts
187 | describe('Cart', () => {
188 | describe('when cart has no items', () => {
189 | it('should show a message saying the cart is empty', () => {
190 | // Arrange
191 | const wrapper = shallowMount(Cart, {
192 | props: {
193 | items: [],
194 | isSpecialOfferActive: false,
195 | },
196 | global: {
197 | mocks: {
198 | $store: {
199 | getters: {
200 | 'cart/total': 0,
201 | }
202 | }
203 | }
204 | }
205 | })
206 |
207 | // Act
208 | // ...
209 |
210 | // Assert
211 | // ...
212 | })
213 | })
214 | })
215 | ```
216 |
217 | ### 4. Query HTML elements based on attributes that are unlikely to change
218 |
219 | ✅ Do:
220 |
221 | Query HTML-elements based on attributes that will not change, even if other things in the
222 | implementation do. For example, you could settle on always using `data-test-id`.
223 |
224 | ❌ Otherwise:
225 |
226 | Tests might fail, even though functionality stayed the same, but someone threw out a CSS class that
227 | was no longer needed for styling.
228 |
229 | 💡 Tip:
230 |
231 | Use a utility method in your project, for querying elements based on `data-test-id`. This will
232 | make it easier to change the attribute in the future if needed, and prevent people from having to
233 | debug tests due to misspelling your data attribute. For example:
234 |
235 | ```ts
236 | // test-utils.ts
237 | export const testId = testId => {
238 | return `[data-test-id="${testId}"]`
239 | }
240 | ```
241 |
242 | ```ts
243 | // cart.spec.ts
244 | import { testId } from './test-utils'
245 |
246 | describe('Cart', () => {
247 | describe('when cart has no items', () => {
248 | it('should show a message saying the cart is empty', () => {
249 | // Arrange
250 | const wrapper = shallowMount(Cart)
251 |
252 | // Act
253 | // ...
254 |
255 | // Assert
256 | expect(wrapper.find(testId('empty-cart-message')).exists()).toBe(true)
257 | })
258 | })
259 | })
260 | ```
261 |
262 | ## Section 2: Black-box testing
263 |
264 | ✅ Do:
265 |
266 | Test the external APIs of the component. This is what is often referred to as black-box testing. In
267 | comparison to testing a class with public methods, figuring out what these APIs are might not be as
268 | straight forward. However, a (probably not conclusive) list of APIs that you can test would be:
269 |
270 | - User interaction with DOM elements
271 | - Props
272 | - Custom events
273 | - Global state, defined and set outside of component
274 | - Side effects: things that have consequences outside the component
275 | - Effect of other APIs on the DOM
276 |
277 | Avoid testing component internals, commonly referred to as implementation details.
278 |
279 | ❌ Otherwise:
280 |
281 | Your tests will be very fragile and break easily. Refactoring & renaming will be a pain. Though
282 | functionality is still fine, your tests will sometimes fail, slowing down the team.
283 |
284 | ### 5. Test user interaction with DOM elements
285 |
286 | ✅ Do:
287 |
288 | Test user interactions with buttons & different inputs. Whenever you see a `@click`, `@change`
289 | or `@input` in your templates, you probably enable some user behavior that can be tested.
290 |
291 | ❌ Otherwise:
292 |
293 | The wanted consequences of user interactions might break, without you noticing it.
294 |
295 | ### 👏 Doing It Right Example: testing the effect of interacting with a button
296 |
297 | ```ts
298 | describe('Cart', () => {
299 | describe('Moving on to checkout', () => {
300 | it('should move on to checkout when clicking the "Checkout button"', async () => {
301 | const wrapper = shallowMount(Cart)
302 | const checkoutButton = wrapper.find('[data-test-id="checkout-button"]')
303 |
304 | await checkoutButton.trigger('click')
305 |
306 | expect(wrapper.emitted().checkout).toBeTruthy()
307 | })
308 | })
309 | })
310 | ```
311 |
312 | ### 6. Test outcomes of different prop values
313 |
314 | ✅ Do:
315 |
316 | Test that your component implements the desired behavior, depending on the values you pass as props.
317 | For example, one might pass a prop `isInteractionDisabled` to a component "ProductListing", and
318 | expect that the component disabled some interactive behavior, when this prop is set to `true`.
319 | If you enjoy writing parametrized tests, this is a prime candidate for doing so.
320 |
321 | ❌ Otherwise:
322 |
323 | Another developer might come along and change something in the implementation, breaking the desired
324 | effect of your props. Another scenario, which happens more often than one might think: someone might
325 | misunderstand the intent of the prop and misuse it, since are no tests to display the use case of
326 | it.
327 |
328 | ### 👏 Doing It Right Example: testing the effect of different prop values
329 |
330 | ```ts
331 | describe('ProductListing', () => {
332 | describe('Interaction with listing', () => {
333 | it('should not open the product details when clicking on a product image, if interaction is disabled', () => {
334 | const wrapper = shallowMount(ProductListing, {
335 | props: {
336 | isInteractionDisabled: true,
337 | }
338 | })
339 | const imageComponent = wrapper.findComponent(ProductListingImage)
340 |
341 | imageComponent.vm.$emit('open-details', { id: 1 })
342 |
343 | expect(wrapper.vm.$router.push).not.toHaveBeenCalled()
344 | })
345 | })
346 | })
347 | ```
348 |
349 | ### 7. Test API to child components (receiving custom events)
350 |
351 | ✅ Do:
352 |
353 | Test that your component reacts the way you would expect it to, on input from a child component. For
354 | example, a component under test: "ProductListing", might receive an event "open-details" from a
355 | child component, and as a response, you want to route to a different page.
356 |
357 | ❌ Otherwise:
358 |
359 | Same consequences as in point 5: Your handling of input might break, not being noticed by anyone,
360 | until a customer comes along and complains.
361 |
362 | ### 👏 Doing It Right Example: testing the effect of a custom event from a child component
363 |
364 | ```ts
365 | describe('ProductListing', () => {
366 | describe('Interaction with listing', () => {
367 | it('should open the product details when clicking on a product image', () => {
368 | const wrapper = shallowMount(ProductListing, {
369 | global: {
370 | mocks: {
371 | $router: {
372 | push: jest.fn(),
373 | }
374 | }
375 | }
376 | })
377 | const imageComponent = wrapper.findComponent(ProductListingImage)
378 |
379 | imageComponent.vm.$emit('open-details', { id: 1 })
380 |
381 | expect(wrapper.vm.$router.push).toHaveBeenCalledWith('/product-details/1')
382 | })
383 | })
384 | })
385 | ```
386 |
387 | ### 8. Test API to parent components (emitting custom events)
388 |
389 | ✅ Do:
390 |
391 | Test that your component emits the correct events, when you want it to. For example, a component
392 | under test: "CheckoutPayment", might emit an event "payment-successful" to its parent component
393 | "Checkout", when the payment was successful.
394 |
395 | ❌ Otherwise:
396 |
397 | Parent components that implement your component under test, and depend on its API, are more likely
398 | to break when
399 | refactoring.
400 |
401 | ### 👏 Doing It Right Example: testing that an event is emitted
402 |
403 | ```ts
404 | describe('CheckoutPayment', () => {
405 | describe('Payment', () => {
406 | it('should emit a "payment-successful" event when the payment is successful', async () => {
407 | const wrapper = shallowMount(CheckoutPayment)
408 | const paymentComponent = wrapper.findComponent(Payment)
409 |
410 | await paymentComponent.vm.$emit('payment-successful')
411 |
412 | expect(wrapper.emitted('payment-successful')).toBeTruthy()
413 | })
414 | })
415 | })
416 | ```
417 |
418 | ### 9. Test effect of global state on component
419 |
420 | ✅ Do:
421 |
422 | Test that your component reacts the way you would expect it to when given a certain global state. In
423 | Vue, state from Pinia or Vuex would the most common thing to test.
424 |
425 | ### 👏 Doing It Right Example: testing the effect of global state on a component
426 |
427 | ```ts
428 | describe('Cart', () => {
429 | describe('Displaying items that a customer has selected', () => {
430 | it('should show a message saying the cart is empty, when the cart is empty', () => {
431 | const wrapper = shallowMount(ProductListing, {
432 | global: {
433 | mocks: {
434 | $store: {
435 | getters: {
436 | 'cart/items': [],
437 | }
438 | }
439 | }
440 | }
441 | })
442 |
443 | expect(wrapper.find(testId('empty-cart-message')).exists()).toBe(true)
444 | })
445 | })
446 | })
447 | ```
448 |
449 | ### 10. Test side effects
450 |
451 | ✅ Do:
452 |
453 | Test that your component has the desired side effects. For example, you might have a globally
454 | available `TrackingService` object, whose method `purchaseCanceled` should be called when a user
455 | cancels their purchase.
456 |
457 | ❌ Otherwise:
458 |
459 | Other components or services that depend on your component, might break without anyone taking
460 | notice. Might cause annoying debugging sessions, because you observe the bug in one place, but the
461 | error takes place somewhere else.
462 |
463 | ### 👏 Doing It Right Example: testing side effects
464 |
465 | ```ts
466 | describe('Checkout', () => {
467 | describe('Canceling the purchase', () => {
468 | it('should notify the tracking service when the purchase is canceled', async () => {
469 | const wrapper = shallowMount(Checkout, {
470 | global: {
471 | mocks: {
472 | $trackingService: {
473 | purchaseCanceled: jest.fn(),
474 | }
475 | }
476 | }
477 | })
478 | const checkoutComponent = wrapper.findComponent(CheckoutPayment)
479 | const cancelSpy = jest.spyOn(wrapper.vm.$trackingService, 'purchaseCanceled')
480 |
481 | await checkoutComponent.vm.$emit('purchase-canceled')
482 |
483 | expect(cancelSpy).toHaveBeenCalled()
484 | })
485 | })
486 | })
487 | ```
488 |
489 | ### 11. Test effects on DOM
490 |
491 | ✅ Do:
492 |
493 | Test that interactions with any of the component APIs, result in the desired effect on the DOM. For
494 | example, given different prop values, should the DOM react in a certain way? Or: if a dialog is
495 | initially hidden on mounting the component, should it be shown as a reaction to a certain user
496 | input?
497 |
498 | ❌ Otherwise:
499 |
500 | Customer: "The product listing is broken."
501 | Dev or PM: "Can you be more specific"
502 | Customer: "When I go to the product listing, and hover over a product image, I don't get the popup
503 | with
504 | all the info like I used to"
505 |
506 | ### 👏 Doing It Right Example: testing the effect on the DOM
507 |
508 | ```ts
509 | describe('ProductListing', () => {
510 | describe('Interaction with listing', () => {
511 | it('should show the product details when hovering over a product image', async () => {
512 | const wrapper = shallowMount(ProductListing)
513 | const imageElement = wrapper.find('[data-test-id="product-image"]')
514 |
515 | await imageElement.vm.$emit('mouseenter')
516 |
517 | expect(wrapper.find(testId('product-details')).exists()).toBe(true)
518 | })
519 | })
520 | })
521 | ```
522 |
523 | ### 12. _Do not_ test the resulting local state
524 |
525 | ✅ Do:
526 |
527 | Avoid testing what the resulting local state of a component is, after triggering some kind of event.
528 | For example: you have a component called "CustomerData" displaying an address form, and a checkbox
529 | with the label "Add alternative delivery address". When checking this checkbox, a data
530 | property `hasAlternativeAddress` is set to true. This, in turn, leads to a second address form being
531 | displayed.
532 |
533 | ❌ Otherwise:
534 |
535 | You are testing implementation details, which might make your tests fail, though everything works.
536 |
537 | ### 👎 Anti-pattern example: Testing the resulting local state
538 |
539 | ```ts
540 | describe('CustomerData', () => {
541 | describe('Adding an alternative delivery address', () => {
542 | it('should set hasAlternativeAddress to true, when the checkbox is checked', async () => {
543 | const wrapper = shallowMount(CustomerData)
544 | const checkbox = wrapper.find('[data-test-id="alternative-address-checkbox"]')
545 |
546 | await checkbox.trigger('click')
547 |
548 | expect(wrapper.vm.hasAlternativeAddress).toBe(true)
549 | })
550 | })
551 | })
552 | ```
553 |
554 | ### 👏 Doing It Right Example: Testing the resulting DOM
555 |
556 | ```ts
557 | describe('CustomerData', () => {
558 | describe('Adding an alternative delivery address', () => {
559 | it('should show the alternative address form, when the checkbox is checked', async () => {
560 | const wrapper = shallowMount(CustomerData)
561 | const checkbox = wrapper.find('[data-test-id="alternative-address-checkbox"]')
562 |
563 | await checkbox.trigger('click')
564 | await wrapper.vm.$nextTick()
565 |
566 | expect(wrapper.find(testId('alternative-address-form')).exists()).toBe(true)
567 | })
568 | })
569 | })
570 | ```
571 |
572 | ### 13. _Do not_ invoke component methods, unless this is how the component is implemented
573 |
574 | ✅ Do:
575 |
576 | Trigger the event, which would invoke the method, instead of invoking the method directly. For
577 | example, in a component "Cart" you might have a method `goToCheckout`. The expected result of
578 | invoking this method is that `this.$router.push` should have been called. Also here, there is a good
579 | way, and a bad way to test it.
580 |
581 | ❌ Otherwise:
582 |
583 | Same as in point 12: You are testing implementation details, which will make your tests fragile.
584 |
585 | ### 👎 Anti-pattern example: invoking component methods programmatically
586 |
587 | ```ts
588 | describe('Cart', () => {
589 | describe('Moving on to checkout', () => {
590 | it('should move on to checkout when calling the "goToCheckout" method', async () => {
591 | const wrapper = shallowMount(Cart)
592 |
593 | await wrapper.vm.goToCheckout()
594 |
595 | expect(wrapper.vm.$router.push).toHaveBeenCalledWith('/checkout')
596 | })
597 | })
598 | })
599 | ```
600 |
601 | ### 👏 Doing It Right Example: triggering the event, which would invoke the method
602 |
603 | ```ts
604 | describe('Cart', () => {
605 | describe('Moving on to checkout', () => {
606 | it('should move on to checkout when clicking the "Checkout button"', async () => {
607 | const wrapper = shallowMount(Cart)
608 | const checkoutButton = wrapper.find('[data-test-id="checkout-button"]')
609 |
610 | await checkoutButton.trigger('click')
611 |
612 | expect(wrapper.vm.$router.push).toHaveBeenCalledWith('/checkout')
613 | })
614 | })
615 | })
616 | ```
617 |
--------------------------------------------------------------------------------