A dict maps unique keys to values. It is the structure behind almost every test configuration, every JSON payload, every API response you'll work with in QA. Dictionaries are Python's equivalent of JavaScript objects ({key: value}) and Java's HashMap — but lighter to declare than either. This lesson covers creating dicts, getting and setting values safely, iterating, nesting, and merging — the toolkit you'll use every time you load a JSON fixture or build a request payload.
Creating a dictionary
Curly braces with key: value pairs:
config = {
"base_url": "https://staging.myapp.com",
"timeout": 5000,
"browser": "chromium",
"headless": True
}Keys are usually strings; they can be any immutable type — strings, ints, tuples — but never lists or other dicts. Values can be anything.
status_messages = {200: "OK", 404: "Not Found", 500: "Server Error"}
empty = {} # empty dict
also_empty = dict() # equivalentNote: {} is an empty dict, not an empty set. To get an empty set you must write set(). Surprising, but a one-time gotcha.
Reading values — bracket vs .get()
Two ways to look up a value:
print(config["base_url"]) # "https://staging.myapp.com"
print(config["does_not_exist"]) # KeyError: 'does_not_exist'Bracket access raises KeyError if the key is missing. For optional values, use .get():
print(config.get("base_url")) # "https://staging.myapp.com"
print(config.get("does_not_exist")) # None — no exception
print(config.get("retries", 3)) # 3 — default if absentconfig.get(key, default) is the canonical way to read "this might be set, but fall back to a sensible value." It's the difference between Python and Java's map.get(key) (returns null) versus map.getOrDefault(key, defaultValue) — Python rolls both into one method.
Use [] when the key must exist — let the KeyError make the bug visible. Use .get() when missing keys are expected.
Adding, updating, removing
config["retries"] = 3 # add new key
config["timeout"] = 10000 # overwrite existing key
del config["headless"] # remove — KeyError if missing
removed = config.pop("browser") # remove AND return the value
maybe = config.pop("nope", None) # safe pop with defaultconfig["x"] = y does double duty: insert if absent, overwrite if present. There's no separate "add" vs "update" method.
Checking key existence
"timeout" in config # True
"nope" not in config # Truein checks keys, not values. To check values you'd write "chromium" in config.values() (slower — values aren't hashed for lookup).
The trio of in, .get(), and [] covers 95% of dict access. A common safe-read pattern:
if "retries" in config:
n = config["retries"]
else:
n = 3…but the .get() version is shorter and the same idea: n = config.get("retries", 3).
Iterating
The default loop variable is the key:
for key in config:
print(key)For key/value pairs, call .items():
for key, value in config.items():
print(f"{key}: {value}")Just values: for v in config.values():. Just keys (explicit): for k in config.keys():. Iterating in insertion order is guaranteed since Python 3.7 — the order you wrote the keys is the order you'll get back.
Nested dictionaries
Real test data is rarely flat. A test user with credentials and preferences:
test_user = {
"name": "Alice",
"credentials": {
"email": "alice@test.com",
"password": "SecurePass123"
},
"preferences": {
"theme": "dark",
"language": "en"
},
"roles": ["tester", "admin"]
}
email = test_user["credentials"]["email"]
print(email) # "alice@test.com"
first_role = test_user["roles"][0]
print(first_role) # "tester"Each […] step drills one level deeper. Mix dicts and lists freely — that's how JSON looks once parsed. The same structure on the wire would be: {"name":"Alice","credentials":{"email":"…",…}} — Python's literal syntax is essentially JSON for objects.
Be aware: deep bracket access with
[]raisesKeyErrorat the first missing key. For optional paths, chain.get()with sensible defaults:test_user.get("credentials", {}).get("email", "Unknown")— the{}mid-chain stops aNonefrom propagating into the next.get().
Merging dictionaries — defaults plus overrides
A pattern you'll write all the time: a default config, plus environment-specific overrides:
defaults = {
"base_url": "http://localhost:3000",
"timeout": 5000,
"headless": False,
"retries": 1
}
staging = {
"base_url": "https://staging.myapp.com",
"headless": True,
"retries": 3
}
config = {**defaults, **staging}
print(config)Output:
{'base_url': 'https://staging.myapp.com', 'timeout': 5000, 'headless': True, 'retries': 3}
{**a, **b} unpacks both dicts into a new one. Later keys win — so staging's values override defaults where they overlap. This is the equivalent of JavaScript's {...defaults, ...staging} and is the cleanest way to layer config in Python.
Python 3.9+ also has the | operator: config = defaults | staging. Same effect, even shorter.
A QA example — building a Playwright launch config
default_browser_config = {
"browser": "chromium",
"headless": True,
"viewport": {"width": 1280, "height": 720},
"timeout": 30000
}
ci_overrides = {
"headless": True,
"viewport": {"width": 1920, "height": 1080},
"slow_mo": 0
}
local_overrides = {
"headless": False,
"slow_mo": 200
}
env = "ci" # imagine this came from os.environ
overrides = ci_overrides if env == "ci" else local_overrides
launch_config = {**default_browser_config, **overrides}
print(f"Launching {launch_config['browser']} headless={launch_config['headless']}")
print(f" viewport: {launch_config['viewport']['width']}x{launch_config['viewport']['height']}")
print(f" slow_mo: {launch_config.get('slow_mo', 0)}ms")Output:
Launching chromium headless=True
viewport: 1920x1080
slow_mo: 0ms
Three small dicts, one merge, and you've got an environment-aware launch configuration — exactly how real Playwright suites organise their settings.
A nested config, drawn
- – email → 'alice@test.com'
- – password → 'SecurePass123'
- – theme → 'dark'
- – language → 'en'
- [0] → 'tester' –
- [1] → 'admin' –
Each branch is a key on test_user. Two of them point at sub-dicts (drilled with another ["…"]); one points at a list (indexed with [0]); one points at a plain string. JSON parsed into Python looks exactly like this shape — that's why dicts and lists are the bedrock of every API test.
⚠️ Common mistakes
- Using
[]when the key might be missing.config["timeout"]raisesKeyErroriftimeoutwas never set. Useconfig.get("timeout", default)whenever the key is optional. Save[]for keys that are guaranteed to exist — its loud failure is then a feature. - Mutating a dict while iterating it.
for k in config: del config[k]raisesRuntimeError: dictionary changed size during iteration. Iterate over a copy of the keys instead:for k in list(config):. - Confusing
{}with an empty set.x = {}is an empty dict. To create an empty set, you must writeset(). The shape{1, 2, 3}is a set;{1: "a", 2: "b"}is a dict;{}is, by convention, the dict.
🎯 Practice task
Build an environment-aware test config. 25-30 minutes.
- Create
test_config.py. - Define a
defaultsdict with at least these keys:base_url,timeout,browser,headless,retries. - Define two override dicts:
staging(real URL, headless=True, retries=3) andlocal(localhost URL, headless=False, retries=1). - Read an environment from somewhere — for now, a hard-coded string
env = "staging". - Merge with
{**defaults, **staging}(ordefaults | stagingif you're on Python 3.9+) intoconfig. Pretty-print every key/value withfor k, v in config.items(): print(f"{k:<10} = {v}"). - Add a nested key:
config["viewport"] = {"width": 1920, "height": 1080}. Read back the nested width withconfig["viewport"]["width"]. - Try both access forms —
config["timeout"]andconfig.get("does_not_exist", "fallback"). Confirm one raisesKeyError(catch it withtry/exceptor comment it out) and the other returns the fallback. - Write a function
def get_setting(cfg: dict, key: str, default=None)that wrapscfg.get(key, default)and prints what was returned. Call it for two keys — one present, one missing. - Stretch: turn the env switcher into a function
def build_config(env: str) -> dict:that picks the right override dict and returns the merged config. Call it for"staging"and"local", print both, and confirm only the differing keys differ.
You can now read, write, and reshape any dict-based data Python loads. The next lesson covers tuples and sets — Python's two complementary collections that solve "fixed-shape record" and "unique values" without reaching for a list.