Skip to content
Test Station Setup

How to Configure Test Environments

Learn how to manage separate test environments with environment variables, config files, and TofuPilot workspace separation.

JJulien Buteau
intermediate7 min readMarch 14, 2026

Your test code shouldn't change between environments. The same script that runs on your dev bench needs to run in QA validation and on the production floor. What changes is the configuration: instrument addresses, measurement limits, TofuPilot workspace, logging levels.

This guide shows three approaches to environment-specific config and how to point each environment at its own TofuPilot workspace.

The Problem

A single test script typically needs different values for:

  • Instrument addresses. Your dev bench DMM is at TCPIP::192.168.1.10 but production uses GPIB0::22.
  • Measurement limits. Dev uses wider tolerances for debugging. Production uses datasheet limits.
  • TofuPilot workspace. Dev runs shouldn't pollute production data.
  • Logging verbosity. Debug logging in dev, errors only in production.
  • Fixture IDs. Each station has its own fixture with unique calibration data.

Hardcoding any of these means editing code for every deployment. That's how bugs reach the production floor.

Approach 1: Environment Variables with python-dotenv

The simplest approach. Create a .env file per environment and load it at startup.

.env.dev
TOFUPILOT_API_KEY=tp_dev_abc123DMM_ADDRESS=TCPIP::192.168.1.10::INSTRPSU_ADDRESS=TCPIP::192.168.1.11::INSTRVOLTAGE_MIN=3.0VOLTAGE_MAX=3.6LOG_LEVEL=DEBUGSTATION_ID=DEV-BENCH-01
.env.production
TOFUPILOT_API_KEY=tp_prod_xyz789DMM_ADDRESS=GPIB0::22::INSTRPSU_ADDRESS=GPIB0::5::INSTRVOLTAGE_MIN=3.2VOLTAGE_MAX=3.4LOG_LEVEL=ERRORSTATION_ID=PROD-LINE1-STN04

Load the right file based on an environment flag:

config.py
import osfrom dotenv import load_dotenv# Set TEST_ENV=dev|qa|production before runningenv = os.getenv("TEST_ENV", "dev")load_dotenv(f".env.{env}")class Config:    TOFUPILOT_API_KEY = os.getenv("TOFUPILOT_API_KEY")    DMM_ADDRESS = os.getenv("DMM_ADDRESS")    PSU_ADDRESS = os.getenv("PSU_ADDRESS")    VOLTAGE_MIN = float(os.getenv("VOLTAGE_MIN", "3.2"))    VOLTAGE_MAX = float(os.getenv("VOLTAGE_MAX", "3.4"))    LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")    STATION_ID = os.getenv("STATION_ID", "UNKNOWN")

Use it in your test:

test_power.py
import openhtf as htffrom tofupilot.openhtf import TofuPilotfrom config import Config@htf.measures(    htf.Measurement("voltage_3v3").in_range(Config.VOLTAGE_MIN, Config.VOLTAGE_MAX),)def test_power_rail(test):    # Use Config.DMM_ADDRESS to connect to the right instrument    test.measurements.voltage_3v3 = 3.29def main():    test = htf.Test(test_power_rail)    with TofuPilot(test):        test.execute(test_start=lambda: "DUT-001")if __name__ == "__main__":    main()

Run with: TEST_ENV=production python test_power.py

Approach 2: YAML Config Files

For more complex configurations with nested structures, YAML is cleaner than flat env vars.

config/production.yaml
instruments:  dmm:    address: "GPIB0::22::INSTR"    timeout_ms: 5000  psu:    address: "GPIB0::5::INSTR"    timeout_ms: 3000limits:  voltage_3v3:    min: 3.2    max: 3.4  current_idle:    max: 0.050station:  id: "PROD-LINE1-STN04"  log_level: "ERROR"
config/dev.yaml
instruments:  dmm:    address: "TCPIP::192.168.1.10::INSTR"    timeout_ms: 10000  psu:    address: "TCPIP::192.168.1.11::INSTR"    timeout_ms: 10000limits:  voltage_3v3:    min: 3.0    max: 3.6  current_idle:    max: 0.100station:  id: "DEV-BENCH-01"  log_level: "DEBUG"

Load with a simple helper:

config_loader.py
import osimport yamlfrom pathlib import Pathdef load_config(env: str = None) -> dict:    """Load YAML config for the given environment."""    env = env or os.getenv("TEST_ENV", "dev")    config_path = Path(__file__).parent / "config" / f"{env}.yaml"    if not config_path.exists():        raise FileNotFoundError(f"No config file for environment: {env}")    with open(config_path) as f:        return yaml.safe_load(f)
test_with_yaml.py
import openhtf as htffrom tofupilot.openhtf import TofuPilotfrom config_loader import load_configcfg = load_config()limits = cfg["limits"]["voltage_3v3"]@htf.measures(    htf.Measurement("voltage_3v3").in_range(limits["min"], limits["max"]),)def test_power_rail(test):    test.measurements.voltage_3v3 = 3.29def main():    test = htf.Test(test_power_rail)    with TofuPilot(test):        test.execute(test_start=lambda: "DUT-001")if __name__ == "__main__":    main()

Point to Different TofuPilot Workspaces

Each environment should write to its own TofuPilot workspace. This keeps dev noise out of production data.

Set the TOFUPILOT_API_KEY environment variable per environment. Each API key is scoped to a workspace. No code changes needed.

run_test.sh
# Devexport TOFUPILOT_API_KEY="tp_dev_abc123"python test_power.py# QAexport TOFUPILOT_API_KEY="tp_qa_def456"python test_power.py# Productionexport TOFUPILOT_API_KEY="tp_prod_xyz789"python test_power.py

The TofuPilot client reads TOFUPILOT_API_KEY from the environment automatically. You don't need to pass it in code.

Comparison: Env Vars vs Config Files vs CLI Args

ApproachBest forProsCons
Environment variablesSimple configs, CI/CD, secretsNo files to deploy, works with Docker/systemd, secret-friendlyFlat structure only, no nesting
YAML config filesComplex configs, nested structuresReadable, supports comments, version-controllableNeed to deploy files, secrets in plaintext
CLI argumentsOne-off overrides, debuggingNo files needed, easy to testVerbose for many params, not persistent
Combined (env + YAML)Production deploymentsSecrets in env, structure in YAMLTwo systems to maintain

The pragmatic approach: use YAML for instrument addresses and limits (things that vary by station), and environment variables for secrets (API keys, credentials). CLI args for quick overrides during debugging.

More Guides

Put this guide into practice