Skip to content
Migrating from Legacy Systems

OpenHTF vs NI TestStand for Manufacturing Test

Compare OpenHTF and NI TestStand for manufacturing test automation, with feature matrices, code examples, cost analysis, and database integration differences.

JJulien Buteau
intermediate10 min readMarch 14, 2026

OpenHTF is a free, open-source test framework from Google. NI TestStand is a commercial test sequencer from NI (Emerson) at $4,310/seat/year. Both run manufacturing tests with measurements, limits, and sequencing. They differ in cost, platform support, database integration, and how you manage test data. This guide compares them side by side with real code and concrete tradeoffs.

Feature Comparison

FeatureOpenHTFNI TestStand
LanguagePythonLabVIEW, C, .NET, Python
LicenseApache 2.0 (free)$4,310/seat/year
PlatformLinux, macOS, WindowsWindows only
Test editorAny code editorProprietary Sequence Editor
Version controlGit (plain .py files)Difficult (binary .seq files)
Structured measurementsBuilt-in (name, value, limits, units)Built-in (Numeric Limit, String Value)
Serial number inputBuilt-in promptBuilt-in (Process Model)
Parallel DUTLimitedNative
Instrument driversPyVISA, pyserial, nidaqmxNI VISA, IVI, NI drivers
Database loggingTofuPilot (1 line)Built-in (complex schema)
Analytics (FPY, Cpk)TofuPilot (automatic)Custom queries or third-party
CI/CD integrationNative (Python)Limited (added 2025 Q2)
Report generationTofuPilot (automatic)Built-in XML/HTML
CommunitySmall (~640 GitHub stars)Large (NI forums, training courses)
Learning curveMediumHigh

Cost Analysis

MetricOpenHTF + TofuPilotNI TestStand
5 seats, 1 year$0 (TofuPilot Lab is free)$21,550
20 seats, 1 year$0$86,200
Runtime deploymentFreeAdditional runtime licenses
TrainingSelf-taught (openhtf.com docs)NI courses ($2,000+)
Vendor lock-inNoneHigh (NI ecosystem)

TestStand also requires Windows, which means Windows licenses for every test station. OpenHTF runs on Linux, which is free and more stable for long-running production stations.

The Same Test in Both Frameworks

A simple functional test: measure a 3.3V rail, check it's within 3.2V to 3.4V.

OpenHTF + TofuPilot

openhtf_power_test.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),
)
def test_power_rail(test):
    voltage = 5.02  # Replace with instrument read
    test.measurements.rail_3v3 = voltage


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


if __name__ == "__main__":
    main()

Measurements are structured data: name, value, limits, units. TofuPilot integration is one with statement. The operator gets a serial number prompt automatically.

NI TestStand

In TestStand, you create a sequence file (.seq) in the Sequence Editor. Add a "Numeric Limit Test" step, set the test expression to your instrument read, configure Low Limit = 3.2 and High Limit = 3.4. Connect the step to a Code Module (LabVIEW VI, DLL, or .NET assembly) that talks to the instrument.

The test logic lives in the Code Module. The sequencing, limits, and reporting live in the .seq file. You can't see both in a single text file, and you can't diff the .seq file in Git.

Concept Mapping

Every TestStand concept has a direct OpenHTF equivalent.

TestStandOpenHTF
Sequence file (.seq)Python test script (.py)
Sequence Editor (GUI)Any code editor (VS Code, PyCharm)
StepPhase function
Numeric Limit Testhtf.Measurement("name").in_range(low, high)
String Value Testhtf.Measurement("name").equals("expected")
Pass/Fail Testhtf.Measurement("name").equals(True)
Code Module (DLL, VI)Plug class
FileGlobals / StationGlobalsPlug instance attributes
Process ModelTofuPilot integration
Setup / Cleanup groupsFirst/last phase functions, or PhaseGroups
UUT Serial Numbertest.execute(test_start=lambda: input("Scan: "))

Instrument Drivers

TestStand uses NI VISA and IVI drivers. OpenHTF uses PyVISA, which talks to the same instruments through the same VISA layer. If your instrument works with TestStand, it works with PyVISA.

TestStand DriverPython Equivalent
NI VISA / IVIPyVISA + 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

NI publishes official Python packages for most of their hardware. You don't lose instrument support by switching to Python.

OpenHTF Plug for an Instrument

TestStand wraps instruments in Code Modules. OpenHTF wraps them in Plugs, which have automatic lifecycle management (setUp/tearDown).

plugs/multimeter.py
import pyvisa
from openhtf.plugs import BasePlug
import openhtf as htf
from openhtf.util import units
from tofupilot.openhtf import TofuPilot


class MultimeterPlug(BasePlug):
    """Wraps a SCPI multimeter connection."""

    def setUp(self):
        rm = pyvisa.ResourceManager()
        self.instr = rm.open_resource("TCPIP::192.168.1.100::INSTR")
        self.instr.timeout = 5000

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

    def tearDown(self):
        self.instr.close()


@htf.measures(
    htf.Measurement("rail_3v3")
    .in_range(3.2, 3.4)
    .with_units(units.VOLT),
)
@htf.plug(dmm=MultimeterPlug)
def measure_power_rail(test, dmm):
    test.measurements.rail_3v3 = dmm.measure_voltage(channel=1)


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


if __name__ == "__main__":
    main()

Sharing Data Between Steps

TestStand uses FileGlobals and StationGlobals to pass data between steps. OpenHTF uses plug instance attributes. The plug persists for the entire test execution.

plugs/shared_state.py
from openhtf.plugs import BasePlug
import openhtf as htf
from openhtf.util import units


class SharedState(BasePlug):
    """Replaces TestStand FileGlobals and StationGlobals."""

    def setUp(self):
        self.cal_offset = 0.0
        self.firmware_version = ""

    def tearDown(self):
        pass


@htf.plug(state=SharedState)
def calibrate(test, state):
    state.cal_offset = 0.023


@htf.measures(
    htf.Measurement("corrected_voltage")
    .in_range(3.2, 3.4)
    .with_units(units.VOLT),
)
@htf.plug(state=SharedState)
def measure_corrected(test, state):
    raw = 3.323  # Replace with instrument read
    test.measurements.corrected_voltage = raw - state.cal_offset

Database and Test Data

This is where the two frameworks differ most.

TestStand Database Logging

TestStand's built-in database logger writes results to SQL Server, Oracle, or Access using a fixed schema. The core tables are UUT_RESULT, STEP_RESULT, and PROP_RESULT, with additional tables for each property type (PROP_NUMERICLIMIT, PROP_STRINGVALUE, etc.).

Querying this schema requires multi-level JOINs:

TableContainsJoins To
UUT_RESULTSerial number, overall pass/fail, timestampsTop-level
STEP_RESULTStep name, step statusUUT_RESULT.ID
PROP_RESULTProperty metadataSTEP_RESULT.ID
PROP_NUMERICLIMITNumeric limits (low, high)PROP_RESULT.ID
PROP_NUMERICMeasured numeric valuePROP_RESULT.ID
PROP_STRINGVALUEString comparison resultsPROP_RESULT.ID

A simple query to get one unit's measurements requires 5-6 table JOINs. Adding custom metadata means modifying the Process Model's database schema mapping, which is fragile and hard to maintain across TestStand versions.

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

OpenHTF + TofuPilot

OpenHTF doesn't have built-in database logging. TofuPilot handles it. You add one with TofuPilot(test): line and every run is stored with structured measurements, limits, units, serial numbers, and metadata.

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 time (unless you build replication)Multi-site from day one

No database to provision, no schema to maintain, no SQL to write. TofuPilot tracks everything automatically. Open the Analytics tab to see FPY, Cpk, and failure analysis for any procedure.

Version Control and Git

TestStand sequence files (.seq) are binary. You can store them in Git, but you can't diff them, review changes in a pull request, or merge branches.

NI added a Git pane in TestStand 2025 Q2. It lets you commit, pull, and push .seq files from within the Sequence Editor. But it's file-level tracking, not content-level diffing. You can see that a file changed, not what changed inside it. NI provides a separate "Diff and Merge Utility" that can compare two .seq files visually, but it doesn't integrate with pull request workflows.

OpenHTF tests are plain Python files. Standard Git workflows apply.

CapabilityTestStandOpenHTF
Commit filesYes (Git pane, 2025 Q2)Yes (any Git client)
Diff changesFile-level only (Diff Utility for visual compare)Line-by-line (git diff)
Pull request reviewNot possible (binary)Standard code review
Branch per featureFiles track, content doesn't mergeStandard
CI/CD lintingNot possibleflake8, mypy, ruff
Automated test of testsDifficultpytest on test logic
Blame historyNogit blame

What a Python Test Diff Looks Like

When you change a measurement limit in an OpenHTF test, the pull request shows exactly what changed:

test_power_rail.py
@htf.measures(
     htf.Measurement("rail_3v3")
-    .in_range(3.2, 3.4)
+    .in_range(3.1, 3.5)
     .with_units(units.VOLT),
 )

Reviewers see the old limit, the new limit, and the context. In TestStand, the same change is invisible inside a binary .seq file.

CI/CD Integration

NI updated TestStand's license agreement in 2025 Q2 to allow CI/CD usage without extra cost (if you have at least one active development license). But running TestStand in CI still requires a Windows runner with TestStand installed, and the sequence files can't be linted or statically analyzed.

OpenHTF tests are Python. They run in any CI system with a Python environment.

CI/CD CapabilityTestStandOpenHTF
Run in CIYes (2025 Q2, Windows runner required)Yes (any OS, any CI)
License for CIIncluded with 1+ dev license (2025 Q2)Free
Static analysisNot possible (.seq binary)flake8, mypy, ruff, pylint
Unit test on test logicDifficultpytest
Lint measurement namesNot possibleCustom rules
Docker runnerNo (Windows required)Yes
.github/workflows/test-lint.yml
# Lint and type-check OpenHTF test scripts in CI
name: Test Script CI
on: [push, pull_request]
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install ruff mypy openhtf
      - run: ruff check tests/
      - run: mypy tests/

You can catch measurement naming issues, import errors, and type problems before test scripts reach the production floor.

When to Use Each

ScenarioBetter Choice
New project, Python teamOpenHTF + TofuPilot
Existing NI hardware investment, large enterpriseTestStand (if it works, keep it)
Multi-OS test stations (Linux, macOS)OpenHTF (TestStand is Windows only)
Budget-conscious (startup, small team)OpenHTF + TofuPilot ($0)
Need FPY, Cpk, analytics out of the boxOpenHTF + TofuPilot
Deep NI PXI/CompactRIO integrationTestStand (tighter NI ecosystem)
CI/CD and Git workflows matterOpenHTF
Non-NI instruments (Keysight, Rigol, R&S)Either (both use VISA)

Migration Path

If you're on TestStand and considering a move, the typical migration takes 4-8 weeks per test procedure. Most teams run both systems in parallel during the transition.

  1. Set up Python + OpenHTF alongside TestStand on one station.
  2. Convert one test procedure (the simplest one). Run both versions on the same DUTs.
  3. Validate measurements match between systems.
  4. Move to the next procedure once results are confirmed.
  5. Decommission TestStand when all procedures are converted.

The biggest risk is rushing. Convert one procedure at a time. Parallel operation is your safety net.

More Guides

Put this guide into practice