Writing Python Test Libraries and the Library API: Guided Hands-On Workflow
Build a local typed Python library around independently unit-tested domain logic, expose a narrow keyword API, generate Libdoc, and consume it from Robot Framework with explicit search paths and evidence.
Learning objectives
- Create a local Python package tree with pure domain logic separated from the Robot adapter.
- Expose a small typed keyword API with TEST scope, public Robot decorators, safe logging, and a Secret parameter.
-
Run Python unit tests independently, generate Libdoc, and execute
Robot using an explicit
--pythonpath. - Inspect keyword signatures, object-scope behavior, results, and conversion failures as evidence.
- Keep the workflow reproducible without requiring a package index, browser, remote system, or paid service beyond installing Robot Framework itself.
Current compatibility baseline — verified 2026-08-31.
Robot Framework 7.4.2 is the stable course baseline
and requires Python 3.8+. The lab uses the documented static Python
library API, robot.api.deco.library,
robot.api.deco.keyword, robot.api.logger,
public exceptions from robot.api,
robot.api.types.Secret, and Libdoc. The
@library decorator disables automatic keyword discovery
by default; keyword methods are therefore exposed deliberately with
@keyword. Function argument annotations participate in
automatic argument conversion. Return annotations and
@keyword(types={"return": ...}) are documentation
metadata in current Robot Framework and do not convert
returned objects during execution. Secret arguments are masked in
Robot representations but are not encrypted and can still be
disclosed by code that accesses .value. The mandatory
path uses only Python, Robot Framework core, and Python's standard
unittest; no browser, API, database, SSH, Pabot, CI
provider, container runtime, paid platform, or production target is
required.
1. Disposable scenario and ownership contract
Create a directory named rf20-library-lab. The
synthetic domain is product quoting: normalize a SKU, validate
quantity/price, and return a quote dictionary. It never calls a real
commerce system. Pure Python code owns business rules; the Robot
adapter owns keyword naming, scope, conversion, logging, and status
mapping.
The lab uses a src/ layout and an explicit Robot/Libdoc
Python search path. That avoids relying on whichever directory
happens to be the current working directory and prepares the project
for packaging later.
rf20-library-lab/
├── src/
│ └── rf20_domain/
│ ├── __init__.py
│ ├── domain.py
│ └── library.py
├── tests/
│ ├── unit/
│ │ └── test_domain.py
│ └── robot/
│ └── inventory.robot
└── evidence/
├── unit/
├── libdoc/
└── robot/
2. Preflight: pin the interpreter and framework first
# Create/activate a virtual environment using your normal Python workflow.
python -m venv .venv
# Bash/zsh:
source .venv/bin/activate
# Windows PowerShell:
# .\.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
python -m pip install "robotframework==7.4.2"
python --version
python -m robot --version
python -m robot.libdoc --help
Record the commands and version output in your evidence notes. The package pin applies to the course lab on 2026-08-31; future learners should re-check the current stable Robot Framework/Python compatibility before changing the pin.
3. Write the domain logic first—without importing Robot Framework
from __future__ import annotations
def normalize_sku(raw: str) -> str:
"""Normalize a synthetic SKU without depending on Robot Framework."""
value = raw.strip().upper().replace(" ", "")
if not value:
raise ValueError("SKU must not be empty.")
if not value.replace("-", "").isalnum():
raise ValueError(f"SKU contains unsupported characters: {raw!r}")
return value
def quote_total(unit_price: float, quantity: int) -> float:
"""Return a deterministic line total for synthetic training data."""
if unit_price < 0:
raise ValueError("Unit price must be non-negative.")
if quantity < 1:
raise ValueError("Quantity must be at least 1.")
return round(unit_price * quantity, 2)
def build_quote(raw_sku: str, unit_price: float, quantity: int) -> dict[str, object]:
sku = normalize_sku(raw_sku)
return {
"sku": sku,
"unit_price": unit_price,
"quantity": quantity,
"total": quote_total(unit_price, quantity),
}
domain.py owns deterministic validation and
calculation. It raises ordinary Python ValueError for
invalid domain input. That makes it callable from Python unit tests,
another Python application, or a future service without needing
Robot Framework execution context.
Notice what is intentionally absent: no robot.api, no
logger, no suite variables, no global cache, no filesystem/network
mutation. The domain layer is the easiest layer to test quickly and
precisely.
4. Prove Python behavior independently with the standard library
import unittest
from rf20_domain.domain import build_quote, normalize_sku, quote_total
class DomainTests(unittest.TestCase):
def test_normalize_sku(self):
self.assertEqual(normalize_sku(" ab-12 "), "AB-12")
def test_quote_total(self):
self.assertEqual(quote_total(12.5, 2), 25.0)
def test_invalid_quantity_is_domain_error(self):
with self.assertRaisesRegex(ValueError, "at least 1"):
quote_total(10.0, 0)
def test_quote_shape(self):
self.assertEqual(
build_quote("x-1", 3.25, 2),
{"sku": "X-1", "unit_price": 3.25, "quantity": 2, "total": 6.5},
)
if __name__ == "__main__":
unittest.main()
Before Robot is involved, make src importable for this
shell and run the unit tests.
# Bash/zsh
mkdir -p evidence/unit
export PYTHONPATH="$PWD/src"
python -m unittest discover -s tests/unit -v 2>&1 | tee evidence/unit/unittest.txt
# Windows PowerShell
# New-Item -ItemType Directory -Force evidence\unit | Out-Null
# $env:PYTHONPATH = (Resolve-Path ".\src").Path
# python -m unittest discover -s tests/unit -v *>&1 |
# Tee-Object -FilePath evidence\unit\unittest.txt
Expected state: four unit tests pass, no Robot result files exist yet, and only the pure domain functions have executed. If these tests fail, repair Python logic before adding Robot as another diagnostic layer.
5. Add the Robot adapter: explicit exposure, TEST scope, typed boundary
from __future__ import annotations
from robot.api import Failure, logger
from robot.api.deco import keyword, library
from robot.api.types import Secret
from .domain import build_quote
@library(scope="TEST", version="1.0.0")
class InventoryLibrary:
"""Robot adapter around independently testable synthetic domain logic."""
def __init__(self, currency: str = "USD"):
self.currency = currency
self._quoted_skus: list[str] = []
@keyword("Quote Line")
def quote_line(self, raw_sku: str, unit_price: float, quantity: int) -> dict[str, object]:
"""Build a quote. Robot converts price and quantity before this call."""
try:
quote = build_quote(raw_sku, unit_price, quantity)
except ValueError as exc:
raise Failure(f"Cannot quote {raw_sku!r}: {exc}") from exc
self._quoted_skus.append(str(quote["sku"]))
logger.info(
f"Quoted sku={quote['sku']} quantity={quantity} currency={self.currency}"
)
return quote
@keyword
def quoted_count(self) -> int:
"""Return instance-local state so the scope can be observed."""
return len(self._quoted_skus)
@keyword("Accept Demo Secret")
def accept_demo_secret(self, token: Secret) -> str:
"""Accept only a Robot Secret object; never log token.value."""
logger.debug(f"Received demo token object {token}")
return "accepted"
def helper_not_exposed(self) -> str:
"""Public Python helper intentionally hidden by @library auto_keywords=False."""
return "python-only-helper"
The class uses @library(scope="TEST"). Every Robot test
receives a fresh instance, so self._quoted_skus cannot
make tests order-dependent. The undecorated
helper_not_exposed remains a normal Python method
because automatic keyword discovery is disabled.
quote_line receives float and
int values after Robot conversion. The adapter catches
a domain ValueError and translates it into public
Failure with automation-facing context. That
translation belongs here—not inside the reusable domain function.
The Secret keyword demonstrates a trust boundary. Logging the
Secret object representation is masked, but the method
deliberately never accesses/logs token.value because
the lab does not need the real value.
6. Generate Libdoc before executing a suite
Libdoc is a non-destructive inspection of the library contract. Its
-P/--pythonpath option adds the
src directory to the library/resource search path.
mkdir -p evidence/libdoc evidence/robot # Bash/zsh; create folders manually or with New-Item on PowerShell
python -m robot.libdoc -P src \
rf20_domain.library.InventoryLibrary \
evidence/libdoc/InventoryLibrary.html
python -m robot.libdoc -P src \
rf20_domain.library.InventoryLibrary \
list > evidence/libdoc/keywords.txt
PowerShell can place the command on one line or use the backtick
continuation character. Inspect the generated HTML/list before
running Robot. You should see Quote Line,
Quoted Count, and
Accept Demo Secret; you should not see
helper_not_exposed. Libdoc should also display the
typed arguments and documented return types.
7. Consume the library from Robot data
*** Settings ***
Library rf20_domain.library.InventoryLibrary currency=USD
*** Variables ***
${DEMO_TOKEN: Secret} %{RF20_DEMO_TOKEN}
*** Test Cases ***
Typed Quote Is Converted Before Python Call
${quote}= Quote Line ab-12 12.50 2
Should Be Equal ${quote}[sku] AB-12
Should Be Equal As Numbers ${quote}[total] 25.0
${count}= Quoted Count
Should Be Equal As Integers ${count} 1
A New Test Gets A New Library Instance
${count}= Quoted Count
Should Be Equal As Integers ${count} 0
Secret Contract Accepts A Secret Object
${status}= Accept Demo Secret ${DEMO_TOKEN}
Should Be Equal ${status} accepted
The typed Secret variable reads a fake token from an environment variable. Set it explicitly instead of hard-coding a real credential:
# Bash/zsh
export RF20_DEMO_TOKEN='token-FAKE_DO_NOT_USE'
# Windows PowerShell
# $env:RF20_DEMO_TOKEN = 'token-FAKE_DO_NOT_USE'
python -m robot \
--pythonpath src \
--outputdir evidence/robot \
tests/robot/inventory.robot
--pythonpath src mutates Python's import search path
for this Robot process; it does not modify source files or install a
global package. The Library import creates a TEST-scoped instance
for each test. The first test mutates only its own
_quoted_skus; the second test independently proves that
the new instance starts at zero.
8. Inspect state before and after each layer
| Evidence | Expected observation | What it proves |
|---|---|---|
evidence/unit/unittest.txt |
Pure Python domain tests pass | Domain logic works without Robot |
| Libdoc keyword list | 3 exposed keywords; helper absent | Decorator boundary is effective |
| Libdoc signatures | price=float, quantity=int, Secret type, return metadata | Public call contract is discoverable |
| Robot first test | Quote total 25.0, count 1 | Argument conversion + instance mutation |
| Robot second test | Count starts at 0 | TEST scope isolation |
| Robot Secret test | PASS without literal secret in suite source | Typed secret boundary is used |
output.xml/log.html |
Keyword hierarchy + statuses | Robot execution evidence exists independently of unit tests |
Do a redaction check on output.xml and
log.html. Search for the fake token literal. If it
appears, treat that as a security defect even though the value is
synthetic.
9. Controlled failure: prove conversion occurs before method execution
Invalid Price Is Rejected Before Python Body
Quote Line SKU-FAIL not-a-number 2
Run this in a separate evidence directory. Robot should fail while
converting unit_price to float. Because
the method body is never entered, its INFO message should not appear
for this call. Preserve the failed output.xml and log;
then remove the intentionally broken test.
This distinction helps debugging: a conversion failure is an
API-contract failure at the Robot→Python boundary, not a failure
inside build_quote.
10. Observe Python instance lifetime without hidden globals
The second test is a scope probe. With TEST scope it sees count zero. Later, the checkpoint lab will deliberately change the decorator to GLOBAL, causing the second test to observe prior state. That controlled failure proves that library scope is executable architecture.
Do not “fix” state leakage by resetting a global list in every test without questioning why the scope is global. Prefer the narrowest lifetime compatible with the external resource being managed.
11. Small challenge: choose the correct layer
Add a rule that rejects a SKU longer than 20 characters. Decide where it belongs before coding:
-
If the 20-character rule is a reusable business invariant,
implement/test it in
domain.py. - If Robot needs a friendlier automation-facing message, translate the domain exception in the adapter.
-
Do not encode the rule only as a Robot
IFif Python and other callers must share it.
Then regenerate Libdoc only if the public keyword contract changed; internal domain implementation changes alone should not force a keyword API change.
12. Cleanup and rollback
The lab mutates only the project directory and virtual environment. Preserve evidence until review is complete. Cleanup consists of deactivating/removing the disposable virtual environment and deleting the lab directory when no evidence is needed. There is no external system to restore.
Never delete the failed conversion run before comparing it with the repaired result. Chapter 14's first-failure evidence rule applies to custom-library development too.
Knowledge check
Why do unit tests run before Robot tests?
They isolate pure Python domain behavior from Robot import, conversion, scope, and result layers. A domain failure can be repaired without adding Robot-specific noise.
What does --pythonpath src change?
It adds src to Python module search locations for that Robot process. It does not set Robot variable scope or install the package globally.
Why does the second Robot test expect Quoted Count to be zero?
The library uses TEST scope, so a new Python object is created for each test. Instance state from the first test must not carry over.
What evidence proves helper_not_exposed stayed
private to Python?
Libdoc/list output does not contain it, and Robot cannot resolve it as a keyword. The @library/@keyword boundary is observable.
If the fake token appears in log.html, should the lab still be considered correct because the token is fake?
No. The fake value is deliberately safe, but appearance in logs proves the library pattern would leak a real value. Treat it as a contract defect and fix the logging path.
13. Summary and next lesson
You now have three independent contracts: Python unit tests for domain behavior, Libdoc for the keyword API, and Robot results for integration/scope/conversion behavior. Lesson 3 compares the design choices behind those contracts and shows when a module, broader scope, packaged dependency, status exception, or logger strategy is justified.
References and version anchors
- Robot Framework 7.4.2 — Creating test libraries — static API and argument conversion
- Robot Framework 7.4.2 — Library scope — TEST/SUITE/GLOBAL object lifetime
- Robot Framework 7.4.2 — Libdoc — documentation generation
- robot.api.types.Secret — public Secret type
- Python unittest — standard-library unit testing
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.