DEV Community

Cover image for 3. Unit testing concept
Sandheep Kumar Patro
Sandheep Kumar Patro

Posted on • Edited on

3. Unit testing concept

Topics covered in this section :-

  1. Synchronous Testing

  2. Asynchronous Testing

  3. Data mocking

  4. Event testing

**Note :- **An interesting point to know here is that the above points are related and i have explained this relationship in detail at the end.

Synchronous Testing

In JavaScript, synchronous code executes line by line in a sequential manner. It doesn't involve asynchronous operations like waiting for network requests or timers to complete. This makes synchronous code predictable and easier to test as the outcome is determined by the input and the code's logic alone.

Advantages of Synchronous Testing:

  • Faster Execution: Synchronous tests generally run faster compared to asynchronous tests because there's no waiting for external factors. This improves test suite execution speed.

  • Simpler Logic: Synchronous tests often involve straightforward assertions about the expected behavior of functions without complex asynchronous handling. This makes them easier to write and maintain.

  • Deterministic Results: Since synchronous code execution is sequential, you can be confident about the order in which code is executed and the values it produces. This simplifies debugging and ensures consistent test results.

When to Use Synchronous Testing:

  • Unit Testing Pure Functions: Synchronous tests are ideal for testing pure functions that don't rely on external factors and produce the same output for the same input. These functions are typically used for data manipulation or calculations within your application.

  • Testing Utility Functions: You can effectively test utility functions that perform simple tasks like string manipulations or date formatting using synchronous tests.

  • Initial Unit Tests: When starting with unit testing, synchronous tests are a great way to get started as they require less setup and reasoning compared to asynchronous tests.

Practical Code Example with Unit Test

//Code (dateUtils.js):
function formatDate(date, formatString = 'YYYY-MM-DD') {
  if (!(date instanceof Date)) {
    throw new TypeError('formatDate() expects a Date object');
  }
  const year = date.getFullYear();
  const month = String(date.getMonth() + 1).padStart(2, '0'); // Zero-pad month
  const day = String(date.getDate()).padStart(2, '0');
  return formatString.replace('YYYY', year).replace('MM', month).replace('DD', day);
}
Enter fullscreen mode Exit fullscreen mode

Explanation:

  1. Function Definition: The formatDate function takes two arguments:
    • date: This is expected to be a valid JavaScript Date object representing a specific date and time.
    • formatString (optional): This is a string that defines the desired output format for the date. It defaults to 'YYYY-MM-DD' (year-month-day) but can be customized to include other parts like hours, minutes, or seconds.
  2. Input Validation: The function starts by checking if the date argument is indeed a Date object using the instanceof operator. If not, it throws a TypeError to indicate an invalid input. This ensures that the function only works with valid dates.
  3. Date Component Extraction: Inside the function, we extract the individual components of the date object:
    • year: Retrieved using date.getFullYear().
    • month: Obtained using date.getMonth(). However, this returns a zero-based index (0 for January, 11 for December). To get the month in the usual 1-based format, we add 1 and then convert it to a string using String().
    • day: Similar to month, we get the day using date.getDate() and convert it to a string.
  4. Zero-Padding: Months and days are typically represented with two digits (01 for January 1st). The padStart() method ensures this format by adding leading zeros if the value is less than two digits. Here, we pad both month and day with a leading '0' if necessary.
  5. String Formatting: The formatString is used as a template for the final output. We use the replace() method repeatedly to substitute placeholders with the actual date components:
    • YYYY gets replaced with the extracted year.
    • MM gets replaced with the zero-padded month.
    • DD gets replaced with the zero-padded day.
  6. Returning the Formatted Date: Finally, the function returns the formatted date string that combines the year, month, and day according to the specified format.
//Unit Test (dateUtils.test.js):
import { test, expect } from 'vitest';
import { formatDate } from './dateUtils';

test('formatDate() formats date correctly', () => {
  const date = new Date(2024, 5, 13); // June 13, 2024
  expect(formatDate(date)).toBe('2024-06-13');
  expect(formatDate(date, 'DD/MM/YYYY')).toBe('13/06/2024');
});

test('formatDate() throws for non-Date arguments', () => {
  expect(() => formatDate('invalid date')).toThrowError(TypeError);
  expect(() => formatDate(123)).toThrowError(TypeError);
});
Enter fullscreen mode Exit fullscreen mode

Explanation:

1. Imports:

  • test and expect are imported from vitest to define test cases and make assertions.

  • formatDate is imported from the ./dateUtils file, assuming it's in the same directory.

2. Test Case 1: formatDate() formats date correctly:

  • Description: This test verifies that the function formats a valid Date object according to the expected output.

  • const date = new Date(2024, 5, 13);: This line creates a new Date object representing June 13, 2024.

  • expect(formatDate(date)).toBe('2024-06-13');: This assertion uses expect to check the output of formatDate(date). It expects the formatted date to be a string equal to "2024-06-13" (default format).

  • expect(formatDate(date, 'DD/MM/YYYY')).toBe('13/06/2024');: This additional assertion tests the custom format. It calls formatDate with the same date object but provides a different formatString ("DD/MM/YYYY") as the second argument. The assertion expects the output to be "13/06/2024" based on the provided format.

3. Test Case 2: formatDate() throws for non-Date arguments:

  • Description: This test ensures that the function throws an error when provided with invalid input (anything other than a Date object).

  • expect(() => formatDate('invalid date')).toThrowError(TypeError);: This line uses an arrow function to wrap the call to formatDate with an invalid string argument ("invalid date"). The expect statement then checks if the function throws a TypeError.

  • expect(() => formatDate(123)).toThrowError(TypeError);: This assertion follows a similar approach, testing if the function throws a TypeError when given a number (123) as input.

Asynchronous Testing

JavaScript, being an event-driven language, heavily relies on asynchronous operations for tasks like network requests, file I/O, timeouts, and more. These operations don't block the main thread, allowing other code to execute while waiting for results. However, this asynchronous nature can introduce challenges when writing unit tests.

Challenges of Asynchronous Testing:

  • Callback Hell: Nested callbacks can lead to difficult-to-read and maintain code.

  • Promises Anti-Patterns: Common pitfalls include forgetting to handle errors or using then chaining excessively.

  • Test Completion: Tests that run asynchronous code need a way to ensure they finish before moving on or making assertions.

Vitest's Approach to Asynchronous Testing:

Vitest leverages the native async/await syntax for a more readable and synchronous-like testing experience. Here's how it works:

  1. Test Functions as Async: Vitest is flexible and works with both synchronous and asynchronous test functions. You only need to make your test function asynchronous if your tests involve waiting for asynchronous operations.

  2. await for Asynchronous Operations: You can freely use await within test functions to pause execution until asynchronous operations resolve (e.g., promises, timers).

  3. Implicit Assertions: Vitest automatically waits for tests to finish before making assertions. Assertions like expect or test implicitly wait for all asynchronous operations within the test function to complete.

Practical Code Example with Unit Test

function scheduleTimer(callback, delay = 1000) {
  return setTimeout(callback, delay);
}

test('scheduleTimer calls callback after delay', async () => {
  const mockCallback = jest.fn();
  const timerId = scheduleTimer(mockCallback);

  // No need to await here as Vitest implicitly waits
  expect(mockCallback).not.toHaveBeenCalled();

  await new Promise((resolve) => setTimeout(resolve, 1200)); // Wait slightly longer than delay

  expect(mockCallback).toHaveBeenCalledTimes(1);
  clearTimeout(timerId); // Clean up timer
});
Enter fullscreen mode Exit fullscreen mode

Understanding the Asynchronous Timer Function:

The function scheduleTimer takes two arguments:

  1. callback: A function to be executed after the delay.

  2. delay (optional): The delay in milliseconds before calling the callback (defaults to 1000ms).

It uses setTimeout from the browser's Web APIs to schedule the callback's execution after the specified delay. setTimeout returns a timer ID that can be used to cancel the timer if needed (shown in the test).

Explanation of the Test:

  1. Mock Callback: We create a mock function (mockCallback) using jest.fn() to track whether the callback is called and with what arguments.

  2. Schedule Timer: We call scheduleTimer with the mock callback and set a slightly longer delay (1200ms) than the default (1000ms). This ensures the callback gets called after the delay. The returned timer ID (timerId) is stored but not used in this test.

  3. Implicit Waiting: Vitest's magic lies here. Since the test function is asynchronous (async), Vitest automatically waits for all asynchronous operations (like the timer in this case) to complete before moving on to assertions. We don't need explicit await or manual waiting for the timer.

  4. Expectation - No Call Before Delay: We expect the mockCallback not to have been called before the delay (expect(mockCallback).not.toHaveBeenCalled()).

  5. Waiting for Callback: We use await new Promise to create a promise that resolves after the desired delay (1200ms). This implicitly tells Vitest to wait until the promise resolves, ensuring the scheduled timer has had enough time to trigger the callback.

  6. Expectation - Callback Called: After the wait, we expect the mockCallback to have been called once (expect(mockCallback).toHaveBeenCalledTimes(1)) and with no arguments (expect(mockCallback).toHaveBeenCalledWith()).

  7. Cleanup (Optional): Although not crucial for this test, we show how to clean up the timer by calling clearTimeout with the stored ID (timerId). This is good practice to prevent unused timers from lingering.

Data mocking

Data Mocking in JavaScript Unit Testing with Vitest

Data mocking is a fundamental technique in unit testing that allows you to isolate and test the behavior of your code without relying on external dependencies or real-world data sources. By creating controlled, predictable test data, you can ensure that your code functions correctly under various scenarios.

Theoretical Explanation

  • Why Mocking?

    • Unit tests should focus on the specific logic of your code, not external factors. Mocking dependencies like databases, APIs, or file systems prevents unpredictable behavior or errors from these external sources during testing.
    • It enables testing edge cases or error conditions without relying on real-world data that might be unavailable or time-consuming to set up.
  • How Mocking Works

    • Mocking frameworks like Vitest provide utilities to create mock objects or functions that simulate the behavior of the original dependencies.
    • You can define the expected inputs, outputs, and side effects (actions performed by the mock) for your tests.

Benefits of Data Mocking

  • Isolation: Tests focus solely on your code's logic, improving test reliability and maintainability.

  • Repeatability: Predictable mock data ensures consistent test results across runs.

  • Control: Define specific data scenarios to test different code paths.

  • Speed: Avoids the overhead of interacting with real external systems.

Vitest Mocking Utilities

Vitest offers the vi object for mocking:

  • vi.fn(): Creates a mock function for complete control over its behavior.

  • vi.spyOn(object, methodName): Spies on an existing method within an object, allowing you to track its calls and modify its behavior.

  • mockImplementation(fn): Sets a custom implementation function for the mock to define its return value or behavior.

  • mockResolvedValue(value): For mocks that simulate promises, specifies the resolved value for successful outcomes.

  • mockRejectedValue(value): Similar to mockResolvedValue, but defines the rejected value for promise errors.

Practical Code Example and Unit Test

// passwordGenerator.js
export function generatePassword(length, includeNumbers, includeSymbols) {
  const characters = [];
  if (includeNumbers) {
    characters.push(...Array(10).keys().map(String)); // Add numbers (0-9)
  }
  if (includeSymbols) {
    characters.push(..."!@#$%^&*()".split("")); // Add symbols
  }
  characters.push(...Array(26).keys().map((i) => String.fromCharCode(97 + i))); // Add lowercase letters
  characters.push(...Array(26).keys().map((i) => String.fromCharCode(65 + i))); // Add uppercase letters

  let password = "";
  for (let i = 0; i < length; i++) {
    const randomIndex = Math.floor(Math.random() * characters.length);
    password += characters[randomIndex];
  }

  return password;
}
Enter fullscreen mode Exit fullscreen mode

Function Breakdown:

  1. Character Set Construction:
    • It starts by building an array of characters (characters) based on the includeNumbers and includeSymbols flags:
      • If includeNumbers is true, numbers (0-9) are added.
      • If includeSymbols is true, common symbols are added.
      • Lowercase and uppercase letters are always included.
  2. Password Generation Loop:
    • An empty string (password) is initialized to hold the generated password.
    • The loop iterates length times (desired password length).
      • Inside the loop:
        • Math.random() is called to generate a random decimal value between 0 (inclusive) and 1 (exclusive).
        • This value is multiplied by the length of the characters array.
        • The result, after applying Math.floor, gives an index within the characters array.
        • The character at the calculated index is retrieved from characters and appended to the password string.
import { generatePassword } from './passwordGenerator';
import { vi } from 'vitest';

describe('generatePassword', () => {
  // Test different password lengths
  test('should create a password of length 1', () => {
    vi.spyOn(Math, 'random').mockReturnValueOnce(0.2); // Mock random value for index selection
    const password = generatePassword(1, true, false);
    expect(password.length).toBe(1);
  });

  test('should create a password of length 8', () => {
    // Mock multiple random values for different character selections
    vi.spyOn(Math, 'random')
      .mockReturnValueOnce(0.1) // Lowercase letter
      .mockReturnValueOnce(0.6) // Number
      .mockReturnValueOnce(0.3) // Lowercase letter
      .mockReturnValueOnce(0.8) // Uppercase letter
      .mockReturnValueOnce(0.4) // Symbol
      .mockReturnValueOnce(0.9) // Lowercase letter
      .mockReturnValueOnce(0.2) // Number
      .mockReturnValueOnce(0.7); // Uppercase letter

    const password = generatePassword(8, true, true);
    expect(password.length).toBe(8);
  });

  // Test password with different character inclusion options
  test('should create a password with only lowercase letters', () => {
    vi.spyOn(Math, 'random').mockReturnValueOnce(0.1) // Lowercase letter
                           .mockReturnValueOnce(0.3) // Lowercase letter
                           .mockReturnValueOnce(0.7); // Lowercase letter

    const password = generatePassword(3, false, false);
    expect(password.match(/[a-z]/g).length).toBe(3); // Check for only lowercase letters
  });

  test('should create a password with numbers and lowercase letters', () => {
    vi.spyOn(Math, 'random')
      .mockReturnValueOnce(0.2) // Number
      .mockReturnValueOnce(0.5) // Lowercase letter
      .mockReturnValueOnce(0.8); // Number

    const password = generatePassword(3, true, false);
    expect(password.match(/[0-9]/g).length).toBe(2); // Check for two numbers
    expect(password.match(/[a-z]/g).length).toBe(1); // Check for one lowercase letter
  });

  // Test edge cases
  test('should handle empty character set (no numbers or symbols)', () => {
    vi.spyOn(Math, 'random').mockReturnValueOnce(0.1); // Lowercase letter
    const password = generatePassword(1, false, false);
    expect(password.length).toBe(1); // Still generates a single character
  });

  test('should not create a password longer than the character set', () => {
    vi.spyOn(Math, 'random').mockReturnValueOnce(0); // Always return 0 for index (first character)
    const password = generatePassword(100, true, true);
    expect(password.length).toBe(characters.length); // Limited by character set length
  });
});
Enter fullscreen mode Exit fullscreen mode

This test suite, passwordGenerator.test.js, leverages meticulously testing the generatePassword function from passwordGenerator.js. The focus here is on mocking the Math.random function, which is the sole dependency that governs the randomness within the password generation process.

Event Testing

Event-driven programming is a fundamental paradigm in JavaScript, where code execution is triggered in response to user interactions or system events. Unit testing for event-driven code ensures that components react correctly to expected events and produce the desired outcomes.

Theoretical Explanation

  • Event Listeners: Components register event listeners (functions) to be invoked when specific events occur (e.g., clicks, key presses).

  • Testing Objectives: Event testing focuses on verifying:

    • Event Attachment: Listeners are attached to the correct elements with appropriate event types.
    • Event Handling: Listeners execute the intended logic when events are triggered.
    • State Updates: Event handlers update component state or emit signals as expected.
    • Side Effects: Any side effects (e.g., network requests, DOM manipulations) occur correctly.
  • Importance of Mocking: When testing event handling, it's often desirable to isolate the component under test from external dependencies. Mocking comes into play here by creating "fake" versions of external functions or objects that the event listener might interact with. This allows you to focus on testing the component's internal logic without introducing side effects or relying on external systems.

Types of Mocking for Event Testing:

  • Function Mocking: This is the most common approach, where you replace the actual event handler with a mock function using Jest's vi.fn(). This allows you to verify if the function was called, how many times, and with what arguments.

  • Module Mocking: If your event listener interacts with another module that's not under test, you can use Vitest's built-in mocking capabilities (provided by the vi object) to mock the entire module or specific exports. This can be useful for testing interactions with data fetching or asynchronous operations.

Practical Code Example

Consider a ProductCard component that displays a product name and dispatches an action to add the product to the cart when the "Add to Cart" button is clicked:

// ProductCard.js
import { addToCart } from './shoppingCartActions'; // Assuming you have a shoppingCartActions module

export default function ProductCard({ name, onClick }) {
  return (
    <div>
      {name}
      <button onClick={onClick}>Add to Cart</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Unit Test with Mocking:

// ProductCard.test.js
import { test, expect } from 'vitest';
import { render, fireEvent } from '@testing-library/react';
import ProductCard from './ProductCard';
import { addToCart } from './shoppingCartActions'; // Assuming you've imported the mocked action

vi.mock('./shoppingCartActions', () => ({
  addToCart: vi.fn(),
})); // Mock the addToCart function

test('ProductCard dispatches addToCart action on click', () => {
  const productName = 'Product X';
  const { getByText } = render(<ProductCard name={productName} />);

  const addToCartButton = getByText('Add to Cart');
  fireEvent.click(addToCartButton);

  expect(addToCart).toHaveBeenCalledTimes(1);
  expect(addToCart).toHaveBeenCalledWith(productName); // Verify argument passed to the action
});
Enter fullscreen mode Exit fullscreen mode

Explanation:

  1. Mock addToCart Action: We use jest.mock to create a mock version of the addToCart function from shoppingCartActions. This isolates the ProductCard component from the actual implementation of the action.

  2. Test Execution:

    • The test renders the ProductCard with a product name and a mocked onClick prop.
    • A click event is simulated on the "Add to Cart" button using fireEvent.click.
  3. Verify Mock Call: We use expect from Vitest to assert that the mocked addToCart function was called once (toHaveBeenCalledTimes(1)) and with the correct product name as an argument (toHaveBeenCalledWith(productName)).

Remember, mocking empowers you to test event-driven components in isolation, ensuring predictable behavior and comprehensive test coverage. By strategically incorporating mocking into your event testing practices, you can build more reliable and maintainable JavaScript applications.

The relationship :-

  1. Asynchronous Testing: A Response to Synchronous Limitations

    • Synchronous testing, while valuable, has limitations:
      • Slowness: It can be slow for applications that handle multiple tasks concurrently. Each test case needs to wait for the previous action to complete, leading to longer test execution times.
      • Limited Scope: Simulating real-world interactions becomes challenging. Synchronous tests can't accurately reflect scenarios where the application waits for external responses (network calls, database queries).
    • Asynchronous testing arose to address these limitations. It allows tests to initiate actions without waiting for immediate responses, mimicking how users interact with applications. This results in:
      • Faster Tests: Tests run concurrently, improving efficiency.
      • More Realistic Scenarios: Asynchronous testing can better simulate how users interact with applications that handle multiple tasks concurrently.
  2. Mocking: The Hero of Asynchronous Testing
    Mocking plays a crucial role in asynchronous testing by providing controlled environments:

    • Isolating Dependencies: Asynchronous operations often rely on external systems (databases, APIs). Mocking allows us to create simulated versions of these dependencies, ensuring the test focuses on the functionality being tested and not external factors.
    • Predictable Behavior: Mocks return pre-defined responses, eliminating the potential for unexpected delays or errors from external systems. This makes tests more reliable and repeatable.
    • Faster Execution: Mocks bypass external calls, speeding up test execution by avoiding network delays or database interactions.
  3. Event Testing: A Breeze with Mocking
    Mocking simplifies event testing by:

    • Controlling Event Data: Testers can define specific event data (e.g., user input, system triggers) that trigger the event. This allows for testing various scenarios without relying on actual user interactions or external events.
    • Predictable Outcomes: Mocks respond to events consistently, eliminating randomness caused by external systems. This makes it easier to verify the application's behavior in response to specific events.
    • Improved Isolation: Mocks isolate the event handling logic from other system parts. This allows for focused testing of how the application reacts to specific events.

Top comments (0)