Test Types & Methods

HIL vs SIL vs MIL Testing: When to Use Each

Compare MIL, SIL and HIL testing on cost, speed and fidelity, with Python examples where only the plug changes between the three levels.

JJulien Buteau
intermediate8 min readMarch 14, 2026

MIL, SIL, and HIL are three levels of hardware-in-the-loop testing that trade cost and speed for fidelity. Use MIL during algorithm development, SIL to validate compiled code, and HIL to verify behavior against real hardware before production.

What MIL, SIL, and HIL Mean

Model-in-the-Loop (MIL) runs your control algorithm as a simulation model against a simulated plant. No hardware involved. Fast iteration, low cost, but lowest fidelity.

Software-in-the-Loop (SIL) compiles your production code and runs it on a host machine against a simulated environment. Same binary, no hardware. Catches code generation bugs MIL misses.

Hardware-in-the-Loop (HIL) runs your production code on the target ECU or microcontroller, connected to a real-time simulator that emulates the physical environment. Highest fidelity, highest cost.

Comparison Matrix

DimensionMILSILHIL
CostLowLow-mediumHigh
SpeedFast (seconds)Medium (minutes)Slow (minutes-hours)
FidelityLowMediumHigh
Hardware requiredNoneNoneTarget ECU + simulator rack
Typical useAlgorithm designCode verificationSystem validation
CI-friendlyYesYesNo (dedicated bench)
Bug discoveryDesign errorsCodegen errorsIntegration, timing

When to Use Each

MIL: Early Algorithm Development

Use MIL when the algorithm is still changing, you need rapid iteration, hardware is not yet available, or you're running parametric sweeps. MIL is wrong when you need to validate timing behavior, interrupt latency, or bus communication.

SIL: Code Verification Before Hardware

Use SIL when code generation is complete, you want to catch regressions in CI, you're validating numerical equivalence between model and generated code, or hardware bench slots are scarce. SIL misses real-time timing faults and hardware-specific peripheral behavior.

HIL: Pre-Production System Validation

Use HIL when the ECU firmware is frozen, you need to validate timing and bus behavior, certification requires hardware evidence, or you're running overnight regression suites. HIL is expensive to set up and slow to iterate.

Example: Voltage Limit Test at Each Level

The same test logic runs at all three levels. Only the plug changes.

With the TofuPilot Framework, that property is structural: the procedure declares the measurement and its limits once, and each level supplies a different plug for the same sensor parameter.

The shared procedure

procedure.yaml
24 lines
name: Battery Voltage Limitversion: 1.0.0unit:  serial_number:    default_value: "UNIT000001"  part_number:    default_value: "ECU-100"plugs:  - name: sensor    python: plugs.sensor:VoltageSensormain:  - name: Measure Battery Voltage    python: phases.measure_battery_voltage    measurements:      - name: Battery Voltage        unit: V        validators:          - operator: ">="            expected_value: 10.0          - operator: "<="            expected_value: 14.5
phases/measure_battery_voltage.py
def measure_battery_voltage(measurements, sensor):    measurements.battery_voltage = sensor.read_voltage()

Record which level produced the run so the dashboard can separate them:

phases/record_level.py
def record_level(run):    run.metadata["test_level"] = "HIL"  # MIL | SIL | HIL

MIL: simulated sensor

plugs/sensor.py
class VoltageSensor:    """MIL: returns a deterministic value from a simulation model."""    def read_voltage(self) -> float:        return 11.8

SIL: compiled production code

plugs/sensor.py
import ctypesclass VoltageSensor:    """SIL: calls the compiled production C library via ctypes."""    LIB_PATH = "./build/battery_monitor.so"    def __init__(self):        self._lib = ctypes.CDLL(self.LIB_PATH)        self._lib.get_battery_voltage.restype = ctypes.c_float    def read_voltage(self) -> float:        return float(self._lib.get_battery_voltage())

HIL: real ECU over CAN

plugs/sensor.py
19 lines
import canclass VoltageSensor:    """HIL: reads voltage from the real ECU over the CAN bus."""    CHANNEL = "can0"    BUSTYPE = "socketcan"    def __init__(self):        self._bus = can.interface.Bus(channel=self.CHANNEL, bustype=self.BUSTYPE)    def read_voltage(self) -> float:        msg = self._bus.recv(timeout=1.0)        raw = msg.data[2]        return raw * 0.1    def close(self):        self._bus.shutdown()

Keep the three plugs in separate directories (or select one with an environment variable) and point the procedure at the level you are running. The phase, the measurement name and the limits never change, which is what makes a MIL/SIL/HIL discrepancy meaningful rather than an artifact of three differently-written tests.

The same thing in OpenHTF

If you prefer everything in Python with no YAML layer, OpenHTF expresses the same pattern with a plug per level:

tests/hil/test_voltage_limit.py
42 lines
import canimport openhtf as htffrom openhtf.util import unitsfrom tofupilot.openhtf import uploadclass HILVoltageSensor(htf.plugs.BasePlug):    """Reads voltage from real ECU over CAN bus."""    CHANNEL = "can0"    BUSTYPE = "socketcan"    def setUp(self):        self._bus = can.interface.Bus(channel=self.CHANNEL, bustype=self.BUSTYPE)    def read_voltage(self) -> float:        msg = self._bus.recv(timeout=1.0)        raw = msg.data[2]        return raw * 0.1    def tearDown(self):        self._bus.shutdown()@htf.plug(sensor=HILVoltageSensor)@htf.measures(    htf.Measurement("battery_voltage")    .in_range(minimum=10.0, maximum=14.5)    .with_units(units.VOLT),)def measure_battery_voltage(test, sensor):    test.measurements.battery_voltage = sensor.read_voltage()def main():    test = htf.Test(measure_battery_voltage, test_name="battery-voltage-hil")    test.add_output_callbacks(upload())    test.execute(test_start=lambda: "UNIT-HIL-001")if __name__ == "__main__":    main()

Swap HILVoltageSensor for a simulated or ctypes-backed plug to get the MIL and SIL variants. Install with pip install "tofupilot[openhtf]", or run the suite with tofupilot run and skip the callback.

How TofuPilot Tracks Results Across All Levels

Each run uploads with the same measurement name (battery_voltage) regardless of level. A metadata key or the procedure name distinguishes the environment.

LevelDistinguishing fieldTraceability
MILtest_level: MILAlgorithm baseline
SILtest_level: SILCodegen regression
HILtest_level: HILHardware validation

A unit that passes MIL but fails SIL indicates a code generation issue. A unit that passes SIL but fails HIL indicates a hardware integration issue. Because the limits are declared in one place, that comparison is valid.

Running MIL and SIL in CI

MIL and SIL have no operator and no hardware, so they belong in the pipeline. Resolve the identify prompt from the procedure and pre-bake any UI values so nothing blocks:

ci/run_sil.sh
tofupilot run ./procedure.yaml --json --ui-values ui.json --ui-timeout 300

HIL needs the bench, so it runs on a dedicated station wired to a self-hosted CI runner instead.

Decision Framework

ActivityRecommended Level
Tuning control gainsMIL
Regression testing in CISIL
Verifying code generation outputSIL
Validating CAN/LIN bus behaviorHIL
Interrupt and timing verificationHIL
Overnight fault injection suiteHIL
Parametric sweep (100+ cases)MIL or SIL
Pre-release sign-offHIL
Root-cause analysis of field issueHIL

For the bench-side detail (GPIO, ADC, PWM plugs and CI wiring), see HIL testing with Python.

More Guides

Put this guide into practice