Skip to content

Unit Testing Guide — Best Practices & Examples (2026)

DodaTech Updated 2026-06-20 7 min read

In this tutorial, you'll learn about Unit Testing Guidesting" >}} Guide. We cover key concepts, practical examples, and best practices.

Unit testing is the practice of verifying the smallest isolatable piece of code — a single function, method, or module — in isolation from the rest of the system, ensuring each unit behaves correctly under all conditions.

What You'll Learn

You'll master the Arrange-Act-Assert pattern, learn test naming conventions that document intent, understand what to test (and what not to), mock dependencies correctly, handle edge cases, and organize test suites for maintainability.

Why Unit Testing Matters

Without unit tests, every refactor is blind. You change one line and hope nothing breaks. In production, that hope costs money. At DodaTech, Durga Antivirus Pro runs thousands of unit tests per commit — catching signature parsing errors before they reach users.

Unit Testing Learning Path

flowchart LR
  A[Testing Basics] --> B[Unit Testing Guide]
  B --> C[Test-Driven Development]
  B --> D[Code Coverage]
  B --> E{Mocking}
  style B fill:#f90,color:#fff

The AAA Pattern

Every unit test follows three phases: Arrange, Act, Assert.

// Jest example — testing a discount calculator
function calculateDiscount(price, code) {
  if (code === 'SAVE10') return price * 0.9;
  if (code === 'SAVE20') return price * 0.8;
  return price;
}

test('applies 10% discount with SAVE10 code', () => {
  // Arrange
  const price = 100;
  const code = 'SAVE10';

  // Act
  const result = calculateDiscount(price, code);

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

Expected output:

PASS  ./discount.test.js
  ✓ applies 10% discount with SAVE10 code (2 ms)

Test Naming Conventions

A test name should read like a sentence: "[Unit] should [expected behavior] when [condition]."

// Good names — document intent
test('rejects order when inventory is insufficient', () => {});
test('returns cached result when TTL has not expired', () => {});
test('throws ValidationError when email format is invalid', () => {});

// Bad names — vague and useless
test('test function 1', () => {});
test('order test', () => {});
test('check email', () => {});

What to Test

Test public behavior, not private implementation.

# Python unittest example
import unittest

class Calculator:
    def add(self, a, b):
        return a + b

    def divide(self, a, b):
        if b == 0:
            raise ValueError("Cannot divide by zero")
        return a / b

class TestCalculator(unittest.TestCase):
    def setUp(self):
        self.calc = Calculator()

    def test_add_returns_sum_of_two_numbers(self):
        result = self.calc.add(3, 5)
        self.assertEqual(result, 8)

    def test_divide_returns_quotient(self):
        result = self.calc.divide(10, 2)
        self.assertEqual(result, 5)

    def test_divide_raises_error_when_dividing_by_zero(self):
        with self.assertRaises(ValueError):
            self.calc.divide(10, 0)

Expected output:

...
----------------------------------------------------------------------
Ran 3 tests in 0.001s

OK

FIRST Principles

Good unit tests follow FIRST:

Principle Meaning
Fast Run in milliseconds
Isolated No shared state or dependencies
Repeatable Same result every run
Self-validating Pass or fail, no manual inspection
Timely Written before or alongside code

Coverage vs Quality

100% coverage does not mean 100% bug-free. It means every line executed, not every scenario verified.

// High coverage, low quality test
test('add function works', () => {
  expect(add(1, 2)).toBe(3);
});
// Line coverage: 100%, but edge cases: 0%

A better approach: focus coverage on critical paths and edge cases. 80-90% on core modules beats 100% on trivial code.

Testing Edge Cases

Edge cases are where bugs hide. Always test boundaries.

function processArray(items) {
  if (!items || !items.length) return [];
  return items.map(item => item.toUpperCase());
}

test('returns empty array for null input', () => {
  expect(processArray(null)).toEqual([]);
});

test('returns empty array for undefined input', () => {
  expect(processArray(undefined)).toEqual([]);
});

test('returns empty array for empty array', () => {
  expect(processArray([])).toEqual([]);
});

test('transforms single element array', () => {
  expect(processArray(['a'])).toEqual(['A']);
});

test('transforms multiple elements', () => {
  expect(processArray(['a', 'b', 'c'])).toEqual(['A', 'B', 'C']);
});

Expected output:

PASS  ./processArray.test.js
  ✓ returns empty array for null input (1 ms)
  ✓ returns empty array for undefined input (1 ms)
  ✓ returns empty array for empty array (1 ms)
  ✓ transforms single element array (1 ms)
  ✓ transforms multiple elements (1 ms)

Testing Private Methods

Don't test private methods directly. If a private method has complex logic, extract it into its own module where it becomes a public API.

// Instead of testing _parseConfig directly, test the public method
class ConfigLoader {
  load(path) {
    const raw = this._readFile(path);
    return this._parseConfig(raw);
  }

  _readFile(path) { /* ... */ }
  _parseConfig(raw) { /* complex logic */ }
}

// Test through the public API
test('load returns parsed config for valid file', () => {
  const loader = new ConfigLoader();
  // Mock only the external boundary
  jest.spyOn(loader, '_readFile').mockReturnValue('{"key": "value"}');
  const result = loader.load('/path/to/config.json');
  expect(result.key).toBe('value');
});

Organizing Test Suites

Group related tests with describe blocks. Keep test files close to the code they test.

src/
├── services/
│   ├── paymentService.js
│   ├── paymentService.test.js
│   ├── userService.js
│   └── userService.test.js
├── utils/
│   ├── formatter.js
│   └── formatter.test.js

Common Mistakes

1. Testing Implementation Details

Tests that break on refactor without behavior change are testing how, not what.

2. Flaky Tests

Random failures from shared mutable state, timeouts, or test order dependency erode trust.

3. Mocking Everything

Testing against mocks of mocks verifies nothing real. Mock only external boundaries.

4. No Edge Case Tests

Happy-path-only tests miss nulls, empties, errors, and boundaries where real bugs live.

5. Slow Test Suites

Unit tests that touch the database or network aren't unit tests. Keep them fast.

6. Giant Test Files

A single test file with 500+ lines becomes unmaintainable. Split by module.

7. Ignoring Failed Tests

Red builds become normal. Fix failures immediately or disable the test.

Practice Questions

1. What are the three phases of a unit test? Arrange (set up), Act (execute), Assert (verify). This is the AAA pattern.

2. How do you test edge cases effectively? Test null/undefined inputs, empty collections, boundary values, error conditions, and invalid data shapes.

3. Should you test private methods? No. Test through the public API. If private logic is complex, extract it into its own module.

4. What is a flaky test and why is it dangerous? A test that passes and fails without code changes. It erodes team trust in the test suite.

5. Challenge: Write a test suite for a password strength checker. Implement a function checkStrength(password) that returns 'weak', 'medium', or 'strong' based on length and character variety. Test all three levels plus edge cases.

Mini Project: Test Runner for a Validation Library

// validators.js
function isEmail(value) {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
}

function isUrl(value) {
  try {
    new URL(value);
    return true;
  } catch {
    return false;
  }
}

function isInRange(value, min, max) {
  return typeof value === 'number' && value >= min && value <= max;
}

module.exports = { isEmail, isUrl, isInRange };
// validators.test.js
const { isEmail, isUrl, isInRange } = require('./validators');

describe('isEmail', () => {
  test('returns true for valid email', () => {
    expect(isEmail('user@example.com')).toBe(true);
  });

  test('returns false for email without @', () => {
    expect(isEmail('userexample.com')).toBe(false);
  });

  test('returns false for empty string', () => {
    expect(isEmail('')).toBe(false);
  });
});

describe('isUrl', () => {
  test('returns true for valid URL', () => {
    expect(isUrl('https://example.com')).toBe(true);
  });

  test('returns false for random text', () => {
    expect(isUrl('not a url')).toBe(false);
  });
});

describe('isInRange', () => {
  test('returns true when value is within range', () => {
    expect(isInRange(5, 1, 10)).toBe(true);
  });

  test('returns false when value is below range', () => {
    expect(isInRange(0, 1, 10)).toBe(false);
  });

  test('returns false for non-numeric value', () => {
    expect(isInRange('5', 1, 10)).toBe(false);
  });
});

FAQ

What is the AAA pattern in unit testing?

Arrange-Act-Assert: set up the test data, execute the function, and verify the result. It's the standard structure for all unit tests.

How much code coverage is enough?

80-90% on critical modules is a solid target. 100% is not necessary — focus on behavior coverage, not line coverage.

What is the difference between a stub and a mock?

A stub returns fixed values. A mock has expectations about how it should be called and can verify interaction patterns.

Should I use real databases in unit tests?

No. Unit tests must be fast and isolated. Use mocks for database calls. Save real database tests for integration tests.

How do I name test files?

Place them next to the source file with a .test.js or .spec.js suffix: paymentService.test.js next to paymentService.js.

What's Next

Test-Driven Development — TDD Workflow Explained
Code Coverage — Statement, Branch, Path Coverage
Mocking in Tests: Complete Guide

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro