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
| Feature | OpenHTF | NI TestStand |
|---|---|---|
| Language | Python | LabVIEW, C, .NET, Python |
| License | Apache 2.0 (free) | $4,310/seat/year |
| Platform | Linux, macOS, Windows | Windows only |
| Test editor | Any code editor | Proprietary Sequence Editor |
| Version control | Git (plain .py files) | Difficult (binary .seq files) |
| Structured measurements | Built-in (name, value, limits, units) | Built-in (Numeric Limit, String Value) |
| Serial number input | Built-in prompt | Built-in (Process Model) |
| Parallel DUT | Limited | Native |
| Instrument drivers | PyVISA, pyserial, nidaqmx | NI VISA, IVI, NI drivers |
| Database logging | TofuPilot (1 line) | Built-in (complex schema) |
| Analytics (FPY, Cpk) | TofuPilot (automatic) | Custom queries or third-party |
| CI/CD integration | Native (Python) | Limited (added 2025 Q2) |
| Report generation | TofuPilot (automatic) | Built-in XML/HTML |
| Community | Small (~640 GitHub stars) | Large (NI forums, training courses) |
| Learning curve | Medium | High |
Cost Analysis
| Metric | OpenHTF + TofuPilot | NI TestStand |
|---|---|---|
| 5 seats, 1 year | $0 (TofuPilot Lab is free) | $21,550 |
| 20 seats, 1 year | $0 | $86,200 |
| Runtime deployment | Free | Additional runtime licenses |
| Training | Self-taught (openhtf.com docs) | NI courses ($2,000+) |
| Vendor lock-in | None | High (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
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.
| TestStand | OpenHTF |
|---|---|
| Sequence file (.seq) | Python test script (.py) |
| Sequence Editor (GUI) | Any code editor (VS Code, PyCharm) |
| Step | Phase function |
| Numeric Limit Test | htf.Measurement("name").in_range(low, high) |
| String Value Test | htf.Measurement("name").equals("expected") |
| Pass/Fail Test | htf.Measurement("name").equals(True) |
| Code Module (DLL, VI) | Plug class |
| FileGlobals / StationGlobals | Plug instance attributes |
| Process Model | TofuPilot integration |
| Setup / Cleanup groups | First/last phase functions, or PhaseGroups |
| UUT Serial Number | test.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 Driver | Python Equivalent |
|---|---|
| NI VISA / IVI | PyVISA + NI-VISA backend |
| NI DAQmx | nidaqmx (official NI Python package) |
| NI Switch | niswitch (official NI Python package) |
| NI DMM | nidmm (official NI Python package) |
| Serial / UART | pyserial |
| Custom DLL | ctypes 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).
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.
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_offsetDatabase 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:
| Table | Contains | Joins To |
|---|---|---|
UUT_RESULT | Serial number, overall pass/fail, timestamps | Top-level |
STEP_RESULT | Step name, step status | UUT_RESULT.ID |
PROP_RESULT | Property metadata | STEP_RESULT.ID |
PROP_NUMERICLIMIT | Numeric limits (low, high) | PROP_RESULT.ID |
PROP_NUMERIC | Measured numeric value | PROP_RESULT.ID |
PROP_STRINGVALUE | String comparison results | PROP_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 Database | TofuPilot |
|---|---|
| 6-table JOIN for one unit's results | Search by serial number, get full history |
| Custom SQL for FPY | FPY trends, updated in real time |
| No Cpk without custom code | Cpk per measurement, automatic |
| No control charts | Control charts with UCL/LCL |
| No failure Pareto | Failure Pareto with drill-down |
| Schema locked to NI's design | Structured data, REST API access |
| SQL Server/Oracle/Access | Cloud 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.
| Capability | TestStand | OpenHTF |
|---|---|---|
| Commit files | Yes (Git pane, 2025 Q2) | Yes (any Git client) |
| Diff changes | File-level only (Diff Utility for visual compare) | Line-by-line (git diff) |
| Pull request review | Not possible (binary) | Standard code review |
| Branch per feature | Files track, content doesn't merge | Standard |
| CI/CD linting | Not possible | flake8, mypy, ruff |
| Automated test of tests | Difficult | pytest on test logic |
| Blame history | No | git 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:
@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 Capability | TestStand | OpenHTF |
|---|---|---|
| Run in CI | Yes (2025 Q2, Windows runner required) | Yes (any OS, any CI) |
| License for CI | Included with 1+ dev license (2025 Q2) | Free |
| Static analysis | Not possible (.seq binary) | flake8, mypy, ruff, pylint |
| Unit test on test logic | Difficult | pytest |
| Lint measurement names | Not possible | Custom rules |
| Docker runner | No (Windows required) | Yes |
# 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
| Scenario | Better Choice |
|---|---|
| New project, Python team | OpenHTF + TofuPilot |
| Existing NI hardware investment, large enterprise | TestStand (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 box | OpenHTF + TofuPilot |
| Deep NI PXI/CompactRIO integration | TestStand (tighter NI ecosystem) |
| CI/CD and Git workflows matter | OpenHTF |
| 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.
- Set up Python + OpenHTF alongside TestStand on one station.
- Convert one test procedure (the simplest one). Run both versions on the same DUTs.
- Validate measurements match between systems.
- Move to the next procedure once results are confirmed.
- Decommission TestStand when all procedures are converted.
The biggest risk is rushing. Convert one procedure at a time. Parallel operation is your safety net.