1. Understanding unit test basics

Sandheep Kumar Patro - Jun 20 - - Dev Community

What is Unit Testing?

Unit testing is a software development practice that involves isolating individual units of code (functions, classes, modules) and verifying their correctness under various conditions. It ensures that each unit behaves as expected and produces the intended output for a given set of inputs.

Benefits of Unit Testing:

  • Improved Code Quality: Catches errors early in the development process, leading to more robust and reliable software.

  • Increased Confidence in Changes: Makes developers more confident to modify code without introducing regressions since unit tests act as a safety net.

  • Better Maintainability: Well-written unit tests document how code works, improving code comprehension for future maintainers.

Let's consider a simple function in TypeScript that calculates the area of a rectangle:

// area.ts
export function calculateArea(width: number, height: number): number {
  return width * height;
}
Enter fullscreen mode Exit fullscreen mode
import { expect, describe, it } from 'vitest';
import { calculateArea } from './area';

describe('calculateArea function', () => {
  it('should calculate the area of a rectangle correctly', () => {
    const width = 5;
    const height = 4;
    const expectedArea = 20;

    const actualArea = calculateArea(width, height);

    expect(actualArea).toEqual(expectedArea);
  });

  it('should return 0 for zero width or height', () => {
    const testCases = [
      { width: 0, height: 5, expectedArea: 0 },
      { width: 5, height: 0, expectedArea: 0 },
    ];

    for (const testCase of testCases) {
      const { width, height, expectedArea } = testCase;
      const actualArea = calculateArea(width, height);
      expect(actualArea).toEqual(expectedArea);
    }
  });
});
Enter fullscreen mode Exit fullscreen mode

Explanation:

  • We import expect from Vitest for assertions.

  • We import calculateArea from our area.ts file.

  • We use describe to create a test suite for calculateArea.

  • Within the describe block, we define two test cases using it:

    • The first test verifies if the function calculates the area correctly for non-zero dimensions.

      • We define width, height, and expectedArea.
      • We call calculateArea with the defined values.
      • We use expect to assert that the actual area (actualArea) matches the expected area.
    • The second test covers scenarios with zero width or height.

      • We create an array of test cases (testCases) with different input values.
      • We iterate through each test case using a for loop.
      • For each case, we extract width, height, and expectedArea.
      • We call calculateArea with these values and assert the result using expect.

Elements of Unit testing:

The elements that make up a unit test in Vitest (or any other unit testing framework) can be broken down into three main parts:

  1. Test Runner and Assertions:
    • Test Runner: This is the core functionality that executes your test cases and provides the framework for running them. Vitest leverages the power of Vite for fast test execution.
    • Assertions: These are statements that verify the expected outcome of your tests. Vitest offers built-in assertions (like expect) or allows using libraries like Chai for more advanced assertions.
  2. Test Description:
    • describe and it blocks: These functions from Vitest (similar to other frameworks) structure your tests.
      • describe defines a test suite that groups related tests for a specific functionality.
      • Within describe, individual test cases are defined using it blocks. Each it block describes a specific scenario you want to test.
  3. Test Arrangements (Optional):
    • Mocking and Stubbing: In some cases, you might need to mock or stub external dependencies or functions to isolate your unit under test. Vitest offers ways to mock dependencies using functions like vi.fn(). These elements come together to form a unit test. You write assertions within it blocks to verify the expected behavior of your unit (function, class, module) when the test runner executes the test with specific arrangements (mocking if needed).

Let's consider a simple function that calculates the area of a rectangle:

Example 1:- Simple Explanation without mocking

function calculateArea(width, height) {
  if (width <= 0 || height <= 0) {
    throw new Error("Width and height must be positive numbers");
  }
  return width * height;
}
Enter fullscreen mode Exit fullscreen mode

Here's a unit test for this function using Vitest, highlighting the elements mentioned earlier:

// test file: rectangleArea.test.js
import { test, expect } from 'vitest';

describe('calculateArea function', () => {
  // Test case 1: Valid inputs
  it('calculates the area correctly for valid dimensions', () => {
    const width = 5;
    const height = 3;
    const expectedArea = 15;

    // Test arrangement (no mocking needed here)
    const actualArea = calculateArea(width, height);

    // Assertions
    expect(actualArea).toBe(expectedArea);
  });

  // Test case 2: Invalid inputs (edge case)
  it('throws an error for non-positive width or height', () => {
    const invalidWidth = 0;
    const validHeight = 3;

    // Test arrangement (no mocking needed here)

    expect(() => calculateArea(invalidWidth, validHeight)).toThrow();
  });
});
Enter fullscreen mode Exit fullscreen mode

Explanation of Elements:

  1. Test Runner and Assertions:
    • Vitest acts as the test runner, executing the test cases defined within the describe and it blocks.
    • The expect function from Vitest allows us to make assertions about the outcome of the test. Here, we use expect(actualArea).toBe(expectedArea) to verify the calculated area matches the expected value.
  2. Test Description:
    • The describe block groups related tests, in this case, all tests for the calculateArea function.
    • Each it block defines a specific test case. Here, we have two test cases: one for valid inputs and another for invalid inputs (edge case).
  3. Test Arrangements (Optional):
    • In this example, we don't need mocking or stubbing as we're directly testing the function with its arguments. However, if the function relied on external dependencies (like file I/O or network calls), we might need to mock them to isolate the unit under test.

Example 2 :- An advance example involving mocking and stubbing

function calculateArea(width, height) {
  if (width <= 0 || height <= 0) {
    throw new Error("Width and height must be positive numbers");
  }
  return width * height;
}

async function fetchData() {
  // Simulate fetching data (could be network call or file read)
  return new Promise((resolve) => resolve({ width: 5, height: 3 }));
}
Enter fullscreen mode Exit fullscreen mode

Now, we want to test calculateArea in isolation without actually making the external call in fetchData. Here's how mocking comes into play:

// test file: rectangleArea.test.js
import { test, expect, vi } from 'vitest';

describe('calculateArea function', () => {
  // Test case 1: Valid dimensions from mocked data
  it('calculates the area correctly using mocked dimensions', async () => {
    const expectedWidth = 5;
    const expectedHeight = 3;
    const expectedArea = 15;

    // Test arrangement (mocking)
    vi.mock('./fetchData', async () => ({ width: expectedWidth, height: expectedHeight }));

    // Call the function under test with any values (mocked data will be used)
    const actualArea = await calculateArea(1, 1); // Doesn't matter what we pass here

    // Assertions
    expect(actualArea).toBe(expectedArea);

    // Restore mocks (optional, but good practice)
    vi.restoreAllMocks();
  });

  // Other test cases (can remain the same as previous example)
});
Enter fullscreen mode Exit fullscreen mode

Explanation of Mocking:

  1. Mocking fetchData:
    • We use vi.mock from Vitest to mock the fetchData function.
    • Inside the mock implementation (an async function here), we return pre-defined values for width and height. This way, calculateArea receives the mocked data instead of making the actual external call.
  2. Test Execution:
    • The test case calls calculateArea with any values (they won't be used due to mocking).
    • Since fetchData is mocked, the pre-defined dimensions are used for calculation.
  3. Assertions:
    • We assert that the actualArea matches the expected value based on the mocked data.

Benefits of Mocking:

  • Isolates the unit under test (calculateArea) from external dependencies.
  • Makes tests faster and more reliable (no external calls involved).
  • Allows testing specific scenarios with controlled data.

Remember: After each test, it's good practice to restore mocks using vi.restoreAllMocks() to avoid affecting subsequent tests. This ensures a clean slate for each test case.

. . . . . . .