DEV Community

Waqas Khan
Waqas Khan

Posted on

The Importance of Writing Unit Test Cases: A Software Engineer’s Perspective

Introduction

In software development, writing code is just one part of the job. Ensuring that code is reliable, maintainable, and free of regressions is just as critical—if not more. Unit testing is a fundamental practice that helps achieve these goals by validating individual components of an application in isolation.

After a decade of writing and maintaining codebases across different domains, I’ve seen firsthand how lack of proper unit tests can lead to unmaintainable software, production failures, and frustrated teams. This article covers why unit tests are essential, best practices, and real-world examples of how they can save developers from nightmare debugging sessions.


1. Why Unit Testing is Crucial

1.1 Preventing Bugs and Regressions

One of the primary reasons for writing unit tests is to catch bugs early. When a developer modifies a function, unit tests ensure that existing logic doesn’t break. Without tests, even a minor change can lead to unexpected failures.

Example: A simple function without tests can easily break:

def add_numbers(a, b):
    return a - b  # Bug: Subtraction instead of addition
Enter fullscreen mode Exit fullscreen mode

A well-written unit test would catch this mistake immediately:

import unittest
from my_module import add_numbers

class TestMathFunctions(unittest.TestCase):
    def test_add_numbers(self):
        self.assertEqual(add_numbers(2, 3), 5)  # This will fail

if __name__ == "__main__":
    unittest.main()
Enter fullscreen mode Exit fullscreen mode

Without this test, the bug could easily slip into production.


1.2 Enforcing Code Quality and Design

Unit tests encourage developers to write modular, reusable, and loosely coupled code. If a function is difficult to test, it's often a sign that it needs refactoring.

For instance, the following function is hard to test because it directly depends on external resources:

def fetch_user_data(user_id):
    response = requests.get(f"https://api.example.com/users/{user_id}")
    return response.json()
Enter fullscreen mode Exit fullscreen mode

A more testable version would abstract the external dependency:

def fetch_user_data(user_id, api_client):
    return api_client.get_user(user_id)
Enter fullscreen mode Exit fullscreen mode

Now, we can mock api_client in unit tests:

from unittest.mock import MagicMock

def test_fetch_user_data():
    mock_api = MagicMock()
    mock_api.get_user.return_value = {"id": 1, "name": "Alice"}

    assert fetch_user_data(1, mock_api) == {"id": 1, "name": "Alice"}
Enter fullscreen mode Exit fullscreen mode

This makes the function easier to test and reduces dependencies on external services during testing.


1.3 Enabling Confident Refactoring

As projects evolve, refactoring is inevitable. Without unit tests, even a simple change can introduce unexpected side effects. Unit tests act as a safety net, allowing developers to refactor with confidence.

Imagine a team decides to optimize the following function:

def calculate_discount(price, discount):
    return price - (price * discount / 100)
Enter fullscreen mode Exit fullscreen mode

After refactoring for better precision:

def calculate_discount(price: float, discount: float) -> float:
    return round(price * (1 - discount / 100), 2)
Enter fullscreen mode Exit fullscreen mode

A comprehensive unit test suite ensures correctness:

def test_calculate_discount():
    assert calculate_discount(100, 10) == 90.00
    assert calculate_discount(50, 20) == 40.00
    assert calculate_discount(99.99, 15) == 84.99
Enter fullscreen mode Exit fullscreen mode

This prevents regressions when improving code.


2. Unit Testing Best Practices

2.1 Follow the AAA Pattern (Arrange, Act, Assert)

The AAA pattern keeps tests structured and readable:

Arrange: Set up test data and dependencies

Act: Call the function being tested

Assert: Verify the result

Example in JavaScript (Jest):

test("should return the correct full name", () => {
  // Arrange
  const firstName = "John";
  const lastName = "Doe";

  // Act
  const fullName = getFullName(firstName, lastName);

  // Assert
  expect(fullName).toBe("John Doe");
});
Enter fullscreen mode Exit fullscreen mode

This pattern makes tests easy to understand and debug.


2.2 Keep Tests Independent

Each test should be self-contained and not rely on other tests. Avoid shared state between tests, as it can cause unpredictable failures.

Bad Example:

global_counter = 0

def test_increment():
    global global_counter
    global_counter += 1
    assert global_counter == 1

def test_double_increment():
    global global_counter
    global_counter += 2
    assert global_counter == 2  # Might fail depending on order
Enter fullscreen mode Exit fullscreen mode

Good Example:

def increment(value):
    return value + 1

def test_increment():
    assert increment(0) == 1
    assert increment(1) == 2
Enter fullscreen mode Exit fullscreen mode

2.3 Use Mocks and Stubs to Isolate Tests

Tests should run fast and not depend on external APIs or databases. Use mocks to simulate external dependencies.

Example: Mocking a database call in Node.js (Jest & MongoDB):

const { getUser } = require("./userService");
const db = require("./db");

jest.mock("./db");

test("should return user from database", async () => {
  db.findUserById.mockResolvedValue({ id: 1, name: "Alice" });

  const user = await getUser(1);
  expect(user.name).toBe("Alice");
});
Enter fullscreen mode Exit fullscreen mode

This ensures the test doesn’t actually hit the database, making it faster and more reliable.


3. Common Pitfalls to Avoid

🚫 Skipping Tests for “Simple” Functions

Even a one-liner can break. Always write tests.

🚫 Testing Implementation Instead of Behavior

Avoid testing internal logic; focus on inputs/outputs.

🚫 Ignoring Edge Cases

Test for edge cases like empty inputs, null values, and unexpected data.


4. Unit Testing in CI/CD Pipelines

Automating unit tests in CI/CD ensures new code doesn’t introduce failures.

Example: Running tests in GitHub Actions

name: Run Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Install dependencies
        run: npm install
      - name: Run unit tests
        run: npm test
Enter fullscreen mode Exit fullscreen mode

This setup ensures every push and pull request runs the test suite before merging.


Conclusion

Unit testing is an essential practice for writing robust, scalable, and maintainable software. As an engineer with a decade of experience, I’ve learned that skipping tests often leads to painful debugging sessions and production failures.

By following best practices like the AAA pattern, keeping tests independent, and automating test execution in CI/CD pipelines, teams can build reliable applications with confidence.

🚀 Start writing unit tests today—it will save you countless hours in the future!

Top comments (5)

Collapse
 
ab_vajarekar3000 profile image
Abhishek Vajarekar

This should be required reading for devs who still skip tests thinking their code is 'too simple'

Collapse
 
pzapolskii profile image
Pavel Zapolskii

Not bad read

Collapse
 
kaldzaq_846890d6947624dd0 profile image
kaldzaq

Finally, someone explained mocks clearly

Collapse
 
__ac030c0ae1 profile image
Анастасия Иван

Wish my team read this last sprint

Collapse
 
nazar_alexandrov_f6562542 profile image
Nazar Alexandrov

Unit tests saved me from a 2AM prod crash. Preach!