Functions & Modules
Up to now your code has been one long script. That is fine for ten lines, but a real test suite has hundreds of steps that repeat—log in, open a page, fill a form—and copying those steps everywhere is a maintenance nightmare. Functions let you name a block of work once and reuse it everywhere; modules let you split code across files and borrow the enormous standard library and third-party packages. This module also covers virtual environments, the single most important habit for keeping a test project clean and reproducible. Get this right and your automation stays readable as it grows.
Learning objectives
By the end of this lesson you will be able to:
- Define functions with
defand call them with arguments. - Use parameters, default values, and keyword arguments.
- Return values from a function and understand what
Nonemeans. - Accept any number of arguments with
*argsand**kwargs. - Explain variable scope: local versus global.
- Write a simple
lambda(anonymous function). - Import from the standard library, install packages with
pip, and create an isolated virtual environment withvenv.
Defining a function with def
A function is a named, reusable block of code. You define one with the def keyword, a name, parentheses, and a colon; the body is indented beneath. Defining a function does not run it—you run it later by calling it with its name and parentheses.
def greet_tester():
print("Welcome to the test suite")
greet_tester() # call it -> Welcome to the test suite
greet_tester() # call it again -- reuse with no copy-paste
Function names follow the same snake_case convention as variables, and a good name describes the action: login, open_dashboard, reset_database. The whole point is to write a step once and call it by name wherever you need it.
Parameters and arguments
Functions become powerful when they accept input. A parameter is the placeholder named in the definition; an argument is the actual value you pass when calling. Inside the function, the parameter behaves like a variable holding whatever you passed.
def log_result(test_name, passed):
status = "PASS" if passed else "FAIL"
print(status, "-", test_name)
log_result("Login test", True) # PASS - Login test
log_result("Checkout test", False) # FAIL - Checkout test
Here test_name and passed are parameters; "Login test" and True are arguments. By default, arguments are matched to parameters by position—first to first, second to second.
Default and keyword arguments
A parameter can have a default value, used when the caller omits it. This is perfect for optional settings like timeouts or retry counts that usually stay the same.
def open_page(url, timeout=10, headless=True):
print("Opening", url, "timeout:", timeout, "headless:", headless)
open_page("https://example.com") # uses both defaults
open_page("https://example.com", 30) # override timeout
open_page("https://example.com", headless=False) # keyword argument
The last call uses a keyword argument: you name the parameter explicitly (headless=False), so you can set just the one you care about and skip the rest. Keyword arguments also make calls self-documenting—open_page(url, headless=False) reads far more clearly than a bare open_page(url, 10, False). One rule: parameters with defaults must come after those without them in the definition.
Returning values
Printing is for humans; returning is for code. The return statement hands a value back to whoever called the function, so the result can be stored, compared, or passed onward. This is what makes functions composable—and it is how every helper in a test produces something you can assert on.
def add_tax(price, rate=0.2):
return price + (price * rate)
total = add_tax(100) # 120.0 -- captured in a variable
print(add_tax(50, 0.1)) # 55.0 -- used directly
def is_valid_status(code):
return code == 200 # returns a Boolean you can assert on
assert is_valid_status(200) # passes
A function without a return (or with a bare return) hands back the special value None, Python’s way of saying “nothing.” Once return runs, the function ends immediately—any lines after it are skipped. Prefer returning values over printing inside functions: a function that returns can be tested and reused, while one that only prints is a dead end.
Functions are how test helpers are built
A real suite is full of small helper functions that each do one job and return a result. You might write build_login_payload(user, pwd) that returns a dictionary, or extract_order_id(response) that returns a string. Your actual tests then read like plain English:
def login(username, password):
# ... drive the browser or call the API ...
return True # True if login succeeded
def test_valid_login():
assert login("tomsmith", "SuperSecret") == True
This separation—reusable helpers that return values, thin tests that assert on them—is the backbone of maintainable automation. When the login flow changes, you fix one function, not fifty copied code blocks.
*args and **kwargs
Sometimes you do not know how many arguments a function will receive. *args collects any number of positional arguments into a tuple, and **kwargs collects any number of keyword arguments into a dictionary. The names args and kwargs are convention; the * and ** are what matter.
def run_all(*args):
for test in args:
print("Running:", test)
run_all("login", "search", "checkout") # any number of names
def configure(**kwargs):
for key, value in kwargs.items():
print(key, "=", value)
configure(browser="Chrome", timeout=30, retries=2)
You will meet these constantly in test frameworks and fixtures, where a function needs to accept flexible options and pass them along. For now, just recognise the syntax: a single * means “gather extra positional arguments,” a double ** means “gather extra keyword arguments.”
Variable scope
Scope is the region of code where a variable is visible. A variable created inside a function is local: it exists only while that function runs and cannot be seen from outside. A variable created at the top level of your file is global: functions can read it, but assigning to it inside a function creates a separate local one instead.
suite_name = "Smoke" # global
def show():
attempts = 3 # local -- only exists in show()
print(suite_name) # can READ the global: Smoke
print(attempts)
show()
print(attempts) # NameError! 'attempts' does not exist out here
This is a feature, not a limitation: locals keep functions self-contained, so a variable in one function cannot accidentally clobber one in another. The clean way to get data out of a function is to return it, not to reach for globals. Avoid the global keyword—code that mutates globals from inside functions is notoriously hard to test and debug.
Never use a mutable default argument
This looks harmless and is one of Python’s most infamous traps:
def add_result(result, results=[]): # DANGER: mutable default
results.append(result)
return results
print(add_result("pass")) # ['pass']
print(add_result("fail")) # ['pass', 'fail'] -- the old one is still there!
The default list is created once when the function is defined and then shared across every call, so results leak from one call into the next—a disaster in tests, where it manifests as data bleeding between cases. The fix is to default to None and create a fresh list inside:
def add_result(result, results=None):
if results is None:
results = [] # a new list every call
results.append(result)
return results
Rule of thumb: default arguments should only ever be immutable values (numbers, strings, True/False, None)—never a list, dict, or set.
A gentle intro to lambda
A lambda is a tiny, unnamed function written in one line, handy when you need a quick throwaway function to pass as an argument. The form is lambda parameters: expression, and the expression’s value is returned automatically.
double = lambda x: x * 2
print(double(5)) # 10
# Most common real use: sorting by a computed key
tests = [("login", 3), ("search", 1), ("checkout", 2)]
tests.sort(key=lambda item: item[1]) # sort by the second element
print(tests) # [('search', 1), ('checkout', 2), ('login', 3)]
Do not overuse them: if a lambda gets complicated, a normal def with a clear name is more readable. But for a short “sort by this” or “filter by that,” a lambda is neat and idiomatic.
Modules and import
A module is just a Python file. Splitting code across modules keeps projects organised, and—more immediately useful—Python ships with a huge standard library of modules you import and use for free. You bring a module in with import.
import random
import datetime
print(random.randint(1, 100)) # a random int, e.g. for test data
today = datetime.date.today()
print("Test run date:", today)
# Import just one name from a module:
from random import choice
accounts = ["alice", "bob", "carol"]
print(choice(accounts)) # pick a random account
Two import styles: import random brings in the whole module (you then write random.randint), while from random import choice pulls one name in directly (you write just choice). Useful standard-library modules for testers include random (generating test data), datetime (timestamps), json (reading/writing API payloads), os (files and environment variables), and re (pattern matching in text).
Installing packages with pip
Beyond the standard library, the community has published hundreds of thousands of packages on PyPI (the Python Package Index). You install them with pip, run from your terminal—not inside a .py file.
pip install requests # a popular HTTP library for API tests
pip install pytest # the standard Python test framework
pip list # see what is installed
pip show requests # details about one package
Once installed, you import the package in your code exactly like a standard-library module. But where pip installs packages matters enormously, which brings us to the most important habit in this module.
Virtual environments (venv)
If you pip install everything globally, every project shares one pile of packages. Project A needs Selenium 4.10, project B needs 4.25, and now they conflict—upgrading one breaks the other. A virtual environment solves this: it is an isolated, per-project folder with its own Python and its own packages, completely separate from the system and from other projects.
The venv module is built into Python. From your project folder:
# 1. Create a virtual environment named .venv
python -m venv .venv
# 2. Activate it
# Windows (PowerShell):
.venv\Scripts\Activate.ps1
# macOS / Linux:
source .venv/bin/activate
# 3. Now pip installs go INTO this environment only
pip install pytest selenium
# 4. When finished:
deactivate
Once activated, your prompt usually shows (.venv) at the front, confirming you are inside it. Everything you pip install now lives in that folder and affects nothing else. Create a fresh virtual environment for every test project—this is non-negotiable professional practice.
Tutor tip: freeze your dependencies with requirements.txt
To make your project reproducible—so a teammate or a CI server can recreate the exact same setup—capture your installed packages to a file:
pip freeze > requirements.txt
That writes every package and its exact version to requirements.txt. Anyone who clones your project then runs pip install -r requirements.txt inside their own fresh virtual environment and gets an identical environment. Commit requirements.txt to version control, but add the .venv folder to your .gitignore—the environment is rebuilt from the file, never shared directly.
Hands-on exercise
Build a small reusable test-helper module and run it inside a virtual environment:
- Create a project folder, then create and activate a virtual environment with
python -m venv .venv. - In a file
helpers.py, write a functionclassify(score, threshold=60)that returns"PASS"or"FAIL"using a ternary and a default argument. - Add a function
summarise(*scores)that uses*argsto accept any number of scores and returns how many passed (reuseclassifyinside it). - In a second file
run.py,importyour helpers and call both functions, printing the results. - Use the standard library:
import randomand generate a list of random scores withrandom.randintto feed your functions. - Run
pip install pytestinside the environment, thenpip freeze > requirements.txtand open the file to see the captured versions. - Bonus: deliberately write the mutable-default bug from the pitfall box, observe the leak, then fix it with the
Nonepattern.
Knowledge check
- What is the difference between a parameter and an argument?
- Why is returning a value usually better than printing inside a function?
- What does a function return if it has no
returnstatement? - What do
*argsand**kwargseach collect? - Why should you never use a list as a default argument value?
- What problem does a virtual environment solve, and which built-in module creates one?
- What does
pip freeze > requirements.txtdo, and why is it useful?
Show answers
- A parameter is the placeholder name in the function definition; an argument is the actual value passed in when the function is called.
- A returned value can be stored, compared, asserted on, and reused, making the function testable and composable; a function that only prints is a dead end.
- It returns
None, the special value meaning “nothing.” *argscollects extra positional arguments into a tuple;**kwargscollects extra keyword arguments into a dictionary.- The default is created once and shared across all calls, so data leaks between calls; default to
Noneand create a fresh list inside instead. - It isolates each project’s packages so different projects’ dependencies cannot conflict; the built-in
venvmodule creates it (python -m venv .venv). - It writes every installed package and its exact version to a file so the environment can be recreated identically with
pip install -r requirements.txt.
Key takeaways
- Define reusable blocks with
def; pass data via parameters, use default and keyword arguments for clean, flexible calls. - Prefer
returnoverprintinside functions—returned values can be stored and asserted on; noreturnmeansNone. *args/**kwargsaccept flexible arguments; keep variables local and avoid globals; never use a mutable default argument.lambdagives a one-line throwaway function, handy as a sort/filter key.- Import standard-library and pip-installed modules to reuse code; always work inside a per-project
venvand capture dependencies withpip freeze.
Go deeper
Professional test code leans on a few function features beyond the basics:
- Type hints and docstrings document a helper without changing how it runs. Editors and tools like
mypyuse the hints to catch mistakes before the test does:def classify(score: int, threshold: int = 60) -> str: """Return 'PASS' if score meets the threshold, else 'FAIL'.""" return "PASS" if score >= threshold else "FAIL" - Functions can return several values at once as a tuple, which the caller unpacks—ideal for a helper that yields both a result and a message:
def check(actual, expected): return actual == expected, f"expected {expected}, got {actual}" ok, detail = check(200, 200)
A bare * in the parameter list forces every following argument to be passed by name, which stops confusing positional call sites like open_page(url, True, False):
def open_page(url, *, headless=True, timeout=10):
...
open_page("https://example.com", headless=False) # the name is now mandatory
Finally, guard any code you want to run only when a file is executed directly—not when it is imported as a module—with the if __name__ == "__main__": block. Test files rely on this so that importing a helper module never accidentally kicks off a run.