Netspective Logo
Automated Testing

Unit Testing

Best practices for writing effective unit tests that verify individual components

Unit tests verify that individual units of code (functions, methods, classes) work correctly in isolation. They form the foundation of the testing pyramid and should comprise the majority of your test suite.

What is a Unit Test?

A unit test verifies a small, isolated piece of code - typically a single function or method. Unit tests should:

  • Execute quickly (milliseconds)
  • Test one thing at a time
  • Not depend on external systems
  • Be deterministic (same input = same output)

Unit Test Anatomy

describe('calculateDiscount', () => {
  it('should apply 10% discount for orders over $100', () => {
    // Arrange
    const orderTotal = 150;
    const discountThreshold = 100;
    const discountRate = 0.10;

    // Act
    const result = calculateDiscount(orderTotal, discountThreshold, discountRate);

    // Assert
    expect(result).toBe(15); // 10% of $150
  });

  it('should return 0 for orders under threshold', () => {
    const result = calculateDiscount(50, 100, 0.10);
    expect(result).toBe(0);
  });
});

Unit Testing Principles

Test Behavior, Not Implementation

Bad - Testing implementation details:

// Brittle: breaks if internal structure changes
it('should store items in the _items array', () => {
  const cart = new ShoppingCart();
  cart.addItem({ id: 1, name: 'Widget' });
  expect(cart._items.length).toBe(1);
});

Good - Testing behavior:

// Robust: tests public API and behavior
it('should include added item in cart contents', () => {
  const cart = new ShoppingCart();
  const widget = { id: 1, name: 'Widget' };

  cart.addItem(widget);

  expect(cart.getItems()).toContainEqual(widget);
  expect(cart.itemCount).toBe(1);
});

Write Descriptive Test Names

Test names should describe the scenario and expected outcome:

// Bad
it('test1', () => { ... });
it('works', () => { ... });

// Good
it('should reject password shorter than 8 characters', () => { ... });
it('should return null when user is not found', () => { ... });
it('should throw AuthenticationError when credentials are invalid', () => { ... });

One Assertion Per Concept

Each test should verify one logical concept (though multiple assertions may be needed):

// Testing user creation - multiple assertions for one concept
it('should create user with provided details', () => {
  const userData = { name: 'John', email: 'john@example.com' };

  const user = userService.create(userData);

  expect(user.id).toBeDefined();
  expect(user.name).toBe('John');
  expect(user.email).toBe('john@example.com');
  expect(user.createdAt).toBeInstanceOf(Date);
});

Test Organization

Describe Blocks for Grouping

describe('UserService', () => {
  describe('create', () => {
    it('should create user with valid data', () => { ... });
    it('should reject duplicate email', () => { ... });
    it('should hash password before storing', () => { ... });
  });

  describe('authenticate', () => {
    it('should return token for valid credentials', () => { ... });
    it('should reject invalid password', () => { ... });
    it('should lock account after 5 failed attempts', () => { ... });
  });

  describe('delete', () => {
    it('should soft delete by default', () => { ... });
    it('should hard delete when specified', () => { ... });
  });
});

Setup and Teardown

describe('OrderProcessor', () => {
  let processor;
  let mockPaymentGateway;

  beforeAll(() => {
    // Run once before all tests in this describe block
    initializeTestDatabase();
  });

  beforeEach(() => {
    // Run before each test
    mockPaymentGateway = createMockPaymentGateway();
    processor = new OrderProcessor(mockPaymentGateway);
  });

  afterEach(() => {
    // Run after each test
    jest.clearAllMocks();
  });

  afterAll(() => {
    // Run once after all tests
    cleanupTestDatabase();
  });

  it('should process valid order', () => { ... });
});

Mocking and Test Doubles

Types of Test Doubles

TypePurposeExample
StubReturns predefined valuesjest.fn().mockReturnValue(42)
MockTracks calls and verifies interactionsexpect(mock).toHaveBeenCalledWith(...)
SpyWraps real function, tracks callsjest.spyOn(object, 'method')
FakeWorking implementation for testingIn-memory database

When to Mock

Mock these:

  • External services (APIs, databases)
  • Time-dependent operations
  • Random number generation
  • File system operations
  • Network requests

Don't mock these:

  • The code you're testing
  • Simple value objects
  • Pure functions
  • Standard library functions

Mocking Examples

// Mocking a module
jest.mock('./emailService', () => ({
  sendEmail: jest.fn().mockResolvedValue({ success: true }),
}));

// Mocking a method
const mockSave = jest.spyOn(userRepository, 'save')
  .mockResolvedValue({ id: 1, name: 'John' });

// Mocking time
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15'));

// Verify mock was called correctly
expect(mockSave).toHaveBeenCalledTimes(1);
expect(mockSave).toHaveBeenCalledWith(expect.objectContaining({
  name: 'John',
  email: 'john@example.com',
}));

Testing Edge Cases

Input Validation

describe('validateEmail', () => {
  // Happy path
  it('should accept valid email', () => {
    expect(validateEmail('user@example.com')).toBe(true);
  });

  // Edge cases
  it('should reject email without @', () => {
    expect(validateEmail('userexample.com')).toBe(false);
  });

  it('should reject email without domain', () => {
    expect(validateEmail('user@')).toBe(false);
  });

  it('should reject empty string', () => {
    expect(validateEmail('')).toBe(false);
  });

  it('should handle null input', () => {
    expect(validateEmail(null)).toBe(false);
  });

  it('should handle undefined input', () => {
    expect(validateEmail(undefined)).toBe(false);
  });

  // Boundary conditions
  it('should accept email with maximum length', () => {
    const longEmail = 'a'.repeat(64) + '@' + 'b'.repeat(63) + '.com';
    expect(validateEmail(longEmail)).toBe(true);
  });
});

Error Handling

describe('divideNumbers', () => {
  it('should throw when dividing by zero', () => {
    expect(() => divideNumbers(10, 0)).toThrow('Division by zero');
  });

  it('should throw TypeError for non-numeric input', () => {
    expect(() => divideNumbers('10', 2)).toThrow(TypeError);
  });
});

describe('fetchUser', () => {
  it('should throw NotFoundError when user does not exist', async () => {
    mockRepository.findById.mockResolvedValue(null);

    await expect(userService.fetchUser(999))
      .rejects
      .toThrow(NotFoundError);
  });
});

Parameterized Tests

Test multiple cases with the same logic:

describe('isValidPassword', () => {
  const validPasswords = [
    'Password123!',
    'Str0ng_P@ssword',
    'Complex#Pass99',
  ];

  const invalidPasswords = [
    ['short', 'too short'],
    ['alllowercase1!', 'missing uppercase'],
    ['ALLUPPERCASE1!', 'missing lowercase'],
    ['NoNumbers!!', 'missing number'],
    ['NoSpecial123', 'missing special character'],
  ];

  test.each(validPasswords)('should accept valid password: %s', (password) => {
    expect(isValidPassword(password)).toBe(true);
  });

  test.each(invalidPasswords)(
    'should reject invalid password: %s (%s)',
    (password, reason) => {
      expect(isValidPassword(password)).toBe(false);
    }
  );
});

Async Testing

Testing Promises

// Async/await (preferred)
it('should fetch user data', async () => {
  const user = await userService.getById(1);
  expect(user.name).toBe('John');
});

// Testing rejection
it('should reject when user not found', async () => {
  await expect(userService.getById(999)).rejects.toThrow('User not found');
});

// Using resolves/rejects matchers
it('should resolve with user data', () => {
  return expect(userService.getById(1)).resolves.toMatchObject({
    name: 'John',
  });
});

Testing Callbacks

it('should call callback with result', (done) => {
  legacyApi.fetchData((err, data) => {
    expect(err).toBeNull();
    expect(data).toBeDefined();
    done();
  });
});

Testing Timers

jest.useFakeTimers();

it('should debounce function calls', () => {
  const callback = jest.fn();
  const debounced = debounce(callback, 1000);

  debounced();
  debounced();
  debounced();

  expect(callback).not.toHaveBeenCalled();

  jest.advanceTimersByTime(1000);

  expect(callback).toHaveBeenCalledTimes(1);
});

Unit Testing for Compliance

Requirement Traceability

Document which tests verify which requirements:

/**
 * @requirement REQ-SEC-001
 * @description Password must meet complexity requirements
 */
describe('Password Validation - REQ-SEC-001', () => {
  it('should require minimum 8 characters', () => { ... });
  it('should require at least one uppercase letter', () => { ... });
  it('should require at least one number', () => { ... });
  it('should require at least one special character', () => { ... });
});

Test Evidence

Generate test reports for audit trails:

# Generate JUnit XML report
jest --reporters=default --reporters=jest-junit

# Generate HTML report
jest --coverage --coverageReporters=html

Best Practices Summary

Do

  • Keep tests fast (< 100ms each)
  • Use descriptive test names
  • Follow AAA pattern (Arrange-Act-Assert)
  • Test edge cases and error conditions
  • Mock external dependencies
  • Run tests in isolation

Don't

  • Test implementation details
  • Share state between tests
  • Make tests depend on execution order
  • Use production data in tests
  • Skip writing tests for "simple" code
  • Ignore failing tests


Compliance

This section fulfills ISO 13485 requirements for design verification (7.3.6) and control of records (4.2.4), and ISO 27001 requirements for secure development lifecycle (A.8.25) and security testing (A.8.29).

View full compliance matrix

How is this guide?

Last updated on

On this page