Testing & Debugging
Build confidence through systematic testing and debugging.
Why Testing Matters
Professional software is not defined by how rarely it breaks, but by how confidently it can be changed. Testing provides fast feedback, prevents regressions, and allows teams to evolve code safely. A well-tested codebase becomes easier to maintain and extend over time.
The ROI of Testing:
- Catch bugs early when they're cheaper to fix
- Document expected behavior through examples
- Enable fearless refactoring and optimization
- Cut the time spent reproducing and locating bugs
In This Lesson
1. The Testing Pyramid
How much of each kind of test to write
2. Unit Tests
unittest vs pytest, side by side
3. Fixtures & Test Data
tmp_path, monkeypatch, capsys, conftest.py
4. Property-Based Testing
Let Hypothesis invent the inputs
5. Test Doubles
Mocks, stubs, fakes, and patching correctly
6. Integration Tests
Real databases and real wiring
7. Functional & E2E
Whole workflows, from the outside in
8. Coverage & Mutation
What coverage proves, and what it cannot
9. Load & Performance
Behavior under expected and extreme load
10. Security Testing
Finding vulnerabilities before attackers do
11. Debugging
pdb, logging, and a systematic method
12. TDD
Red, green, refactor
The Testing Pyramid
Unit Tests (70-80%)
Fast, isolated, test individual functions. Run in milliseconds.
Integration Tests (15-20%)
Test component interactions. Run in seconds.
E2E Tests (5-10%)
Full system workflows. Run in minutes.
Unit Tests
Unit tests verify individual functions or classes in isolation. They should be deterministic, fast, and independent of external systems.
unittest is included with Python. It's class-based and explicit, making test structure clear and familiar to developers from Java or C#.
# calculator.py - the production code under test
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
# test_calculator.py - tests live in their own module
import unittest
from calculator import Calculator
class TestCalculator(unittest.TestCase):
def setUp(self):
"""Run before each test"""
self.calc = Calculator()
def test_add_positive_numbers(self):
result = self.calc.add(2, 3)
self.assertEqual(result, 5)
def test_add_negative_numbers(self):
result = self.calc.add(-1, 1)
self.assertEqual(result, 0)
def test_divide_by_zero_raises_error(self):
with self.assertRaises(ValueError):
self.calc.divide(10, 0)
if __name__ == "__main__":
unittest.main()
# Run with: python -m unittest test_calculator| Feature | unittest | pytest |
|---|---|---|
| Setup | Built-in, no installation | pip install pytest |
| Syntax | Class-based, verbose | Function-based, concise |
| Assertions | self.assertEqual(), etc. | Plain assert statements |
| Fixtures | setUp/tearDown methods | @pytest.fixture decorator |
| Parametrization | Manual loops or subtests | @pytest.mark.parametrize |
| Output | Basic | Detailed, colored |
- Unit tests should not touch the filesystem, network, or database
- Each test should test one behavior or scenario
- Tests should be independent - order shouldn't matter
- Use descriptive test names:
test_user_login_with_invalid_password_fails - Structure each test as Arrange, Act, Assert: set up the world, do one thing, check one outcome
Fixtures & Test Data
Most of the work in a test suite is arranging the world before the assertion. pytest ships several fixtures for exactly that, so you rarely need to write temporary-directory or environment-variable plumbing yourself.
# app.py
import json
import os
def write_report(path, rows):
path.write_text(json.dumps([{"name": n, "age": a} for n, a in rows]))
def get_region():
return os.environ["APP_REGION"]
def print_summary(total):
print(f"Total: {total}")
# test_app.py - pytest ships these fixtures, nothing to import or install
import json
import logging
from app import get_region, print_summary, write_report
def test_report_is_written_to_disk(tmp_path):
"""tmp_path: a fresh temporary directory per test, as a pathlib.Path"""
output = tmp_path / "report.json"
write_report(output, rows=[("alice", 30)])
assert json.loads(output.read_text()) == [{"name": "alice", "age": 30}]
def test_region_comes_from_environment(monkeypatch):
"""monkeypatch: set variables or attributes, undone after the test"""
monkeypatch.setenv("APP_REGION", "us-east-1")
assert get_region() == "us-east-1"
def test_cli_prints_summary(capsys):
"""capsys: capture whatever the code wrote to stdout/stderr"""
print_summary(total=42)
assert "Total: 42" in capsys.readouterr().out
def test_large_order_is_logged(caplog):
"""caplog: assert on log records instead of scraping printed text"""
with caplog.at_level(logging.WARNING):
logging.getLogger("orders").warning("Large order detected: %s", 12345)
assert "Large order detected: 12345" in caplog.texttmp_pathgives each test its own directory, so file tests never collide or leakmonkeypatchundoes every change automatically, so one test cannot poison the nextcaplogasserts on structured log records rather than scraping printed text
Your own fixtures belong in conftest.py, where pytest discovers them automatically for every test file in that directory and below.
# conftest.py - fixtures defined here are available to every test file in
# this directory and below, with no import required
import pytest
from myapp.clock import Clock
from myapp.db import Database
@pytest.fixture(scope="session")
def database():
"""scope='session': built once for the entire run (for expensive setup)"""
db = Database.connect("sqlite:///:memory:")
yield db
db.close()
@pytest.fixture
def user(database):
"""Default scope='function': rebuilt fresh for every single test.
Fixtures can depend on other fixtures, and pytest resolves the order."""
return database.create_user(name="Alice")
@pytest.fixture(autouse=True)
def frozen_clock():
"""autouse=True: applied to every test in scope without being requested"""
Clock.freeze("2026-01-01T00:00:00Z")
yield
Clock.reset()
# Useful command-line selection while developing:
# pytest -k "signup" run only tests whose name matches "signup"
# pytest -x stop at the first failure
# pytest --lf re-run only the tests that failed last time
# pytest -m "not slow" skip tests marked @pytest.mark.slowsession fixture is shared by every test that uses it, so any state a test mutates leaks into the tests that follow. Share expensive connections, not mutable data.Property-Based Testing
Example-based tests only cover the inputs you thought of, and bugs live in the inputs you did not. Property-based testing inverts this: you state a rule that must always hold, and the library hunts for a counter-example.
# pip install hypothesis
import pytest
from hypothesis import given, strategies as st
from statistics_utils import calculate_average
# Example-based: you pick the inputs, so you only catch the cases you imagined
def test_average_of_known_values():
assert calculate_average([10, 20, 30]) == 20.0
# Property-based: Hypothesis generates hundreds of inputs looking for a
# counter-example, then shrinks any failure to the smallest one that still fails
@given(st.lists(st.integers(min_value=-10**6, max_value=10**6), min_size=1))
def test_average_lies_between_min_and_max(numbers):
assert min(numbers) <= calculate_average(numbers) <= max(numbers)
@given(
st.lists(st.integers(min_value=-1000, max_value=1000), min_size=1),
st.integers(min_value=-1000, max_value=1000),
)
def test_shifting_every_value_shifts_the_average(numbers, offset):
shifted = [n + offset for n in numbers]
# pytest.approx: never compare floating point results with ==
assert calculate_average(shifted) == pytest.approx(
calculate_average(numbers) + offset
)
# Drop min_size=1 and Hypothesis hands you [] on the very first attempts,
# forcing you to decide what an empty input should actually do.Good properties to look for
- Round trips:
decode(encode(x)) == x - Invariants: a sorted list has the same length and elements
- Bounds: a result always lands inside a known range
- Agreement: a fast implementation matches a slow, obviously correct one
Shrinking is the superpower
When Hypothesis finds a failure it does not report the random 500-element list it happened to generate. It shrinks the input down to the smallest case that still fails, which is usually small enough to debug by reading it.
Test Doubles: Mocks, Stubs & Fakes
Test doubles replace real dependencies to isolate the code under test.
Mock
Records calls and verifies interactions. Use when you care how something is called.
Stub
Returns predefined responses. Use when you need controlled test data.
Fake
Working implementation, but simplified. Example: in-memory database.
from unittest.mock import Mock, patch
import requests
class WeatherService:
def get_temperature(self, city):
response = requests.get(f"https://api.weather.com/{city}")
return response.json()["temp"]
# Using Mock
def test_weather_service_with_mock():
service = WeatherService()
# Create a mock response
mock_response = Mock()
mock_response.json.return_value = {"temp": 72}
# Patch requests.get to return our mock
with patch('requests.get', return_value=mock_response) as mock_get:
temp = service.get_temperature("Houston")
assert temp == 72
# Verify the API was called correctly
mock_get.assert_called_once_with("https://api.weather.com/Houston")
# Using pytest-mock (cleaner syntax)
def test_weather_service_with_pytest_mock(mocker):
service = WeatherService()
# Stub the response
mock_get = mocker.patch('requests.get')
mock_get.return_value.json.return_value = {"temp": 72}
temp = service.get_temperature("Houston")
assert temp == 72Patch where it is used, not where it is defined
This is the mistake that costs beginners the most time: the patch appears to apply, the test runs, and the real dependency is called anyway. The rule follows directly from how import works.
from unittest.mock import patch
# The single most common mocking mistake: patching the wrong name.
# patch() replaces an attribute on a module, so you must patch the name in
# the module that LOOKS IT UP, not the module that defines it.
# --- myapp/weather.py ---
import requests # the module object is stored; requests.get is resolved per call
def get_temperature(city):
return requests.get(f"https://api.weather.com/{city}").json()["temp"]
# Works: the lookup happens at call time, so replacing requests.get is enough
patch("requests.get")
# --- myapp/report.py ---
from requests import get # the function itself is copied into myapp.report NOW
def fetch_rows(url):
return get(url).json()
# Does NOT work: myapp.report.get still points at the original function
patch("requests.get")
# Works: patch the copy that myapp.report actually calls
patch("myapp.report.get")
# Also prefer autospec, so a mock rejects calls the real object would reject
with patch("myapp.report.get", autospec=True) as mock_get:
fetch_rows("https://example.com/rows")
mock_get.assert_called_once_with("https://example.com/rows")
# Without autospec, mock_get(1, 2, 3, nonsense=True) would happily passIntegration Tests
Integration tests ensure that multiple components work together correctly. They verify interactions between your code and real external systems.
import pytest
from sqlalchemy import create_engine, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import sessionmaker
from myapp.models import Base, User # your SQLAlchemy declarative models
# Using a test database
@pytest.fixture
def db_session():
# Create test database
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
yield session
# Cleanup
session.close()
engine.dispose()
def test_user_repository_creates_user(db_session):
# Integration test - actually talks to database
user = User(name="Alice", email="alice@example.com")
db_session.add(user)
db_session.commit()
# Query it back (SQLAlchemy 2.0 style: select() + scalars())
result = db_session.scalars(
select(User).where(User.name == "Alice")
).first()
assert result is not None
assert result.email == "alice@example.com"
def test_user_repository_with_duplicate_email_fails(db_session):
# Test business logic + database constraints.
# Requires User.email to be declared with unique=True
user1 = User(name="Alice", email="test@example.com")
user2 = User(name="Bob", email="test@example.com")
db_session.add(user1)
db_session.commit()
db_session.add(user2)
with pytest.raises(IntegrityError):
db_session.commit()
# A failed commit leaves the session unusable until it is rolled back
db_session.rollback()- Test containers: Spin up real databases/services in Docker
- In-memory databases: Fast but may miss database-specific bugs
- Test fixtures: Pre-populate test data for consistent scenarios
Functional & End-to-End Tests
E2E tests validate complete user workflows from the outside-in, treating your application as a black box and testing it like a real user would.
# API E2E Test (api_client is a pytest fixture wrapping requests or TestClient)
def test_complete_user_signup_flow(api_client):
# User signs up
response = api_client.post("/api/signup", json={
"email": "newuser@example.com",
"password": "SecurePass123!",
"name": "New User"
})
assert response.status_code == 201
user_id = response.json()["user_id"]
# User receives confirmation email
# get_sent_emails() is a test helper that reads the mock email backend
emails = get_sent_emails()
assert len(emails) == 1
assert "newuser@example.com" in emails[0]["to"]
# User logs in
response = api_client.post("/api/login", json={
"email": "newuser@example.com",
"password": "SecurePass123!"
})
assert response.status_code == 200
token = response.json()["token"]
# User accesses protected resource
response = api_client.get(
"/api/profile",
headers={"Authorization": f"Bearer {token}"}
)
assert response.status_code == 200
assert response.json()["name"] == "New User"
# Web UI E2E Test (using Selenium)
from selenium import webdriver
from selenium.webdriver.common.by import By
def test_user_can_complete_checkout(browser):
# Navigate to product page
browser.get("https://example.com/products/widget")
# Add to cart
add_button = browser.find_element(By.ID, "add-to-cart")
add_button.click()
# Go to checkout
browser.get("https://example.com/checkout")
# Fill in form
browser.find_element(By.ID, "email").send_keys("buyer@example.com")
browser.find_element(By.ID, "card-number").send_keys("4242424242424242")
# Submit order
browser.find_element(By.ID, "submit-order").click()
# Verify confirmation
confirmation = browser.find_element(By.CLASS_NAME, "order-confirmation")
assert "Thank you" in confirmation.textAPI E2E Tests
Tools: pytest, requests, FastAPI TestClient
Test backend workflows, authentication, business logic flows
UI E2E Tests
Tools: Selenium, Playwright, Cypress
Test user interfaces, browser interactions, visual workflows
Coverage & Mutation Testing
Code coverage measures which lines of code are executed during testing.
# Install coverage tool pip install pytest-cov # Run tests with coverage pytest --cov=myapp --cov-report=html # View detailed HTML report # Coverage report saved to htmlcov/index.html # Example output: # Name Stmts Miss Cover # ---------------------------------------- # myapp/__init__.py 10 0 100% # myapp/models.py 45 3 93% # myapp/views.py 67 12 82% # myapp/utils.py 23 0 100% # ---------------------------------------- # TOTAL 145 15 90%
That said, coverage reports are valuable for a different reason: they reveal unreachable paths and dead code - branches that can never be triggered, conditions that are always true, or functions nobody calls. Achieving 100% coverage won't guarantee correctness, but it does confirm that every line of your code is reachable, every requirement has at least one test exercising it, and you have no dead code silently sitting in your codebase.
Mutation testing: who tests the tests?
Coverage tells you a line ran. It cannot tell you whether any assertion would have noticed if that line were wrong. Mutation testing answers exactly that: it makes small changes to your source, one at a time, and re-runs your suite. A mutant that gets killed (the tests fail) is good news. A mutant that survives is a change your tests did not detect.
# pip install mutmut
# setup.cfg
# [mutmut]
# source_paths=myapp
# myapp/discounts.py - fully covered by one test
def apply_discount(price, percent):
percent = min(percent, 50) # never discount more than half
return price - price * (percent / 100)
# tests/test_discounts.py
def test_apply_discount():
assert apply_discount(100, 10) == 90
# Coverage is happy:
# Name Stmts Miss Cover
# myapp/discounts.py 3 0 100%
# Mutation testing is not:
# $ mutmut run
# 10/10 killed 9 survived 1
#
# $ mutmut results
# myapp.discounts.x_apply_discount__mutmut_6: survived
#
# $ mutmut show myapp.discounts.x_apply_discount__mutmut_6
# - percent = min(percent, 50)
# + percent = min(percent, 51)
# The cap is executed by the test but never verified, so changing it breaks
# nothing. That surviving mutant is the missing test:
# assert apply_discount(100, 90) == 50Load & Performance Testing
Performance tests evaluate how your system behaves under expected and extreme load conditions.
# Using Locust for load testing
import random
from locust import HttpUser, task, between
class WebsiteUser(HttpUser):
wait_time = between(1, 3) # Wait 1-3 seconds between tasks
@task(3) # Run this task 3x more often than other tasks
def view_products(self):
self.client.get("/products")
@task(1)
def view_product_detail(self):
product_id = random.randint(1, 100)
self.client.get(f"/products/{product_id}")
@task(1)
def add_to_cart(self):
self.client.post("/cart", json={
"product_id": random.randint(1, 100),
"quantity": 1
})
# Run with: locust -f loadtest.py --host=https://example.com
# Opens web UI at http://localhost:8089 to configure users and spawn rateLoad Testing
Simulates expected traffic to verify system handles normal load. Goal: Confirm performance under typical conditions.
Stress Testing
Pushes system beyond normal capacity to find breaking points. Goal: Identify maximum capacity and failure modes.
- Response time: P50, P95, P99 latencies
- Throughput: Requests per second
- Error rate: Percentage of failed requests
- Resource usage: CPU, memory, database connections
Security & Penetration Testing
Security testing identifies vulnerabilities before attackers do. It should be integrated throughout development, not just at the end.
Common Vulnerabilities
- SQL Injection
- Cross-Site Scripting (XSS)
- Authentication bypass
- Insecure dependencies
- Sensitive data exposure
- Missing rate limiting
Security Testing Tools
- Bandit: Python code scanner
- pip-audit: Dependency checker (PyPA)
- OWASP ZAP: Web app scanner
- SQLMap: SQL injection tester
- Snyk: Vulnerability scanner
# Test for SQL injection vulnerability
def test_sql_injection_prevention(db):
# Malicious input attempting SQL injection
malicious_input = "1' OR '1'='1"
# Should be safely handled by parameterized queries
cursor = db.execute(
"SELECT * FROM users WHERE id = ?",
(malicious_input,)
)
# Note: cursor.rowcount is -1 for SELECT in the DB-API, so assert on
# the rows themselves, never on rowcount
assert cursor.fetchall() == []
# And the table is still intact: the input was treated as data, not SQL
assert db.execute("SELECT COUNT(*) FROM users").fetchone()[0] > 0
# Test authentication
def test_requires_authentication(client):
response = client.get("/api/profile")
assert response.status_code == 401
response = client.get(
"/api/profile",
headers={"Authorization": "Bearer invalid_token"}
)
assert response.status_code == 401
# Test rate limiting
def test_rate_limiting(client):
# Make 100 rapid requests
responses = [client.post("/api/login") for _ in range(100)]
# Some should be rate limited
status_codes = [r.status_code for r in responses]
assert 429 in status_codes # Too Many RequestsDebugging Techniques
Effective debugging is a critical skill. Here are professional techniques that will save you hours of frustration.
1. The Python Debugger (pdb)
def calculate_discount(price, discount_percent):
breakpoint() # Python 3.7+: drops into pdb at this line
discount = price * (discount_percent / 100)
final_price = price - discount
return final_price
result = calculate_discount(100, 20)
# When execution hits breakpoint():
# (Pdb) p price # Print variable
# 100
# (Pdb) p discount_percent
# 20
# (Pdb) n # Next line
# (Pdb) p discount # Check calculated value
# 20.0
# (Pdb) c # Continue execution
# Older code still uses the explicit form: import pdb; pdb.set_trace()
# breakpoint() is preferred because it honours the PYTHONBREAKPOINT
# variable, so PYTHONBREAKPOINT=0 disables every breakpoint at once.Common pdb commands:
n- Next lines- Step into functionc- Continue executionp variable- Print variable
l- List source codew- Where am I? (stack)q- Quit debuggerh- Help
2. Logging for Debugging
import logging
# Configure logging
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
class PaymentError(Exception):
"""Raised when a payment cannot be processed."""
def process_order(order_id, items):
# Pass values as arguments instead of f-strings: the message is only
# formatted if the level is actually enabled
logger.debug("Processing order %s with %s items", order_id, len(items))
total = sum(item['price'] for item in items)
logger.info("Order %s total: %s", order_id, total)
if total > 10000:
logger.warning("Large order detected: %s", order_id)
try:
charge_payment(total)
logger.info("Payment successful for order %s", order_id)
except PaymentError:
# .exception() logs at ERROR level and appends the traceback
logger.exception("Payment failed for order %s", order_id)
raise
# Output (%(asctime)s includes milliseconds):
# 2026-01-03 10:30:45,127 - __main__ - DEBUG - Processing order 12345 with 3 items
# 2026-01-03 10:30:45,131 - __main__ - INFO - Order 12345 total: 450
# 2026-01-03 10:30:46,042 - __main__ - INFO - Payment successful for order 12345This is the debugging view of logging. For handlers, formatters, rotation, and structured output, see Lesson 12: Logging.
3. IDE Debugger (Visual Debugging)
Modern IDEs (PyCharm, VS Code) provide visual debuggers with:
- Breakpoints: Click line number to pause execution
- Variable inspection: Hover to see values
- Watch expressions: Monitor specific variables
- Call stack: Navigate function call hierarchy
- Conditional breakpoints: Break only when condition is true
4. The Debugging Mindset
✓ Do This
- Reproduce the bug consistently
- Form a hypothesis before debugging
- Use binary search (divide and conquer)
- Read error messages carefully
- Check assumptions with assertions
- Take breaks when stuck
✗ Avoid This
- Random code changes hoping it fixes
- Ignoring error messages
- Debugging without understanding the code
- Not testing your fix thoroughly
- Leaving debug code in production
- Debugging tired or frustrated
Test-Driven Development (TDD)
TDD is a development methodology where you write tests before writing the implementation code. It follows a simple cycle: Red → Green → Refactor.
Red
Write a failing test that defines desired behavior
Green
Write minimal code to make the test pass
Refactor
Improve code quality while keeping tests green
# TDD Example: Building a shopping cart
# Step 1: RED - Write failing test
def test_empty_cart_has_zero_total():
cart = ShoppingCart()
assert cart.total() == 0
# Result: NameError: name 'ShoppingCart' is not defined
# Step 2: GREEN - Make it pass (minimal code)
class ShoppingCart:
def total(self):
return 0
# Run test again: PASSES
# Step 3: RED - Next test
from dataclasses import dataclass
from decimal import Decimal
@dataclass
class Item:
name: str
price: Decimal # Decimal, not float: money must not lose cents
def test_cart_with_one_item():
cart = ShoppingCart()
cart.add_item(Item("Book", Decimal("10.00")))
assert cart.total() == Decimal("10.00")
# Result: AttributeError: 'ShoppingCart' object has no attribute 'add_item'
# Step 4: GREEN - Make it pass
class ShoppingCart:
def __init__(self):
self.items = []
def add_item(self, item):
self.items.append(item)
def total(self):
return sum(item.price for item in self.items)
# Run test: PASSES
# Step 5: REFACTOR - Improve without breaking tests
# (Tests stay green throughout refactoring)- Forces you to think about requirements before coding
- Produces better-designed, more testable code
- Provides instant feedback and confidence
- Built-in regression protection
Key Takeaways
- Testing enables confident refactoring - change code without fear
- Different test types serve different purposes - use the right tool for the job
- Unit tests form the foundation - fast feedback is invaluable
- Debugging is a learnable skill - systematic approach beats random changes
- Coverage is a guide, not a goal - test behaviors, not lines of code
- Mutation testing grades your tests - a surviving mutant is a missing assertion
- Properties find what examples miss - state the rule and let Hypothesis hunt for counter-examples
- Patch where the name is looked up - the most common mocking bug is patching the wrong module
- Security testing is continuous - integrate it throughout development
- TDD changes how you design - tests first leads to better architecture
Practice Exercises
Exercise 1: Password Validator
Write unit tests for a password validation function that checks:
- Minimum 8 characters
- At least one uppercase letter
- At least one number
- At least one special character
Use pytest and test both valid and invalid passwords
Exercise 2: Integration Test
Create an integration test for a user registration system that:
- Saves user to database
- Prevents duplicate emails
- Hashes passwords securely
Use an in-memory SQLite database for testing
Exercise 3: TDD Challenge
Use TDD to build a simple calculator class that supports:
- Addition, subtraction, multiplication, division
- Memory functions (store, recall, clear)
- Division by zero error handling
Write tests first, then implement to make them pass
Additional Resources
- Books: "Test Driven Development" by Kent Beck, "The Art of Unit Testing" by Roy Osherove
- Documentation: pytest.org, docs.python.org/3/library/unittest.html
- Tools: pytest-cov (coverage), pytest-mock (mocking), Hypothesis (property-based testing), mutmut (mutation testing)
- Going further: pytest-asyncio (async tests), freezegun (frozen clocks), schemathesis (API contract tests), tox/nox (multi-version runs)
- Videos: Search for "pytest tutorial" and "debugging techniques" on YouTube
What's Next?
With testing skills mastered, it's time to make your code faster! Learn how to profile and optimize performance.
- Profiling Tools - Identify performance bottlenecks in your code
- Optimization Techniques - Learn strategies to make Python code run faster
- Memory Management - Understand and optimize memory usage