Skip to content
Migrating from Legacy Systems

Replace TestStand Callbacks in Python

Map each TestStand callback to its Python equivalent, and learn which ones you should delete instead of porting during a migration.

JJulien Buteau
advanced11 min readSeptember 9, 2026

Replace TestStand Callbacks in Python

Callbacks are usually the hardest part of moving a test system off a traditional test executive. The measurement code ports easily: a voltage read is a voltage read. The callbacks are harder, because they encode behavior that lives outside any single test sequence, and because much of what they do disappears rather than migrates.

This guide maps each common callback to its Python equivalent, and identifies which ones you should not port at all.

What Callbacks Do

In NI TestStand, a process model defines the lifecycle around your test sequence, and callbacks are the named points where a sequence can override the default. PreUUT prompts for a serial number. PostUUT handles the result. TestReport formats the report. Override one in a client sequence and your version runs instead of the model's.

The pattern solves a real problem: customizing the lifecycle for one product without copying the whole model. Its cost is indirection, since understanding one run can mean opening the model, the client sequence, and every override.

The Mapping

CallbackPurposePython equivalent
PreUUTLoopOnce before testing beginsStation-scoped plug initialization
PreUUTIdentify the next unitunit: block, prompt or auto_identify
MainSequenceThe test itselfmain: phases
PostUUTAct on one unit's resultteardown: phases
PostUUTLoopOnce after the last unitStation-scoped plug teardown
TestReportFormat the reportNot ported: built in
LogToDatabaseStore resultsNot ported: built in
ProcessModelPostStepAfter every stepNot ported: engine records phases
SequenceFilePreStepBefore each step in a filesetup: phases or depends_on

Four of nine are not ported. That is the main finding of most migrations: a large share of callback code exists to move results into storage, and that job no longer belongs to your test code.

PreUUT: Identifying the Unit

PreUUT typically prompts for a serial number and validates it, deciding whether to continue or skip.

In the TofuPilot Framework, identification is declared rather than coded:

procedure.yaml
unit:  serial_number:    default_value: "SN000001"  part_number:    default_value: "PCB-MAIN-V2"

The operator is prompted before the test starts. For a station that derives serials instead of asking, auto_identify removes the prompt:

procedure.yaml
unit:  auto_identify: true  serial_number:    default_value: "BURNIN-{slot}"  part_number:    default_value: "PCB-MAIN-V2"

When identification needs real logic — reading a serial from EEPROM, checking an MES for a work order, refusing a unit already tested — that becomes the first setup phase:

phases/identify.py
def identify(phase, unit, scanner, mes):    serial = scanner.read()    if not mes.is_released(serial):        phase.fail(f"{serial} has no released work order")        return    unit.serial_number = serial

A failing setup phase stops the run before any main phase executes, which is the behavior PreUUT returning False was giving you.

PostUUT: Acting on the Result

PostUUT runs after the test with the result available, typically to drive a pass/fail indicator, print a label, or route the unit.

This becomes a teardown phase. Teardown always runs, whether the test passed, failed, errored, or timed out:

procedure.yaml
teardown:  - name: Signal Result    python: phases.signal_result
phases/signal_result.py
def signal_result(unit, indicator, printer):    indicator.ready()    printer.print_label(unit.serial_number)

Two differences from PostUUT are worth knowing.

Teardown runs per slot. On a multi-socket station a slot's teardown can run while other slots are still testing, so anything touching shared hardware needs scope: execution to run once, after every slot is done.

Result-dependent branching is usually the wrong shape here. A PostUUT that formatted a report or wrote a database row has no equivalent, because the engine uploads the run itself. What remains is physical: light a lamp, print a label, release a fixture. When an action genuinely must differ on pass and fail, drive it from the phase that made the determination rather than reconstructing the verdict in teardown.

PreUUTLoop and PostUUTLoop: Station Lifetime

These run once around the whole testing session, usually to open instrument connections and close them at the end.

There is no phase equivalent, because this is not test logic. It is resource lifetime, and it belongs to the plug:

procedure.yaml
plugs:  - name: power_supply    python: plugs.psu:PowerSupply    scope: station    config:      address: "192.168.1.100"

A station-scoped plug is created on first use and held across executions, so back-to-back units do not pay the reconnection cost. Connection setup goes in __init__, cleanup in __del__:

plugs/psu.py
import pyvisaclass PowerSupply:    def __init__(self, address: str):        self._rm = pyvisa.ResourceManager()        self._inst = self._rm.open_resource(f"TCPIP::{address}::INSTR")    def read_voltage(self) -> float:        return float(self._inst.query("MEAS:VOLT?"))    def __del__(self):        self._inst.close()

The engine terminates the plug subprocess on deletion rather than relying on garbage collection, so cleanup is deterministic. It also health-checks a held plug process before each reuse and respawns it if it died — one of the failure modes a hand-written PreUUTLoop usually did not cover.

Note the scope boundary: a station plug is released when a new deployment is applied, so plug code changes always take effect on the first run after a deploy. In a one-shot tofupilot run, station behaves as execution.

TestReport and LogToDatabase: Do Not Port

These are the two callbacks teams invest the most in, and the two that should not survive the migration.

In a classic executive, results are your problem. You write a report callback to produce XML, a database callback to insert rows, and a schema to hold them. It is normal for this to be thousands of lines, and it is normal for it to be the least-loved code in the repository.

In the TofuPilot Framework the engine uploads the run: procedure, unit, phases, measurements, logs, attachments, outcome. Stations queue offline and drain on reconnect with the original timestamp.

The instinct to port a report callback anyway is worth resisting. If the goal was an XML file for a customer, that is a report generated from stored data, not a step inside the test. Keeping it in the test means every station needs the formatting code, every schema change is a redeploy, and a formatting bug fails a passing unit.

The honest tradeoff: you no longer control the storage format. If you have a genuine requirement to write a specific schema into a system of record, that is an export from the API, running once, off the station.

ProcessModelPostStep: Do Not Port

ProcessModelPostStep runs after every step, typically to log step results or accumulate statistics.

The engine already records every phase: name, outcome, duration, measurements with their validators, retry count. It arrives in the dashboard without a callback, and phase-level analysis like a pareto of failures is a view over that data.

Port this only if it did something genuinely unusual, such as toggling a watchdog between steps. That becomes ordinary code in the phases that need it, not a global hook.

Per-Step Hooks: depends_on and Setup

SequenceFilePreStep and similar per-step hooks are often used to enforce ordering or preconditions.

Ordering is declared directly:

procedure.yaml
main:  - name: Power On    python: phases.power_on    key: power_on  - name: Measure Rails    python: phases.measure_rails    depends_on: [power_on]

Phases without dependencies run in parallel on a worker pool; depends_on serializes what must be ordered. This replaces a hook that existed to check "did the previous step pass" with a statement of the actual dependency.

Preconditions that apply to everything go in setup, which must pass before any main phase runs. Preconditions for one phase go in the phase, using phase.skip():

phases/advanced_calibration.py
def advanced_calibration(phase, device):    if not device.supports_advanced_mode():        phase.skip()        return    device.calibrate()

Failure Behavior

Much callback code exists to control what happens after a failure. That is configuration:

procedure.yaml
execution:  on_first_failure: continue

The default stop cancels phases that have not started. continue runs them anyway, which is what you want when a full failure profile is more valuable than cycle time.

For intermittent failures, retry is per phase and explicit:

procedure.yaml
main:  - name: Network Connect    python: phases.network    retry:      limit: 5      delay: 1s

Retries do not happen automatically on failure. The phase must return a retry action, or then: must map an outcome to a retry. Every attempt is preserved and uploaded with a retry_count, so a unit that passed on the fourth attempt is visible as such rather than looking clean.

A Migration Order That Works

  1. Inventory the callbacks and mark each one. Most fall into: reporting or database (delete), lifecycle (becomes plug scope), or real logic (becomes a phase).
  2. Port the plugs first. Instrument connections are the foundation, and getting them right makes everything else testable.
  3. Port MainSequence to main phases, one measurement at a time, with validators in YAML rather than in the code.
  4. Move PreUUT into the unit: block, adding a setup phase only if identification has real logic.
  5. Move PostUUT into teardown, checking scope for anything touching shared hardware.
  6. Delete the reporting and database callbacks. Run both systems in parallel for a batch and compare stored results before removing the old path.
  7. Add slots last, once a single unit passes reliably.

Step 6 is the one that stalls. It is worth agreeing early with whoever owns the quality records what "the results are in the new system" means, because that conversation, not the code, is usually the critical path.

Key Points

  • Callbacks split into three groups: lifecycle, real logic, and result plumbing.
  • Result plumbing — reports, database writes, per-step logging — is not ported; the engine records and uploads runs.
  • Lifecycle callbacks become plug scope: station for connections held across runs, execution for shared resources, slot per unit.
  • Real logic becomes phases: setup for preconditions, teardown for post-test actions that always run.
  • Failure handling and ordering become configuration: on_first_failure, retry, and depends_on.

More Guides

Put this guide into practice