├── .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 | --------------------------------------------------------------------------------