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_abc123
DMM_ADDRESS=TCPIP::192.168.1.10::INSTR
PSU_ADDRESS=TCPIP::192.168.1.11::INSTR
VOLTAGE_MIN=3.0
VOLTAGE_MAX=3.6
LOG_LEVEL=DEBUG
STATION_ID=DEV-BENCH-01
.env.production
TOFUPILOT_API_KEY=tp_prod_xyz789
DMM_ADDRESS=GPIB0::22::INSTR
PSU_ADDRESS=GPIB0::5::INSTR
VOLTAGE_MIN=3.2
VOLTAGE_MAX=3.4
LOG_LEVEL=ERROR
STATION_ID=PROD-LINE1-STN04

Load the right file based on an environment flag:

config.py
import os
from dotenv import load_dotenv

# Set TEST_ENV=dev|qa|production before running
env = 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 htf
from tofupilot.openhtf import TofuPilot
from 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.29

def 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: 3000

limits:
  voltage_3v3:
    min: 3.2
    max: 3.4
  current_idle:
    max: 0.050

station:
  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: 10000

limits:
  voltage_3v3:
    min: 3.0
    max: 3.6
  current_idle:
    max: 0.100

station:
  id: "DEV-BENCH-01"
  log_level: "DEBUG"

Load with a simple helper:

config_loader.py
import os
import yaml
from pathlib import Path

def 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 htf
from tofupilot.openhtf import TofuPilot
from config_loader import load_config

cfg = 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.29

def 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
# Dev
export TOFUPILOT_API_KEY="tp_dev_abc123"
python test_power.py

# QA
export TOFUPILOT_API_KEY="tp_qa_def456"
python test_power.py

# Production
export 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