Skip to content
Migrating from Legacy Systems

Migrate from NI TestStand to Python

A practical guide to replacing NI TestStand with Python and OpenHTF for manufacturing test, with step-by-step migration patterns and TofuPilot integration.

JJulien Buteau
intermediate12 min readMarch 14, 2026

NI TestStand costs $4,310/seat/year, locks you into Windows, and requires specialized engineers to maintain. Python with OpenHTF gives you the same test sequencing capabilities with open-source tooling, cross-platform support, and version control. TofuPilot replaces TestStand's database logging and Process Model with structured analytics out of the box.

Why Teams Migrate

The decision usually comes down to three factors:

FactorTestStandPython + OpenHTF
License cost$4,310/seat/yearFree
PlatformWindows onlyWindows, Linux, macOS
Version controlBinary .seq files, hard to diffPlain .py files, full Git support
CI/CDCustom integrations neededNative Python tooling
HiringRequires TestStand-trained engineersAny Python developer
Test editorProprietary GUIAny code editor
Instrument driversNI VISA + IVIPyVISA (same instruments)
Data managementComplex DB schema + custom queriesTofuPilot (built-in analytics)

The migration doesn't have to happen all at once. Most teams run both systems in parallel during the transition, converting one test procedure at a time.

TestStand Concepts in Python

Every TestStand concept has a direct Python equivalent. This mapping covers the core building blocks.

Sequences and Steps

In TestStand, you build a sequence of steps in the sequence editor. In OpenHTF, you define phases as Python functions and pass them to a Test object.

TestStand:

MainSequence ├── Setup (Precondition group) ├── PowerOnSelfTest (step) ├── FunctionalTest (step) └── Cleanup (Postcondition group)

Python with OpenHTF:

migration/sequence_mapping.py
import openhtf as htf
from openhtf.util import units
from tofupilot.openhtf import TofuPilot


def setup(test):
    """Equivalent to TestStand Setup group."""
    pass  # Initialize fixture, instruments, etc.


@htf.measures(
    htf.Measurement("post_voltage")
    .in_range(4.8, 5.2)
    .with_units(units.VOLT),
)
def power_on_self_test(test):
    """Equivalent to a TestStand NumericLimitTest step."""
    voltage = 5.01  # Replace with instrument read
    test.measurements.post_voltage = voltage


@htf.measures(
    htf.Measurement("firmware_crc")
    .equals("0xA3F7B2C1"),
)
def functional_test(test):
    """Equivalent to a TestStand StringValueTest step."""
    crc = "0xA3F7B2C1"  # Replace with DUT query
    test.measurements.firmware_crc = crc


def cleanup(test):
    """Equivalent to TestStand Cleanup group."""
    pass  # Disable outputs, close connections


def main():
    test = htf.Test(
        setup,
        power_on_self_test,
        functional_test,
        cleanup,
        procedure_id="FCT-001",
        part_number="PCBA-100",
    )
    with TofuPilot(test):
        test.execute(test_start=lambda: input("Scan serial number: "))


if __name__ == "__main__":
    main()

Step Types

TestStand has built-in step types: NumericLimitTest, StringValueTest, PassFailTest. OpenHTF uses Measurement objects with validators.

TestStand Step TypeOpenHTF Equivalent
NumericLimitTestMeasurement("name").in_range(low, high).with_units(unit)
StringValueTestMeasurement("name").equals("expected")
PassFailTestMeasurement("name").equals(True)
MultipleNumericLimitTestMultiple Measurement objects on the same phase
NI Switch / ActionPlain Python function (no measurement decorator)

Sharing Data Between Phases

TestStand uses FileGlobals, StationGlobals, and Locals to pass data between steps. In OpenHTF, use plugs with instance attributes to share data across phases. The plug persists for the entire test execution.

migration/shared_data.py
from openhtf.plugs import BasePlug
import openhtf as htf


class CalibrationPlug(BasePlug):
    """Stores calibration data shared between phases."""

    def setUp(self):
        self.offset = 0.0

    def tearDown(self):
        pass


@htf.plug(cal=CalibrationPlug)
def phase_one(test, cal):
    """Store calibration data for later phases."""
    cal.offset = 0.023


@htf.plug(cal=CalibrationPlug)
def phase_two(test, cal):
    """Use calibration data from a previous phase."""
    raw_reading = 3.31  # From instrument
    corrected = raw_reading - cal.offset

Code Modules

TestStand uses Code Modules (DLLs, .NET assemblies, LabVIEW VIs) to interface with instruments and DUTs. OpenHTF uses Plugs, which are Python classes with automatic lifecycle management.

migration/plug_mapping.py
import pyvisa
from openhtf.plugs import BasePlug


class MultimeterPlug(BasePlug):
    """Equivalent to a TestStand Code Module wrapping an instrument driver."""

    def setUp(self):
        """Called once before the test. Like TestStand's Setup entry point."""
        rm = pyvisa.ResourceManager()
        self.instr = rm.open_resource("TCPIP::192.168.1.100::INSTR")
        self.instr.timeout = 5000

    def measure_voltage(self, channel=1):
        """Query the multimeter for a DC voltage reading."""
        self.instr.write(f":CONF:VOLT:DC AUTO,(@{channel})")
        self.instr.write(":INIT")
        return float(self.instr.query(":FETCH?"))

    def tearDown(self):
        """Called after the test. Like TestStand's Cleanup entry point."""
        self.instr.close()

Replace TestStand Database Logging

TestStand's built-in database logger writes to SQL Server, Oracle, or Access using a fixed schema. The core tables (UUT_RESULT, STEP_RESULT, PROP_RESULT, PROP_NUMERICLIMIT, PROP_NUMERIC) require 5-6 JOINs for a simple measurement query.

The TestStand Database Schema Problem

A typical query to get one unit's test results in TestStand's database:

teststand_query.sql
-- 6-table JOIN to get measurements for one serial number
SELECT
    u.UUT_SERIAL_NUMBER,
    u.UUT_STATUS,
    s.STEP_NAME,
    s.STATUS,
    n.DATA AS measured_value,
    nl.LOW AS lower_limit,
    nl.HIGH AS upper_limit,
    nl.UNITS
FROM UUT_RESULT u
JOIN STEP_RESULT s ON s.UUT_RESULT = u.ID
JOIN PROP_RESULT p ON p.STEP_RESULT = s.ID
JOIN PROP_NUMERICLIMIT nl ON nl.PROP_RESULT = p.ID
JOIN PROP_NUMERIC n ON n.PROP_RESULT = p.ID
WHERE u.UUT_SERIAL_NUMBER = 'SN-5001'
ORDER BY u.START_DATE_TIME DESC

This schema is rigid. Adding custom metadata (firmware version, station ID, operator) means modifying the Process Model's database mapping, which is fragile across TestStand upgrades.

TestStand doesn't include analytics. FPY trends, Cpk, control charts, and failure Pareto all require custom SQL or a third-party tool.

TofuPilot Replaces All of This

With OpenHTF + TofuPilot, you don't manage a database. Measurements flow directly from your test code:

migration/tofupilot_replacement.py
import openhtf as htf
from openhtf.util import units
from tofupilot.openhtf import TofuPilot


@htf.measures(
    htf.Measurement("rail_3v3")
    .in_range(3.2, 3.4)
    .with_units(units.VOLT),
    htf.Measurement("current_draw")
    .in_range(0.1, 0.5)
    .with_units(units.AMPERE),
)
def power_test(test):
    test.measurements.rail_3v3 = 3.31
    test.measurements.current_draw = 0.25


def main():
    test = htf.Test(power_test, procedure_id="FCT-001", part_number="PCBA-100")
    with TofuPilot(test):
        test.execute(test_start=lambda: input("Scan serial: "))


if __name__ == "__main__":
    main()

No database connection code. No SQL. No schema maintenance.

TestStand DatabaseTofuPilot
6-table JOIN for one unit's resultsSearch by serial number, get full history
Custom SQL for FPYFPY trends updated in real time
No Cpk without custom codeCpk per measurement, automatic
No control chartsControl charts with UCL/LCL
No failure ParetoFailure Pareto with drill-down
Schema locked to NI's designStructured data, REST API access
SQL Server/Oracle/AccessCloud or self-hosted
One site at a timeMulti-site from day one

TofuPilot tracks FPY, Cpk, throughput, and failure analysis automatically. Open the Analytics tab to see trends for any procedure.

Import Historical TestStand Data

You have years of test data in TestStand's database. TofuPilot's Python SDK lets you import it:

migration/import_teststand_history.py
import pyodbc
from tofupilot import TofuPilotClient

client = TofuPilotClient()

conn = pyodbc.connect(
    "DRIVER={SQL Server};"
    "SERVER=teststand-db;"
    "DATABASE=TestStandResults;"
)
cur = conn.cursor()

cur.execute("""
    SELECT
        u.ID,
        u.UUT_SERIAL_NUMBER,
        u.UUT_STATUS,
        u.START_DATE_TIME,
        u.EXECUTION_TIME,
        s.STEP_NAME,
        s.STATUS,
        n.DATA,
        nl.LOW,
        nl.HIGH,
        nl.UNITS
    FROM UUT_RESULT u
    JOIN STEP_RESULT s ON s.UUT_RESULT = u.ID
    LEFT JOIN PROP_RESULT p ON p.STEP_RESULT = s.ID
    LEFT JOIN PROP_NUMERICLIMIT nl ON nl.PROP_RESULT = p.ID
    LEFT JOIN PROP_NUMERIC n ON n.PROP_RESULT = p.ID
    ORDER BY u.START_DATE_TIME
""")

current_uut_id = None
steps = []

for row in cur:
    uut_id, serial, status, started, duration, step_name, step_status, value, low, high, unit = row

    if current_uut_id and current_uut_id != uut_id:
        client.create_run(
            procedure_id="FCT-001",
            unit_under_test={"serial_number": prev_serial},
            run_passed=(prev_status == "Passed"),
            started_at=prev_started.isoformat(),
            duration=prev_duration,
            steps=steps,
        )
        steps = []

    current_uut_id = uut_id
    prev_serial = serial
    prev_status = status
    prev_started = started
    prev_duration = duration

    step = next((s for s in steps if s["name"] == step_name), None)
    if not step:
        step = {"name": step_name, "step_passed": step_status == "Passed", "measurements": []}
        steps.append(step)

    if value is not None:
        step["measurements"].append({
            "name": step_name,
            "measured_value": value,
            "unit": unit or "",
            "lower_limit": low,
            "upper_limit": high,
        })

conn.close()

Adapt the connection string and procedure_id to match your setup. Run this once to backfill your TofuPilot workspace with historical trends.

Process Models

TestStand's Process Model handles serial number input, report generation, and database logging. With TofuPilot, you get all three:

  • Serial number input via test.execute(test_start=lambda: input("Scan: ")) or custom UI
  • Report generation is automatic (every run gets a detailed page in TofuPilot)
  • Database logging happens on every run (measurements, limits, pass/fail, attachments)
  • Analytics (FPY, Cpk, control charts) are computed automatically from your data

No custom output callbacks or database connectors needed.

Migration Strategy

Phase 1: Parallel Operation (Week 1-2)

Keep TestStand running. Set up a Python environment alongside it. Convert one simple test procedure (the easiest one, fewest instruments). Run both versions on the same DUTs to validate results match.

Test Station ├── TestStand (existing tests) └── Python + OpenHTF (new test, same DUT) └── TofuPilot (data logging)

Phase 2: Instrument Drivers (Week 2-4)

Convert TestStand Code Modules to Python plugs. Most NI instruments work with PyVISA (same VISA layer TestStand uses). Third-party instruments with SCPI support work out of the box.

TestStand DriverPython Equivalent
NI VISA / IVIPyVISA + pyvisa-py or NI-VISA backend
NI DAQmxnidaqmx (official NI Python package)
NI Switchniswitch (official NI Python package)
NI DMMnidmm (official NI Python package)
Serial / UARTpyserial
Custom DLLctypes or cffi

Phase 3: Full Conversion (Week 4-8)

Convert remaining test procedures one at a time. Start with the highest-volume procedures (biggest impact on throughput). Keep TestStand as fallback until each procedure is validated.

Phase 4: Decommission (Week 8+)

Once all procedures are running in Python and validated against TestStand results, decommission TestStand. Cancel the license renewals. Archive the .seq files.

What You Gain

After migration, your test infrastructure looks different:

  • Test scripts in Git. Full diff history, code review on test changes, branching for new product variants.
  • CI/CD for tests. Run linting, type checking, and unit tests on test scripts before deploying to stations.
  • Any OS. Test stations can run Linux (cheaper, more stable for long-running production).
  • Any editor. VS Code, PyCharm, vim. No proprietary IDE.
  • Analytics from day one. TofuPilot gives you FPY, Cpk, control charts, and failure Pareto without building custom database integrations.
  • Half the hiring pool opens up. Any Python developer can contribute to test development.

Common Pitfalls

Don't convert everything at once

The biggest migration risk is trying to convert all procedures simultaneously. Convert one, validate it, move to the next. Parallel operation is your safety net.

Don't skip instrument validation

PyVISA talks to the same instruments, but timing and trigger behavior can differ from NI VISA drivers. Validate measurements match between the old and new systems on the same DUT. A 0.1% measurement difference matters when your limits are tight.

Don't lose your test data history

Export historical data from TestStand's database before decommissioning. Use the import script above to backfill TofuPilot so you maintain traceability and trend analysis across the migration boundary.

More Guides

Put this guide into practice