Migrating from Legacy Systems

Migrate from NI TestStand to Python

Migrate from NI TestStand to Python: map sequences, step types, process models and callbacks, and validate the port before you cut over.

JJulien Buteau
intermediate12 min readMarch 14, 2026
Validate one test before a wider rollout.
Existing test
Connect results
Compare one run
Roll out

TestStand is a capable test executive. The reasons teams leave it are usually commercial and operational rather than technical: about $4,310 per seat per year, Windows-only, and binary .seq files that resist code review. This guide is the concrete migration: what maps to what, what the work actually costs, and how to validate the port before anything is decommissioned.

For the shorter survey of options, see TestStand alternatives.

Before You Start: Inventory What You Use

The size of the job is set almost entirely by your step modules.

What your steps callMigration effort
Python modulesMinimal; they port as-is
C/C++ DLLsSmall; call them with ctypes
.NET assembliesSmall to medium; pythonnet, or reimplement
LabVIEW VIs doing instrument workLarge; this is the real cost
ActiveX / COMMedium; often easier to reimplement

Also inventory the parts of TestStand you actually rely on. Most teams use sequences, limit steps and a stock operator interface. Fewer use callbacks in anger, and those are where the surprises live.

The Concept Map

TestStandTofuPilot Framework
Sequence file (.seq)procedure.yaml
MainSequencemain: list
StepEntry under main:
Step module (code)Phase function in phases/
Numeric Limit Testmeasurements with >= / <= validators
Multiple Numeric Limit TestSeveral measurements in one phase
String Value Testvalidators with operator: "=="
Pass/Fail TestBoolean measurement, == true
Action stepPhase with no measurements
Preconditionenable_if on the phase
Run mode: Skipenable_if: false
Loop / retry on failureretry on the phase
Sequential modelDefault execution
Parallel / Batch modelexecution.slots
UUT serial numberunit.serial_number
PreUUT / PostUUTsetup / teardown phases
Station globalsPlug state or run metadata
StationInfo, step resultsRun metadata and measurements
Database logging stepAutomatic upload
Report generationDashboard and exports

Porting a Sequence

A typical TestStand MainSequence: power up, three Numeric Limit Tests, a String Value Test, power down.

procedure.yaml
55 lines
name: PCBA Functional Testversion: 1.0.0description: Ported from FCT_PCBA100.seq rev Dunit:  serial_number:    default_value: "PCBA000001"  part_number:    default_value: "PCBA-100"plugs:  - name: psu    python: plugs.psu:PowerSupply  - name: dmm    python: plugs.dmm:Multimeter  - name: dut    python: plugs.dut:Dutmain:  - name: Power Up    python: phases.power_up  - name: Rail Measurements    python: phases.rail_measurements    depends_on: [power_up]    measurements:      - name: Rail 3V3        unit: V        description: FCT_PCBA100.seq step 3        validators:          - operator: ">="            expected_value: 3.2          - operator: "<="            expected_value: 3.4      - name: Rail 5V0        unit: V        validators:          - operator: ">="            expected_value: 4.8          - operator: "<="            expected_value: 5.2      - name: Quiescent Current        unit: mA        validators:          - operator: "<="            expected_value: 45.0  - name: Firmware Version    python: phases.firmware_version    depends_on: [rail_measurements]    measurements:      - name: Firmware Version        validators:          - operator: "=="            expected_value: "2.1.0"
phases/power_up.py
def power_up(psu):    """Action step: no measurements, just side effects."""    psu.configure(channel=1, volts=5.0, current_limit=0.5)    psu.output(1, True)
phases/rail_measurements.py
def rail_measurements(measurements, dmm):    """Three Numeric Limit Tests become three measurements in one phase."""    measurements.rail_3v3 = dmm.dc_voltage()    measurements.rail_5v0 = dmm.dc_voltage()    measurements.quiescent_current = dmm.dc_current_ma()
phases/firmware_version.py
def firmware_version(measurements, dut):    measurements.firmware_version = dut.query_firmware()

Keeping the original step reference in description is worth the small effort. During validation you will be comparing against the TestStand report line by line, and it makes that mechanical.

Power-down belongs in the plug's close(), not a final phase, so it happens even when a phase raises:

plugs/psu.py
class PowerSupply:    def configure(self, channel: int, volts: float, current_limit: float):        ...    def output(self, channel: int, on: bool):        ...    def close(self):        """Runs at teardown whatever happened during the run."""        for ch in (1, 2, 3):            self.output(ch, False)

Porting the Process Models

Sequential is the default. Nothing to do.

Parallel and Batch become fixture slots:

procedure.yaml
execution:  slots:    - name: Slot 1    - name: Slot 2    - name: Slot 3    - name: Slot 4

Each slot runs independently with its own unit, measurements and outcome, and a failure in one slot leaves the others running. For a fixture with many identical positions, a count works instead of a list:

procedure.yaml
execution:  slots: 16

Porting the Callbacks

PreUUT and PostUUT become setup and teardown phases. The distinction that matters, and that TestStand also forces you to think about, is scope:

  • Slot-scoped teardown touches that slot only and may run while neighbouring slots are still testing.
  • Execution-scoped teardown runs once after every slot is finished, and is where a shared rack supply, a fixture lock or a chamber belongs.

The classic multi-slot bug is a slot-scoped teardown switching off a shared supply while another slot is mid-measurement. See replace TestStand callbacks in Python.

Calling Existing Code

You rarely have to rewrite working instrument code immediately.

C/C++ DLL:

plugs/legacy_dll.py
import ctypesclass LegacyInstrument:    def __init__(self):        self._lib = ctypes.CDLL("./vendor/instrument.dll")        self._lib.read_voltage.restype = ctypes.c_double    def read_voltage(self) -> float:        return float(self._lib.read_voltage())

LabVIEW VI, via a built executable or the LabVIEW Python API, wrapped the same way. This is the step worth measuring honestly: if most of your instrument logic is in VIs, that is the migration, and the sequencing part is the easy half.

Validate Before You Cut Over

Non-negotiable, and cheap:

  1. Pick 20-30 units including known-bad ones.
  2. Run TestStand and the new procedure on each.
  3. Compare measurement by measurement, not just pass/fail.
  4. Investigate every disagreement before decommissioning anything.

A limit transcribed with the wrong sign or a factor of ten is the failure mode that ships bad boards, and it is invisible if you only compare verdicts.

Keep measurement names stable from day one of the port. They are the identity of the data across every run you will ever take.

What It Costs

Work itemEffort
Port step modulesSmall if C/Python, large if LabVIEW
Re-express sequences as proceduresSmall, mostly mechanical
Rebuild the operator interfaceSmall; declared in the procedure
Replace database loggingSmall; upload replaces the schema
Parallel validationMedium, and mandatory
RetrainingSmall; Python is a smaller step than TestStand was

When Not to Migrate

  • Deep NI hardware integration. PXI chassis, switching, FPGA targets and NI-specific drivers you would have to rewrite.
  • Large working sequence library, no new stations. If it works and is not growing, the cost may not be justified.
  • Heavy LabVIEW step modules with no budget to port them.

If You Prefer Decorators

OpenHTF is the other purpose-built Python option, with sequencing and limits expressed in Python decorators rather than YAML:

fct.py
import openhtf as htffrom openhtf.util import unitsfrom tofupilot.openhtf import upload@htf.measures(    htf.Measurement("rail_3v3").in_range(3.2, 3.4).with_units(units.VOLT),)def test_rail(test):    test.measurements.rail_3v3 = 3.31test = htf.Test(test_rail, part_number="PCBA-100")test.add_output_callbacks(upload())test.execute(test_start=lambda: input("Scan serial: "))

Install with pip install "tofupilot[openhtf]". Both run on the same CLI and upload to the same dashboard. See TofuPilot Framework vs OpenHTF.

Common Migration Mistakes

MistakeConsequenceFix
Porting sequences before step modulesNothing runs end to endPort and test modules first
Comparing pass/fail only during validationA wrong limit survives the portCompare measurement values
Recreating TestStand structure exactlyYou inherit workarounds you no longer needPort the intent
Power-down as a final phaseBoard stays energised when a phase raisesPut it in the plug's close()
Slot-scoped teardown on shared equipmentIntermittent multi-slot failuresExecution scope for shared resources
Big-bang cutoverOne risky dayOne station, one product, then expand

More Guides

Put this guide into practice