python

exercises

exercises.py🐍
"""
Testing in Python - Exercises
Practice writing tests, using mocks, and following TDD
"""


# ============================================================
# EXERCISE 1: Basic Test Cases
# ============================================================
"""
Write unit tests for the following StringUtils class.
Cover all methods with at least 2 test cases each.
"""

import unittest


class StringUtils:
    """Utility class for string operations."""
    
    @staticmethod
    def reverse(s):
        """Reverse a string."""
        return s[::-1]
    
    @staticmethod
    def is_palindrome(s):
        """Check if string is a palindrome (case-insensitive)."""
        s = s.lower().replace(" ", "")
        return s == s[::-1]
    
    @staticmethod
    def count_vowels(s):
        """Count vowels in a string."""
        return sum(1 for c in s.lower() if c in 'aeiou')
    
    @staticmethod
    def truncate(s, max_length, suffix="..."):
        """Truncate string if longer than max_length."""
        if len(s) <= max_length:
            return s
        return s[:max_length - len(suffix)] + suffix


class TestStringUtils(unittest.TestCase):
    """Test cases for StringUtils."""
    
    def test_reverse(self):
        # YOUR CODE HERE
        pass
    
    def test_is_palindrome(self):
        # YOUR CODE HERE
        pass
    
    def test_count_vowels(self):
        # YOUR CODE HERE
        pass
    
    def test_truncate(self):
        # YOUR CODE HERE
        pass


# ============================================================
# EXERCISE 2: Exception Testing
# ============================================================
"""
Write tests for the BankAccount class that verify exceptions
are raised correctly in error cases.
"""

class BankAccount:
    """Simple bank account with validation."""
    
    def __init__(self, balance=0):
        if balance < 0:
            raise ValueError("Initial balance cannot be negative")
        self._balance = balance
    
    @property
    def balance(self):
        return self._balance
    
    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("Deposit amount must be positive")
        self._balance += amount
    
    def withdraw(self, amount):
        if amount <= 0:
            raise ValueError("Withdrawal amount must be positive")
        if amount > self._balance:
            raise ValueError("Insufficient funds")
        self._balance -= amount


class TestBankAccount(unittest.TestCase):
    """Test cases for BankAccount including exception cases."""
    
    def test_create_account_with_positive_balance(self):
        # YOUR CODE HERE
        pass
    
    def test_create_account_with_negative_balance_raises(self):
        # YOUR CODE HERE - use assertRaises
        pass
    
    def test_deposit_positive_amount(self):
        # YOUR CODE HERE
        pass
    
    def test_deposit_negative_amount_raises(self):
        # YOUR CODE HERE
        pass
    
    def test_withdraw_success(self):
        # YOUR CODE HERE
        pass
    
    def test_withdraw_insufficient_funds_raises(self):
        # YOUR CODE HERE
        pass


# ============================================================
# EXERCISE 3: Test with setUp and tearDown
# ============================================================
"""
Write tests for a ShoppingCart class.
Use setUp to create a fresh cart before each test.
"""

class ShoppingCart:
    """Shopping cart for an e-commerce site."""
    
    def __init__(self):
        self.items = []
    
    def add_item(self, name, price, quantity=1):
        self.items.append({
            "name": name,
            "price": price,
            "quantity": quantity
        })
    
    def remove_item(self, name):
        self.items = [item for item in self.items if item["name"] != name]
    
    def get_total(self):
        return sum(item["price"] * item["quantity"] for item in self.items)
    
    def get_item_count(self):
        return sum(item["quantity"] for item in self.items)
    
    def clear(self):
        self.items = []


class TestShoppingCart(unittest.TestCase):
    """Test cases for ShoppingCart with setUp."""
    
    def setUp(self):
        """Create a cart with some items before each test."""
        # YOUR CODE HERE
        pass
    
    def test_add_item(self):
        # YOUR CODE HERE
        pass
    
    def test_remove_item(self):
        # YOUR CODE HERE
        pass
    
    def test_get_total(self):
        # YOUR CODE HERE
        pass
    
    def test_get_item_count(self):
        # YOUR CODE HERE
        pass
    
    def test_clear(self):
        # YOUR CODE HERE
        pass


# ============================================================
# EXERCISE 4: Mocking External Dependencies
# ============================================================
"""
Write tests for the EmailNotifier class.
Mock the email_service to avoid sending real emails.
"""

from unittest.mock import Mock, patch


class EmailNotifier:
    """Send email notifications."""
    
    def __init__(self, email_service):
        self.email_service = email_service
    
    def send_welcome_email(self, user_email, user_name):
        subject = "Welcome!"
        body = f"Hello {user_name}, welcome to our platform!"
        return self.email_service.send(user_email, subject, body)
    
    def send_password_reset(self, user_email, reset_link):
        subject = "Password Reset"
        body = f"Click here to reset your password: {reset_link}"
        return self.email_service.send(user_email, subject, body)
    
    def send_bulk_newsletter(self, email_list, content):
        results = []
        for email in email_list:
            result = self.email_service.send(email, "Newsletter", content)
            results.append(result)
        return results


class TestEmailNotifier(unittest.TestCase):
    """Test cases for EmailNotifier with mocked email service."""
    
    def setUp(self):
        """Create mock email service and notifier."""
        # YOUR CODE HERE
        pass
    
    def test_send_welcome_email(self):
        # YOUR CODE HERE
        # Verify email_service.send was called with correct arguments
        pass
    
    def test_send_password_reset(self):
        # YOUR CODE HERE
        pass
    
    def test_send_bulk_newsletter(self):
        # YOUR CODE HERE
        # Verify send was called for each email in the list
        pass


# ============================================================
# EXERCISE 5: Test Side Effects
# ============================================================
"""
Test a function that retries on failure using mock side effects.
"""

def retry_fetch(fetcher, max_retries=3):
    """Retry fetching data up to max_retries times."""
    last_error: Exception | None = None
    for attempt in range(max_retries):
        try:
            return fetcher.fetch()
        except Exception as e:
            last_error = e
    if last_error is not None:
        raise last_error
    raise RuntimeError("No attempts made")


class TestRetryFetch(unittest.TestCase):
    """Test retry_fetch with various scenarios."""
    
    def test_succeeds_on_first_try(self):
        # YOUR CODE HERE
        # Mock fetcher that succeeds immediately
        pass
    
    def test_succeeds_after_retries(self):
        # YOUR CODE HERE
        # Mock fetcher that fails twice then succeeds
        # Use side_effect = [Exception, Exception, "success"]
        pass
    
    def test_fails_after_max_retries(self):
        # YOUR CODE HERE
        # Mock fetcher that always fails
        pass


# ============================================================
# EXERCISE 6: Parameterized Tests
# ============================================================
"""
Write parameterized tests for a password validator.
Test many cases efficiently using subTest.
"""

import re


def validate_password(password):
    """
    Validate password strength.
    Returns (is_valid, error_message or None)
    
    Requirements:
    - At least 8 characters
    - At least one uppercase letter
    - At least one lowercase letter
    - At least one digit
    - At least one special character (!@#$%^&*)
    """
    if len(password) < 8:
        return False, "Password must be at least 8 characters"
    if not re.search(r'[A-Z]', password):
        return False, "Password must contain an uppercase letter"
    if not re.search(r'[a-z]', password):
        return False, "Password must contain a lowercase letter"
    if not re.search(r'\d', password):
        return False, "Password must contain a digit"
    if not re.search(r'[!@#$%^&*]', password):
        return False, "Password must contain a special character"
    return True, None


class TestPasswordValidator(unittest.TestCase):
    """Parameterized tests for password validator."""
    
    def test_valid_passwords(self):
        """Test various valid passwords."""
        valid_passwords = [
            "Password1!",
            "SecurePass123@",
            "MyP@ssw0rd",
            # Add more valid passwords
        ]
        
        for password in valid_passwords:
            with self.subTest(password=password):
                is_valid, error = validate_password(password)
                self.assertTrue(is_valid, f"Expected valid: {password}")
    
    def test_invalid_passwords(self):
        """Test various invalid passwords with expected errors."""
        invalid_cases = [
            ("short1!", "at least 8 characters"),
            ("nouppercase1!", "uppercase"),
            ("NOLOWERCASE1!", "lowercase"),
            ("NoDigits!!", "digit"),
            ("NoSpecial1a", "special character"),
        ]
        
        for password, expected_error in invalid_cases:
            with self.subTest(password=password):
                is_valid, error = validate_password(password)
                # YOUR CODE HERE - verify it's invalid and error contains expected text
                pass


# ============================================================
# EXERCISE 7: Testing with Files (using tempfile)
# ============================================================
"""
Test a ConfigManager that reads/writes configuration files.
Use tempfile to avoid creating real files.
"""

import tempfile
import os
import json


class ConfigManager:
    """Manage configuration files."""
    
    def __init__(self, config_path):
        self.config_path = config_path
        self.config = {}
    
    def load(self):
        """Load configuration from file."""
        if os.path.exists(self.config_path):
            with open(self.config_path, 'r') as f:
                self.config = json.load(f)
        return self.config
    
    def save(self):
        """Save configuration to file."""
        with open(self.config_path, 'w') as f:
            json.dump(self.config, f)
    
    def get(self, key, default=None):
        """Get a configuration value."""
        return self.config.get(key, default)
    
    def set(self, key, value):
        """Set a configuration value."""
        self.config[key] = value


class TestConfigManager(unittest.TestCase):
    """Test ConfigManager using temporary files."""
    
    def setUp(self):
        """Create a temporary file for testing."""
        self.temp_file = tempfile.NamedTemporaryFile(
            mode='w', suffix='.json', delete=False
        )
        self.temp_file.close()
        self.config_path = self.temp_file.name
    
    def tearDown(self):
        """Clean up temporary file."""
        if os.path.exists(self.config_path):
            os.unlink(self.config_path)
    
    def test_load_empty_config(self):
        # YOUR CODE HERE
        pass
    
    def test_save_and_load_config(self):
        # YOUR CODE HERE
        pass
    
    def test_get_with_default(self):
        # YOUR CODE HERE
        pass


# ============================================================
# EXERCISE 8: Test a Cache Class
# ============================================================
"""
Implement tests for a simple in-memory cache with TTL.
Test expiration using mocked time.
"""

import time


class SimpleCache:
    """In-memory cache with time-to-live support."""
    
    def __init__(self, default_ttl=60):
        self.default_ttl = default_ttl
        self._cache = {}
    
    def set(self, key, value, ttl=None):
        """Store a value with optional TTL."""
        if ttl is None:
            ttl = self.default_ttl
        expires_at = time.time() + ttl
        self._cache[key] = {"value": value, "expires_at": expires_at}
    
    def get(self, key):
        """Get a value if not expired."""
        if key not in self._cache:
            return None
        entry = self._cache[key]
        if time.time() > entry["expires_at"]:
            del self._cache[key]
            return None
        return entry["value"]
    
    def delete(self, key):
        """Delete a key from cache."""
        if key in self._cache:
            del self._cache[key]


class TestSimpleCache(unittest.TestCase):
    """Test SimpleCache with mocked time."""
    
    def test_set_and_get(self):
        # YOUR CODE HERE
        pass
    
    def test_get_nonexistent_key(self):
        # YOUR CODE HERE
        pass
    
    def test_delete(self):
        # YOUR CODE HERE
        pass
    
    @patch('time.time')
    def test_expiration(self, mock_time):
        """Test that cache entries expire correctly."""
        # YOUR CODE HERE
        # Set mock_time.return_value to control "current time"
        # Set a value, advance time past TTL, verify it's gone
        pass


# ============================================================
# EXERCISE 9: TDD Exercise - Build a Stack
# ============================================================
"""
Practice Test-Driven Development (TDD):
1. Write tests first
2. Run tests (they fail)
3. Implement minimal code to pass
4. Refactor if needed

Implement a Stack class with these operations:
- push(item): Add item to top
- pop(): Remove and return top item (raise if empty)
- peek(): Return top item without removing (raise if empty)
- is_empty(): Return True if stack is empty
- size(): Return number of items
"""

class TestStack(unittest.TestCase):
    """Tests for Stack class - write these FIRST."""
    
    def test_new_stack_is_empty(self):
        # YOUR CODE HERE
        pass
    
    def test_push_increases_size(self):
        # YOUR CODE HERE
        pass
    
    def test_pop_returns_last_pushed(self):
        # YOUR CODE HERE
        pass
    
    def test_pop_empty_raises_error(self):
        # YOUR CODE HERE
        pass
    
    def test_peek_returns_without_removing(self):
        # YOUR CODE HERE
        pass
    
    def test_peek_empty_raises_error(self):
        # YOUR CODE HERE
        pass
    
    def test_lifo_order(self):
        """Test Last-In-First-Out ordering."""
        # YOUR CODE HERE
        pass


# Implement Stack class after writing tests
class Stack:
    """Stack data structure - implement after tests."""
    
    def __init__(self):
        # YOUR CODE HERE
        pass
    
    def push(self, item):
        # YOUR CODE HERE
        pass
    
    def pop(self):
        # YOUR CODE HERE
        pass
    
    def peek(self):
        # YOUR CODE HERE
        pass
    
    def is_empty(self):
        # YOUR CODE HERE
        pass
    
    def size(self):
        # YOUR CODE HERE
        pass


# ============================================================
# CHALLENGE: Test an API Client
# ============================================================
"""
Write comprehensive tests for this API client.
Mock all HTTP requests to avoid real network calls.
Test success cases, error handling, and edge cases.
"""

class APIError(Exception):
    """API error with status code."""
    def __init__(self, message, status_code):
        self.message = message
        self.status_code = status_code
        super().__init__(message)


class UserAPIClient:
    """Client for user management API."""
    
    def __init__(self, base_url, http_client):
        self.base_url = base_url.rstrip('/')
        self.http = http_client
    
    def get_user(self, user_id):
        """Get user by ID."""
        response = self.http.get(f"{self.base_url}/users/{user_id}")
        if response.status_code == 404:
            raise APIError("User not found", 404)
        if response.status_code != 200:
            raise APIError("Failed to get user", response.status_code)
        return response.json()
    
    def create_user(self, name, email):
        """Create a new user."""
        response = self.http.post(
            f"{self.base_url}/users",
            json={"name": name, "email": email}
        )
        if response.status_code == 400:
            raise APIError("Invalid user data", 400)
        if response.status_code == 409:
            raise APIError("Email already exists", 409)
        if response.status_code != 201:
            raise APIError("Failed to create user", response.status_code)
        return response.json()
    
    def list_users(self, page=1, per_page=10):
        """List users with pagination."""
        response = self.http.get(
            f"{self.base_url}/users",
            params={"page": page, "per_page": per_page}
        )
        if response.status_code != 200:
            raise APIError("Failed to list users", response.status_code)
        return response.json()


class TestUserAPIClient(unittest.TestCase):
    """Comprehensive tests for UserAPIClient."""
    
    def setUp(self):
        self.mock_http = Mock()
        self.client = UserAPIClient("https://api.example.com", self.mock_http)
    
    # Implement at least 10 test cases covering:
    # - Successful operations
    # - Error handling (404, 400, 409, 500)
    # - Edge cases (empty responses, pagination)
    
    # YOUR CODE HERE
    pass


# ============================================================
# SOLUTIONS (Uncomment to check your work)
# ============================================================

"""
# Solution 1: Basic Test Cases
class TestStringUtils(unittest.TestCase):
    def test_reverse(self):
        self.assertEqual(StringUtils.reverse("hello"), "olleh")
        self.assertEqual(StringUtils.reverse(""), "")
        self.assertEqual(StringUtils.reverse("a"), "a")
    
    def test_is_palindrome(self):
        self.assertTrue(StringUtils.is_palindrome("radar"))
        self.assertTrue(StringUtils.is_palindrome("A man a plan a canal Panama"))
        self.assertFalse(StringUtils.is_palindrome("hello"))
    
    def test_count_vowels(self):
        self.assertEqual(StringUtils.count_vowels("hello"), 2)
        self.assertEqual(StringUtils.count_vowels("rhythm"), 0)
        self.assertEqual(StringUtils.count_vowels("AEIOU"), 5)
    
    def test_truncate(self):
        self.assertEqual(StringUtils.truncate("hello", 10), "hello")
        self.assertEqual(StringUtils.truncate("hello world", 8), "hello...")
        self.assertEqual(StringUtils.truncate("hello", 5), "hello")


# Solution 2: Exception Testing
class TestBankAccount(unittest.TestCase):
    def test_create_account_with_positive_balance(self):
        account = BankAccount(100)
        self.assertEqual(account.balance, 100)
    
    def test_create_account_with_negative_balance_raises(self):
        with self.assertRaises(ValueError) as context:
            BankAccount(-100)
        self.assertIn("negative", str(context.exception))
    
    def test_deposit_positive_amount(self):
        account = BankAccount(100)
        account.deposit(50)
        self.assertEqual(account.balance, 150)
    
    def test_deposit_negative_amount_raises(self):
        account = BankAccount(100)
        with self.assertRaises(ValueError):
            account.deposit(-50)
    
    def test_withdraw_success(self):
        account = BankAccount(100)
        account.withdraw(30)
        self.assertEqual(account.balance, 70)
    
    def test_withdraw_insufficient_funds_raises(self):
        account = BankAccount(100)
        with self.assertRaises(ValueError) as context:
            account.withdraw(150)
        self.assertIn("Insufficient", str(context.exception))


# Solution 3: ShoppingCart Tests
class TestShoppingCart(unittest.TestCase):
    def setUp(self):
        self.cart = ShoppingCart()
        self.cart.add_item("Apple", 1.50, 2)
        self.cart.add_item("Banana", 0.75, 3)
    
    def test_add_item(self):
        self.cart.add_item("Orange", 2.00, 1)
        self.assertEqual(len(self.cart.items), 3)
    
    def test_remove_item(self):
        self.cart.remove_item("Apple")
        self.assertEqual(len(self.cart.items), 1)
    
    def test_get_total(self):
        # (1.50 * 2) + (0.75 * 3) = 3.00 + 2.25 = 5.25
        self.assertEqual(self.cart.get_total(), 5.25)
    
    def test_get_item_count(self):
        self.assertEqual(self.cart.get_item_count(), 5)  # 2 + 3
    
    def test_clear(self):
        self.cart.clear()
        self.assertEqual(len(self.cart.items), 0)


# Solution 4: Mocking EmailNotifier
class TestEmailNotifier(unittest.TestCase):
    def setUp(self):
        self.mock_email = Mock()
        self.notifier = EmailNotifier(self.mock_email)
    
    def test_send_welcome_email(self):
        self.mock_email.send.return_value = True
        result = self.notifier.send_welcome_email("john@example.com", "John")
        
        self.assertTrue(result)
        self.mock_email.send.assert_called_once_with(
            "john@example.com",
            "Welcome!",
            "Hello John, welcome to our platform!"
        )
    
    def test_send_password_reset(self):
        self.mock_email.send.return_value = True
        result = self.notifier.send_password_reset("john@example.com", "http://reset.link")
        
        self.mock_email.send.assert_called_once()
        args = self.mock_email.send.call_args[0]
        self.assertIn("reset.link", args[2])
    
    def test_send_bulk_newsletter(self):
        self.mock_email.send.return_value = True
        emails = ["a@test.com", "b@test.com", "c@test.com"]
        
        results = self.notifier.send_bulk_newsletter(emails, "Newsletter content")
        
        self.assertEqual(len(results), 3)
        self.assertEqual(self.mock_email.send.call_count, 3)


# Solution 5: Retry Fetch
class TestRetryFetch(unittest.TestCase):
    def test_succeeds_on_first_try(self):
        mock_fetcher = Mock()
        mock_fetcher.fetch.return_value = "data"
        
        result = retry_fetch(mock_fetcher)
        
        self.assertEqual(result, "data")
        self.assertEqual(mock_fetcher.fetch.call_count, 1)
    
    def test_succeeds_after_retries(self):
        mock_fetcher = Mock()
        mock_fetcher.fetch.side_effect = [Exception("fail"), Exception("fail"), "success"]
        
        result = retry_fetch(mock_fetcher)
        
        self.assertEqual(result, "success")
        self.assertEqual(mock_fetcher.fetch.call_count, 3)
    
    def test_fails_after_max_retries(self):
        mock_fetcher = Mock()
        mock_fetcher.fetch.side_effect = Exception("always fails")
        
        with self.assertRaises(Exception):
            retry_fetch(mock_fetcher, max_retries=3)
        
        self.assertEqual(mock_fetcher.fetch.call_count, 3)


# Solution 9: Stack (TDD)
class Stack:
    def __init__(self):
        self._items = []
    
    def push(self, item):
        self._items.append(item)
    
    def pop(self):
        if self.is_empty():
            raise IndexError("Stack is empty")
        return self._items.pop()
    
    def peek(self):
        if self.is_empty():
            raise IndexError("Stack is empty")
        return self._items[-1]
    
    def is_empty(self):
        return len(self._items) == 0
    
    def size(self):
        return len(self._items)

class TestStack(unittest.TestCase):
    def test_new_stack_is_empty(self):
        stack = Stack()
        self.assertTrue(stack.is_empty())
        self.assertEqual(stack.size(), 0)
    
    def test_push_increases_size(self):
        stack = Stack()
        stack.push(1)
        self.assertEqual(stack.size(), 1)
        self.assertFalse(stack.is_empty())
    
    def test_pop_returns_last_pushed(self):
        stack = Stack()
        stack.push("a")
        self.assertEqual(stack.pop(), "a")
    
    def test_pop_empty_raises_error(self):
        stack = Stack()
        with self.assertRaises(IndexError):
            stack.pop()
    
    def test_peek_returns_without_removing(self):
        stack = Stack()
        stack.push(1)
        self.assertEqual(stack.peek(), 1)
        self.assertEqual(stack.size(), 1)
    
    def test_peek_empty_raises_error(self):
        stack = Stack()
        with self.assertRaises(IndexError):
            stack.peek()
    
    def test_lifo_order(self):
        stack = Stack()
        stack.push(1)
        stack.push(2)
        stack.push(3)
        self.assertEqual(stack.pop(), 3)
        self.assertEqual(stack.pop(), 2)
        self.assertEqual(stack.pop(), 1)
"""


if __name__ == "__main__":
    print("Testing Exercises")
    print("=" * 50)
    print("\nComplete the exercises above to practice:")
    print("- Writing basic unit tests")
    print("- Testing exceptions")
    print("- Using setUp and tearDown")
    print("- Mocking external dependencies")
    print("- Testing with side effects")
    print("- Parameterized testing")
    print("- Testing file operations")
    print("- Test-Driven Development (TDD)")
    print("\nRun with: python -m unittest exercises.py")
    print("Or with pytest: pytest exercises.py -v")
Exercises - Python Tutorial | DeepML