Testing & TDD

Unit Testing Fundamentals

20 min Lesson 2 of 35

Unit Testing Fundamentals

Unit testing is the practice of testing the smallest testable parts of an application, called units, in isolation from the rest of the system. A unit is typically a single function, method, or class. Unit tests form the foundation of your test suite and are the most numerous tests you'll write.

What is a Unit?

A "unit" is the smallest piece of code that can be logically isolated and tested independently. The definition can vary:

  • Function/Method Level: Testing individual functions or methods
  • Class Level: Testing a class with all its methods
  • Module Level: Testing a small module or component

Key Principle: A unit test should test one unit of functionality in isolation. If your test requires a database, API calls, or file system operations, it's likely an integration test, not a unit test.

Characteristics of Good Unit Tests

Effective unit tests share these characteristics:

  • Fast: Execute in milliseconds, allowing frequent execution
  • Isolated: Test one thing at a time without external dependencies
  • Repeatable: Same input always produces same output
  • Self-Validating: Pass or fail automatically, no manual checking
  • Thorough: Cover normal cases, edge cases, and error conditions
  • Readable: Clear test names and structure

The AAA Pattern

The AAA (Arrange-Act-Assert) pattern is the most popular structure for writing unit tests. It divides tests into three clear sections:

1. Arrange (Setup)

Set up the test conditions and initialize objects needed for the test.

2. Act (Execute)

Execute the code being tested—call the function or method.

3. Assert (Verify)

Verify that the code behaved as expected.

<script> // JavaScript example using AAA pattern test('calculateTotal should sum prices correctly', () => { // Arrange: Setup test data const items = [ { price: 10.00, quantity: 2 }, { price: 5.50, quantity: 3 } ]; // Act: Execute the function const total = calculateTotal(items); // Assert: Verify the result expect(total).toBe(36.50); }); </script>
<?php // PHP example using AAA pattern public function test_user_full_name_concatenation() { // Arrange $user = new User(); $user->first_name = 'John'; $user->last_name = 'Doe'; // Act $fullName = $user->getFullName(); // Assert $this->assertEquals('John Doe', $fullName); } ?>

Pro Tip: Adding blank lines between the three sections makes tests more readable and easier to understand at a glance.

Anatomy of a Unit Test

Let's break down the structure of a well-written unit test:

Test Name

The test name should clearly describe what is being tested and the expected outcome.

<script> // Good: Descriptive and clear test('login returns token when credentials are valid', () => { }); // Bad: Vague and unclear test('test1', () => { }); test('it works', () => { }); </script>

Test Body

Follow the AAA pattern to structure the test logic clearly.

Assertions

Use appropriate assertions that make test failures easy to understand.

Writing Your First Unit Test

Let's write a complete unit test for a simple calculator function:

<script> // calculator.js - The code we want to test function add(a, b) { return a + b; } function subtract(a, b) { return a - b; } function multiply(a, b) { return a * b; } function divide(a, b) { if (b === 0) { throw new Error('Division by zero'); } return a / b; } module.exports = { add, subtract, multiply, divide }; </script>
<script> // calculator.test.js - Our unit tests const { add, subtract, multiply, divide } = require('./calculator'); describe('Calculator', () => { describe('add', () => { test('should add two positive numbers', () => { // Arrange const a = 5; const b = 3; // Act const result = add(a, b); // Assert expect(result).toBe(8); }); test('should add negative numbers', () => { expect(add(-5, -3)).toBe(-8); }); test('should add zero', () => { expect(add(5, 0)).toBe(5); }); }); describe('divide', () => { test('should divide numbers correctly', () => { expect(divide(10, 2)).toBe(5); }); test('should throw error when dividing by zero', () => { expect(() => divide(10, 0)).toThrow('Division by zero'); }); }); }); </script>

Test Organization

Organize tests logically to make them easy to navigate and understand:

describe Blocks

Group related tests together using describe blocks.

<script> describe('User', () => { describe('authentication', () => { test('should login with valid credentials', () => { }); test('should reject invalid password', () => { }); }); describe('profile', () => { test('should update profile information', () => { }); test('should validate email format', () => { }); }); }); </script>

Testing Different Scenarios

Comprehensive unit tests cover multiple scenarios:

Happy Path (Normal Case)

Test the expected behavior with valid inputs.

<script> test('should create user with valid data', () => { const user = createUser('John', 'john@example.com'); expect(user.name).toBe('John'); expect(user.email).toBe('john@example.com'); }); </script>

Edge Cases

Test boundary conditions and unusual but valid inputs.

<script> test('should handle empty string', () => { expect(trimString('')).toBe(''); }); test('should handle very long strings', () => { const longString = 'a'.repeat(10000); expect(processString(longString).length).toBe(10000); }); </script>

Error Cases

Test how your code handles invalid inputs and error conditions.

<script> test('should throw error for null input', () => { expect(() => processUser(null)).toThrow(); }); test('should return error message for invalid email', () => { const result = validateEmail('invalid-email'); expect(result.isValid).toBe(false); expect(result.error).toBe('Invalid email format'); }); </script>

Common Mistake: Testing only the happy path. Always test edge cases and error conditions—that's where bugs often hide!

Test Isolation

Unit tests must be isolated from external dependencies and from each other:

No External Dependencies

Don't rely on databases, APIs, file systems, or network calls.

<script> // BAD: This is not a unit test—it depends on a database test('should save user to database', async () => { await database.connect(); const user = await User.create({ name: 'John' }); expect(user.id).toBeDefined(); }); // GOOD: This is a unit test—it tests logic in isolation test('should validate user data before saving', () => { const user = new User({ name: 'John' }); expect(user.isValid()).toBe(true); }); </script>

Independent Tests

Each test should run independently—they shouldn't affect each other.

<script> // BAD: Tests depend on shared state let counter = 0; test('first test', () => { counter++; expect(counter).toBe(1); }); test('second test', () => { counter++; // Depends on first test! expect(counter).toBe(2); }); // GOOD: Each test is independent test('first test', () => { let counter = 0; counter++; expect(counter).toBe(1); }); test('second test', () => { let counter = 0; counter++; expect(counter).toBe(1); }); </script>

Setup and Teardown

Use setup and teardown hooks to prepare and clean up test environments:

<script> describe('ShoppingCart', () => { let cart; // Runs before each test beforeEach(() => { cart = new ShoppingCart(); }); // Runs after each test afterEach(() => { cart = null; }); test('should start empty', () => { expect(cart.items).toHaveLength(0); }); test('should add item', () => { cart.addItem({ id: 1, name: 'Product' }); expect(cart.items).toHaveLength(1); }); }); </script>

Available Hooks:

  • beforeAll() - Runs once before all tests in the describe block
  • beforeEach() - Runs before each test
  • afterEach() - Runs after each test
  • afterAll() - Runs once after all tests in the describe block

Testing Pure vs Impure Functions

Pure Functions (Easy to Test)

Pure functions always return the same output for the same input and have no side effects.

<script> // Pure function - easy to test function calculateDiscount(price, percentage) { return price * (percentage / 100); } test('should calculate discount correctly', () => { expect(calculateDiscount(100, 10)).toBe(10); expect(calculateDiscount(100, 10)).toBe(10); // Always same result }); </script>

Impure Functions (Require Mocking)

Impure functions have side effects or depend on external state—they need special handling.

<script> // Impure function - harder to test function saveUser(user) { const timestamp = new Date(); // External dependency user.createdAt = timestamp; database.save(user); // Side effect return user; } // Test requires mocking test('should set createdAt timestamp', () => { const mockDate = new Date('2024-01-01'); jest.spyOn(global, 'Date').mockImplementation(() => mockDate); const user = saveUser({ name: 'John' }); expect(user.createdAt).toEqual(mockDate); }); </script>

What NOT to Test

Not everything needs a unit test. Avoid testing:

  • Third-Party Code: Don't test libraries or frameworks—trust they're already tested
  • Trivial Code: Simple getters/setters don't need tests
  • Private Methods: Test public interfaces; private methods are implementation details
  • Framework Magic: Don't test framework features like routing or ORM behavior

Rule of Thumb: If your test is just repeating the implementation code, it's probably not adding value. Test behavior, not implementation.

Common Unit Testing Patterns

Testing Return Values

<script> test('should return uppercase string', () => { expect(toUpperCase('hello')).toBe('HELLO'); }); </script>

Testing Exceptions

<script> test('should throw error for invalid input', () => { expect(() => parseJSON('invalid')).toThrow(SyntaxError); }); </script>

Testing State Changes

<script> test('should update counter state', () => { const counter = new Counter(); expect(counter.value).toBe(0); counter.increment(); expect(counter.value).toBe(1); }); </script>

Testing Array/Object Properties

<script> test('should return user with correct properties', () => { const user = createUser('John', 'john@example.com'); expect(user).toHaveProperty('name', 'John'); expect(user).toHaveProperty('email', 'john@example.com'); expect(user.roles).toContain('user'); }); </script>

Practice Exercise

Write unit tests for this validation function:

<script> function validatePassword(password) { if (!password) { return { valid: false, error: 'Password is required' }; } if (password.length < 8) { return { valid: false, error: 'Password must be at least 8 characters' }; } if (!/[A-Z]/.test(password)) { return { valid: false, error: 'Password must contain uppercase letter' }; } if (!/[0-9]/.test(password)) { return { valid: false, error: 'Password must contain a number' }; } return { valid: true }; } </script>

Write tests for:

  1. Empty password
  2. Password too short
  3. Password without uppercase letter
  4. Password without number
  5. Valid password

Summary

Unit testing is the foundation of a solid test suite. By testing small units of code in isolation using the AAA pattern, you can catch bugs early and build confidence in your code. Remember: good unit tests are fast, isolated, repeatable, and readable. In the next lesson, we'll explore Test-Driven Development (TDD), a methodology that makes unit testing even more powerful.

Key Takeaways:

  • Unit tests verify small, isolated pieces of code
  • Use the AAA pattern: Arrange, Act, Assert
  • Test happy paths, edge cases, and error conditions
  • Keep tests isolated from external dependencies
  • Each test should be independent and repeatable
  • Write descriptive test names that explain what is being tested