Javascript testing quick start guide

Javascript testing quick start guide

A Practical Introduction to Unit Testing with Vitest

Testing is a crucial aspect of software development, and JavaScript is no exception. As web applications become more complex, testing ensures that our code works as expected, catches bugs early in the development process, and facilitates refactoring and improving code quality.

Benefits of Testing

  1. Early Bug Detection: Testing helps identify bugs and issues early in the development cycle, preventing them from propagating further and causing more significant problems down the line.

  2. Refactoring Facilitation: When refactoring code, tests provide a safety net, ensuring that the existing functionality remains intact while making changes.

  3. Improved Code Quality: Writing tests forces developers to consider various edge cases and error conditions, leading to more robust and reliable code.

  4. Documentation: Tests serve as a form of documentation, illustrating how functions and components should behave, making it easier for new team members or future developers to understand the codebase.

When to Start Testing

The timing of when to start writing tests is crucial. If a project is still in the early iteration phase with potential breaking changes to requirements, it may not be the best time to invest heavily in testing. However, once the product becomes more stable and the scope for breaking changes decreases, it's an ideal time to start writing tests, especially for critical modules.

Types of Tests

There are several types of tests commonly used in JavaScript development:

  1. Unit Tests: Unit tests verify the correctness of individual units, such as functions, classes, or small modules, in isolation.

  2. Integration Tests: Integration tests focus on verifying how different units or components of an application work together as a whole.

  3. End-to-End Tests: End-to-End (E2E) tests simulate user interactions with the entire system, testing the application from start to finish.

AAA Pattern

The AAA (Arrange, Act, Assert) pattern is a structured approach to writing unit tests that helps in making them more readable, maintainable, and consistent. Here's what each step involves:

  1. Arrange: In this step, you set up the test environment, including any necessary data or configuration required for the test to run. This could involve instantiating objects, defining variables, or setting up mock dependencies.

  2. Act: This step involves executing the code under test, typically by calling a function or method with the arranged inputs.

  3. Assert: In the final step, you verify that the actual output or behaviour matches your expected outcome. This is where you use assertion functions like expect from testing libraries to check if the results are correct.

Vitest

Vitest is a popular testing library for JavaScript, and it provides several functions to help you write and organise your tests effectively.

  • describe(name, fn): This function is used to group related tests together. The name parameter is a string that describes the group, and the fn parameter is a callback function containing the tests.

  • it(name, fn) or test(name, fn): These functions are used to define individual test cases. The name parameter is a string that describes the test case, and the fn parameter is a callback function containing the test logic.

  • expect(value): This function is used to start an assertion. It returns an object that provides various matcher functions to assert different conditions.

  • toBe(value): This matcher is used to check if the actual value is strictly equal (===) to the expected value.

  • toBeUndefined(): This matcher checks if the actual value is undefined.

  • toBeNull(): This matcher checks if the actual value is null.

  • toBeTruthy(): This matcher checks if the actual value is truthy (evaluates to true).

  • toBeFalsy(): This matcher checks if the actual value is falsy (evaluates to false).

Here's an example that demonstrates the use of these functions:

import { sum } from './sum';

describe('sum', () => {
  it('should add two positive numbers', () => {
    // Arrange
    const a = 2;
    const b = 3;

    // Act
    const result = sum(a, b);

    // Assert
    expect(result).toBe(5);
  });

  it('should return 0 if both arguments are 0', () => {
    expect(sum(0, 0)).toBe(0);
  });

  it('should return the same value if one argument is 0', () => {
    expect(sum(5, 0)).toBe(5);
    expect(sum(0, 7)).toBe(7);
  });
});

Test-Driven Development (TDD)

Test-Driven Development (TDD) is an approach where tests are written before the application code. The three steps in TDD are:

  1. Write a failing test.

  2. Write just enough code to make the test pass.

  3. Refactor the code if necessary, ensuring that the tests still pass.

Let's go through a more real-world example of the TDD process for a simple shopping cart functionality.

  1. Write the first failing test:
import { ShoppingCart } from './ShoppingCart';

describe('ShoppingCart', () => {
  it('should initialize with an empty cart', () => {
    const cart = new ShoppingCart();

    expect(cart.items).toEqual([]);
    expect(cart.totalPrice).toBe(0);
  });
});

Run the test: It will fail because the ShoppingCart class doesn't exist yet.

  1. Write the minimal code to make the test pass:
export class ShoppingCart {
  constructor() {
    this.items = [];
    this.totalPrice = 0;
  }
}

Run the test: It should now pass.

  1. Write another failing test:
import { ShoppingCart } from './ShoppingCart';

describe('ShoppingCart', () => {
  it('should initialize with an empty cart', () => {
    const cart = new ShoppingCart();

    expect(cart.items).toEqual([]);
    expect(cart.totalPrice).toBe(0);
  });

  it('should add an item to the cart', () => {
    const cart = new ShoppingCart();
    const item = { name: 'Book', price: 10 };

    cart.addItem(item);

    expect(cart.items).toContainEqual(item);
    expect(cart.totalPrice).toBe(10);
  });
});

Run the tests: The second test should fail because we haven't implemented the addItem method yet.

  1. Write the code to make the new test pass:
export class ShoppingCart {
  constructor() {
    this.items = [];
    this.totalPrice = 0;
  }

  addItem(item) {
    this.items.push(item);
    this.totalPrice += item.price;
  }
}

Run the tests: Both tests should now pass.

  1. Write another failing test:
import { ShoppingCart } from './ShoppingCart';

describe('ShoppingCart', () => {
  it('should initialize with an empty cart', () => {
    const cart = new ShoppingCart();

    expect(cart.items).toEqual([]);
    expect(cart.totalPrice).toBe(0);
  });

  it('should add an item to the cart', () => {
    const cart = new ShoppingCart();
    const item = { name: 'Book', price: 10 };

    cart.addItem(item);

    expect(cart.items).toContainEqual(item);
    expect(cart.totalPrice).toBe(10);
  });

  it('should remove an item from the cart', () => {
    const cart = new ShoppingCart();
    const item = { name: 'Book', price: 10 };

    cart.addItem(item);
    cart.removeItem(item);

    expect(cart.items).toEqual([]);
    expect(cart.totalPrice).toBe(0);
  });
});

Run the tests: The third test should fail because we haven't implemented the removeItem method yet.

  1. Write the code to make the new test pass:
export class ShoppingCart {
  constructor() {
    this.items = [];
    this.totalPrice = 0;
  }

  addItem(item) {
    this.items.push(item);
    this.totalPrice += item.price;
  }

  removeItem(item) {
    const index = this.items.indexOf(item);
    if (index !== -1) {
      this.items.splice(index, 1);
      this.totalPrice -= item.price;
    }
  }
}

Run the tests: All three tests should now pass.

You can continue this cycle by writing tests for additional requirements, such as handling duplicate items, applying discounts, or implementing checkout functionality. The TDD approach ensures that you write testable code from the beginning and that your codebase remains well-covered by tests as it grows in complexity.

Here are a few more examples of tests you could write for the shopping cart:

  • Test adding multiple items to the cart and verifying the correct total price

  • Test removing an item that doesn't exist in the cart (edge case)

  • Test adding and removing the same item multiple times

  • Test applying a discount code and verifying the discounted total price

  • Test checking out the cart and resetting the cart state

By following the TDD approach and continuously writing tests before implementing new features or refactoring existing code, you can ensure that your codebase remains robust, maintainable, and easy to extend or modify in the future.

Conclusion

Testing is an essential practice in JavaScript development, providing numerous benefits such as early bug detection, facilitation of refactoring, improved code quality, and documentation. By understanding when to start testing, the qualities of a good test, and the different types of tests available, developers can write more robust and reliable code. Tools like Vitest and the TDD approach can further enhance the testing process and contribute to a more efficient and high-quality software development lifecycle.