Chapter 20Lesson 02240–300 min

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.

Python library labTEST scopeLibdocunittestSecret contract

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 IF if 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?

What does --pythonpath src change?

Why does the second Robot test expect Quoted Count to be zero?

What evidence proves helper_not_exposed stayed private to Python?

If the fake token appears in log.html, should the lab still be considered correct because the token is fake?

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.

Next lesson

Writing Python Test Libraries and the Library API: Configuration, Design Patterns, and Trade-Offs

Continue with Writing Python Test Libraries and the Library API: Configuration, Design Patterns, and Trade-Offs. It builds directly on the state, evidence, and operating assumptions established here, so carry those constraints forward rather than treating the next page as an isolated topic.

References and version anchors

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.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.