Unit Testing Fundamentals

Chapter 1: Unit Testing Fundamentals

What is a Unit Test?

A unit test verifies a single "unit" of code works correctly in isolationβ€”typically a function, method, or class. Unit tests shouldn't depend on databases, file systems, networks, or external systems.

Unit vs Integration vs E2E

  • Unit Tests: Test individual functions in isolation. Fast, numerous, focused.
  • Integration Tests: Test multiple components together. Slower, fewer.
  • End-to-End Tests: Test complete workflows. Slowest, fewest.

FIRST Principles

Great unit tests follow FIRST:

  • Fast: Run in milliseconds
  • Isolated: No dependencies on other tests or external systems
  • Repeatable: Same results every time
  • Self-Validating: Clear pass/fail
  • Timely: Written before or alongside code (TDD)

Writing Your First Test

Let's write a simple function and test it using pytest's conventions.

The Function to Test

The Test Function

This follows the Arrange-Act-Assert (AAA) pattern, which structures tests clearly:

  1. Arrange: Set up test data and preconditions
  2. Act: Call the function/method being tested
  3. Assert: Verify the result matches expectations

Try it yourself in this interactive terminal:

Test Naming Conventions

Test functions must start with test_ for pytest to discover them automatically. Use descriptive names that explain what you're testing:

βœ… Good Names:

  • test_add_returns_sum_of_two_numbers()
  • test_divide_raises_error_when_divisor_is_zero()
  • test_user_creation_sets_default_role()

❌ Bad Names:

  • test1() - Doesn't explain what's being tested
  • test_func() - Too vague
  • testAddition() - Doesn't follow naming convention

pytest Assertions

Pytest makes assertions incredibly powerful through assertion rewriting. When an assertion fails, pytest shows you exactly what went wrong with detailed information.

Basic Assertions

Plain assert statements work beautifully with pytest:

Advanced Assertions

Pytest provides helpers for complex scenarios:

Assertion Introspection Magic

When assertions fail, pytest's introspection shows exactly what went wrong:

def test_complex_assertion():
    data = {'users': [{'name': 'Alice', 'age': 30}]}
    assert data['users'][0]['age'] == 25  # This will fail

# Pytest output shows:
# AssertionError: assert 30 == 25
#  +  where 30 = {'name': 'Alice', 'age': 30}['age']
#  +    where {'name': 'Alice', 'age': 30} = [{'name': 'Alice', 'age': 30}][0]
#  +      where [{'name': 'Alice', 'age': 30}] = {'users': [{'name': 'Alice', 'age': 30}]}['users']

This detailed breakdown makes debugging failed tests much easier.

Test Organization

Test Functions vs Test Classes

You have two main ways to organize tests:

Test Functions (simple, straightforward):

def test_login_success():
    # Test successful login
    pass

def test_login_invalid_password():
    # Test login with wrong password
    pass

Test Classes (group related tests):

class TestLogin:
    def test_success(self):
        # Test successful login
        pass

    def test_invalid_password(self):
        # Test login with wrong password
        pass

When to Use Classes

Use test classes when:

  • Related tests share setup/teardown logic
  • Grouping improves organization (e.g., all User tests)
  • State sharing between tests is needed (use sparingly)

Use functions when:

  • Tests are independent
  • Setup is minimal
  • Simplicity is preferred

File Organization

Organize tests to mirror your source code structure:

project/
β”œβ”€β”€ src/
β”‚   └── myapp/
β”‚       β”œβ”€β”€ auth.py
β”‚       β”œβ”€β”€ database.py
β”‚       └── api.py
└── tests/
    β”œβ”€β”€ test_auth.py      # Tests for auth.py
    β”œβ”€β”€ test_database.py  # Tests for database.py
    └── test_api.py       # Tests for api.py

This makes it easy to find tests for any module.

Test Classes

Test classes provide a way to group related tests and share setup logic.

Basic Test Class

class TestCalculator:
    """Tests for calculator operations."""

    def test_addition(self):
        result = 2 + 2
        assert result == 4

    def test_subtraction(self):
        result = 10 - 3
        assert result == 7

    def test_multiplication(self):
        result = 5 * 4
        assert result == 20

Setup and Teardown

Use setup_method and teardown_method for test preparation and cleanup:

Class-Level Setup

For expensive operations, use setup_class (runs once per class):

class TestDatabaseOperations:
    @classmethod
    def setup_class(cls):
        """Connect to test database once."""
        cls.db = Database.connect("test.db")

    @classmethod
    def teardown_class(cls):
        """Close database connection once."""
        cls.db.close()

    def test_insert(self):
        # Use self.db
        pass

    def test_query(self):
        # Use self.db
        pass

Testing Edge Cases

Edge cases are boundary inputs or unusual scenarios that might break your code.

Common Edge Cases

  • Empty inputs: sum([]), process("")
  • None values: Test how functions handle None
  • Boundary values: Minimum, maximum, just outside range
  • Large numbers: Very large or very small values
  • Special characters: Strings with quotes, HTML tags, etc.

Boundary Value Analysis

For ranges, test:

  1. Minimum valid value
  2. Just below minimum (invalid)
  3. Maximum valid value
  4. Just above maximum (invalid)
  5. Typical middle value

Example:

def test_age_boundaries():
    assert is_valid_age(0) == True    # Min
    assert is_valid_age(-1) == False  # Below
    assert is_valid_age(120) == True  # Max

Test Naming Conventions

Good test names are self-documenting. Use pattern: test_<what>_<condition>_<expected_result>

# βœ… Good
def test_withdraw_with_sufficient_funds_reduces_balance():
    pass

def test_withdraw_with_insufficient_funds_raises_error():
    pass

# ❌ Poor
def test_withdraw():  # Too vague
    pass

def test1():  # No context
    pass

Best Practices:

  • Explain why, not what
  • Avoid abbreviations
  • Long clear names are fine

Course Recommendations

Ready to master unit testing in Python? Check out these courses on paiml.com:

Test-Driven Development with Python

  • Complete TDD workflow and methodology
  • Red-Green-Refactor cycle mastery
  • Unit testing patterns and anti-patterns
  • Refactoring with confidence
  • Enroll at paiml.com

Advanced pytest Techniques

  • Fixtures and dependency injection
  • Parametrized tests for comprehensive coverage
  • Custom plugins and hooks
  • Testing async code and concurrency
  • Enroll at paiml.com

Clean Code with Python

  • Writing testable code
  • SOLID principles in Python
  • Refactoring techniques
  • Code smells and how to fix them
  • Enroll at paiml.com

Quiz

πŸ“ Test Your Knowledge: Unit Testing Fundamentals

Take this quiz to reinforce what you've learned in this chapter.