Functions — def, Parameters, and Return Values

8 min read

A function packages a chunk of logic, gives it a name, and lets you call it from anywhere. In a QA codebase, helpers like generate_email(), format_duration(), and build_api_url() save you from copy-pasting the same six lines into every test. Python's function syntax is the lightest you'll meet in any mainstream language: def name(params): body. No return type to declare, no class to wrap it in. This lesson covers def, parameters (positional, keyword, default, *args, **kwargs), return values, type hints, and docstrings.

The basic shape — def name(params):

def validate_response(status_code, body):
    if status_code != 200:
        return False
    if not body:
        return False
    return True

Read it line by line:

  • def — keyword that starts a function definition. Short for "define".
  • validate_response — the function name. snake_case by convention.
  • (status_code, body) — the parameters. Comma-separated, no types declared (yet).
  • : — colon to end the definition line. Same rule as if and for.
  • The body is indented. Four spaces, like every other Python block.
  • return value — sends the value back to the caller. Optional — a function with no return returns None.

Calling the function:

result = validate_response(200, '{"users": []}')
print(result)        # True
 
result = validate_response(404, "")
print(result)        # False

Compared to Java's public static boolean validateResponse(int statusCode, String body) { … }, Python is positively bare. No public, no static, no return type. Less safety in some senses, more speed in others.

Return values — and what happens without one

A return statement sends a value back and exits the function immediately:

def http_status_label(code):
    if code < 400:
        return "ok"
    if code < 500:
        return "client-error"
    return "server-error"
 
print(http_status_label(503))   # "server-error"

Multiple returns in one function are completely fine — often clearer than a single return at the bottom with nested ifs.

A function with no return (or return with no value) returns None:

def log_event(message):
    print(f"[event] {message}")
 
result = log_event("test started")
print(result)        # None

This trips up beginners: result = print("hello") assigns None to result because print returns None. If you intend a function to be used for its return value, double-check it has a return.

Default parameter values

You can give a parameter a default — callers may then omit it:

def create_user(name, email, role="tester"):
    return {"name": name, "email": email, "role": role}
 
user1 = create_user("Alice", "alice@test.com")                  # role="tester"
user2 = create_user("Bob", "bob@test.com", role="admin")        # role="admin"

This is closer to JavaScript's function f(x = 1) than Java's overloaded methods. It's cheap, ergonomic, and used everywhere in Python.

One trap. Don't use a mutable default like [] or {}. Python evaluates the default once when the function is defined, not on every call — so all calls share the same list. Use None and create a fresh value inside:

def add_test(name, tags=None):
    tags = tags or []           # fresh list every call
    return {"name": name, "tags": tags}

Keyword arguments — call by name

You don't have to pass arguments in order. Specify the parameter name and Python figures out where it goes:

def create_user(name, email, role="tester"):
    return {"name": name, "email": email, "role": role}
 
user = create_user(email="carol@test.com", name="Carol")
print(user)

Output:

{'name': 'Carol', 'email': 'carol@test.com', 'role': 'tester'}

Order is irrelevant when you use keyword arguments. They are especially useful for functions with many parameters — create_user(name="Alice", email="…", role="admin", verified=True) reads better than five positional values in a row.

You can mix positional and keyword arguments — but positional ones must come first:

create_user("Alice", "alice@test.com", role="admin")     # ok
create_user(name="Alice", "alice@test.com")              # SyntaxError

*args — variable positional arguments

If you don't know in advance how many arguments will be passed, prefix the parameter name with *. Inside the function it becomes a tuple:

def log(*messages):
    for msg in messages:
        print(f"[log] {msg}")
 
log("started", "running test 1", "running test 2")
log("completed in 3.2s")

messages is a tuple — ("started", "running test 1", "running test 2") in the first call. The name args is conventional but not required; *messages reads better when the meaning is "messages."

The mirror image when calling: * unpacks a sequence into positional arguments:

inputs = ["started", "phase 1 done", "phase 2 done"]
log(*inputs)        # equivalent to log("started", "phase 1 done", "phase 2 done")

**kwargs — variable keyword arguments

Two stars collect named extra arguments into a dict:

def configure(**options):
    for key, value in options.items():
        print(f"{key:<10} = {value}")
 
configure(env="staging", retries=3, timeout=30)

Output:

env        = staging
retries    = 3
timeout    = 30

**options is a dict — {"env": "staging", "retries": 3, "timeout": 30}. Pass the contents of a dict as keyword arguments by unpacking with **:

defaults = {"env": "staging", "retries": 3}
configure(**defaults, timeout=30)

*args and **kwargs together let you forward arbitrary arguments through a wrapper function — a common pattern in test helpers and decorators (chapter 5).

Type hints — optional but loved

Type hints attach a declared type to each parameter and the return value. Python doesn't enforce them at runtime, but IDEs and mypy use them for autocompletion and static checks:

def validate_response(status_code: int, body: str) -> bool:
    return status_code == 200 and len(body) > 0

The shape is name: Type for parameters and -> Type after the ) for the return type. For complex types you'd import from typing:

from typing import Optional
 
def find_user(user_id: int) -> Optional[dict]:
    """Return the user dict, or None if not found."""
    ...

For QA helpers, hinting parameters and the return type is a small effort that pays back in IDE autocompletion. We won't insist on hints throughout this course — Python's idiom is "hint when it helps."

Docstrings — built-in documentation

A triple-quoted string immediately after the def line becomes the function's docstring. IDEs show it on hover; tools like Sphinx generate API docs from it.

def format_duration(milliseconds: int) -> str:
    """Convert milliseconds to a 'Xm Ys' label.
 
    Examples:
        format_duration(125_000) -> '2m 5s'
        format_duration(45_000)  -> '0m 45s'
    """
    minutes, seconds = divmod(milliseconds // 1000, 60)
    return f"{minutes}m {seconds}s"
 
help(format_duration)        # prints the docstring

Docstrings are the standard way to document Python code. Every function in a real test framework has one.

A function, anatomically

Three QA helpers

A pocket library you might write in week one of a testing project:

from datetime import datetime
 
def generate_email(base: str = "qa") -> str:
    """Build a unique-looking email address for test data."""
    stamp = datetime.now().strftime("%Y%m%d%H%M%S")
    return f"{base}+{stamp}@test.com"
 
def format_duration(milliseconds: int) -> str:
    """Render a duration in 'Xm Ys' shape."""
    minutes, seconds = divmod(milliseconds // 1000, 60)
    return f"{minutes}m {seconds}s"
 
def build_api_url(env: str, path: str, **params) -> str:
    """Compose a URL with optional query parameters."""
    base = f"https://{env}.api.example.com"
    if not params:
        return f"{base}{path}"
    query = "&".join(f"{k}={v}" for k, v in params.items())
    return f"{base}{path}?{query}"
 
print(generate_email())                                 # qa+20260506...@test.com
print(format_duration(125_000))                         # 2m 5s
print(build_api_url("staging", "/users", role="admin")) # https://staging.api.example.com/users?role=admin

Three reusable helpers — under thirty lines total — that any test in your suite can call. That economy is the whole point of functions.

⚠️ Common mistakes

  • Mutable default arguments. def add(item, items=[]): looks fine but the same list is shared across every call without an explicit items=. Use items=None and create a new list inside the function.
  • Forgetting return. A function that does its work via side effects and returns nothing returns None. Calling result = my_func(...) and then trying to use result will fail with 'NoneType' object has no …. If you mean for the function to produce a value, write return value.
  • Calling without parentheses. format_duration (just the name) is the function object; format_duration(125_000) actually calls it. Reviewers see print(format_duration) regularly — fix is to add the ().

🎯 Practice task

Build a small QA helper module. 25-30 minutes.

  1. Create qa_helpers.py.
  2. Write def assert_status(actual: int, expected: int = 200) -> None: that prints "OK" if they match and raises AssertionError(f"expected {expected}, got {actual}") otherwise. Test with both a matching and a non-matching call (wrap the failing one in a try/except so the script still runs).
  3. Write def build_user(name: str, email: str, role: str = "tester", **extras) -> dict: that returns a dict with name, email, role, plus everything from extras. Call it both with and without extras (e.g. verified=True, age=30). Print each result.
  4. Write def format_duration(milliseconds: int) -> str: that returns a string like "2m 5s" for 125000. Use divmod.
  5. Write def log(*messages: str) -> None: that prints each message prefixed with a timestamp (datetime.now().strftime(...)).
  6. At the bottom of the file, exercise each function with at least two calls. Run with python qa_helpers.py and confirm all the outputs.
  7. Stretch: add a def create_users(count: int, base: str = "qa") -> list: that uses generate_email() (also write that one) to produce a list of count user dicts via build_user. Confirm it returns a list of length count.

You can now define and call any function shape Python supports. The next lesson covers list comprehensions — Python's most loved single-line idiom for transforming and filtering data.

// tip to track lessons you complete and pick up where you left off across devices.