Skip to content

This textbook is in beta – content is actively being refined. Report issues or suggestions

10.5 Unit tests for subsystems

Why it matters

Testing mechatronic subsystems presents unique challenges: hardware components are expensive, potentially dangerous, and may not be available during development. Unit testing with simulated devices allows developers to verify control logic, validate safety features, and ensure system reliability without requiring physical hardware.

Testability patterns and fixtures enable systematic verification of subsystem behaviour, making it possible to catch bugs early, document expected behaviour, and maintain confidence when making changes to complex control systems.

Concepts

Unit testing fundamentals for mechatronics

Unit testing verifies that individual components or subsystems behave correctly in isolation. For mechatronic systems, this means testing control logic separately from hardware interfaces.

Key principles:

  • Isolation: test each subsystem independently

  • Repeatability: tests produce consistent results

  • Fast execution: tests run quickly without hardware delays

  • Clear assertions: tests specify expected behaviour precisely

Challenges in mechatronic testing:

  • Hardware dependencies make testing slow and expensive

  • Real sensors produce noisy, variable data

  • Actuators have physical constraints and safety implications

  • Timing-dependent behaviour is difficult to reproduce

Test fixtures: simulating physical devices

Test fixtures provide controlled, predictable substitutes for hardware components. They enable testing without physical devices while maintaining realistic behaviour patterns.

Production SystemTest EnvironmentControl LogicReal SensorReal ActuatorPhysical EnvironmentMock SensorMock ActuatorSimulated EnvironmentTest FrameworkControls simulatedenvironment and devicebehaviour for testingSame logic testedin both environments

Guided example: temperature control system testing

Let’s test a temperature control system that manages heating based on sensor readings and safety limits.

# temperature_controller.py - The system under test
class TemperatureController:
    def __init__(self, sensor, heater, safety_limit=80.0):
        self.sensor = sensor
        self.heater = heater
        self.safety_limit = safety_limit
        self.target_temperature = 20.0
        self.is_enabled = False

    def set_target(self, temperature):
        """Set the target temperature"""
        if 10 <= temperature <= 70:
            self.target_temperature = temperature
            return True
        return False

    def enable(self):
        """Enable the controller"""
        self.is_enabled = True

    def disable(self):
        """Disable the controller"""
        self.is_enabled = False
        self.heater.set_power(0)  # Ensure heater is off

    def update(self):
        """Main control loop - call regularly"""
        if not self.is_enabled:
            return

        # Read current temperature
        current_temp = self.sensor.read_temperature()

        # Safety check - emergency shutdown if too hot
        if current_temp >= self.safety_limit:
            self.disable()
            raise SafetyException(f"Temperature {current_temp}°C exceeds safety limit {self.safety_limit}°C")

        # Simple proportional control
        error = self.target_temperature - current_temp

        if error > 0:  # Need heating
            power = min(100, error * 10)  # Proportional gain of 10
            self.heater.set_power(power)
        else:  # Too hot or at target
            self.heater.set_power(0)

    def get_status(self):
        """Get current system status"""
        return {
            'enabled': self.is_enabled,
            'target': self.target_temperature,
            'current': self.sensor.read_temperature(),
            'heater_power': self.heater.get_power()
        }

class SafetyException(Exception):
    """Raised when safety limits are exceeded"""
    pass

Now let’s create test fixtures for the sensor and heater:

# test_fixtures.py - Mock devices for testing
class MockTemperatureSensor:
    def __init__(self, initial_temperature=20.0):
        self._temperature = initial_temperature
        self._noise_level = 0.0
        self._failure_mode = None

    def read_temperature(self):
        """Read temperature with optional noise and failure simulation"""
        if self._failure_mode == "disconnected":
            raise ConnectionError("Sensor disconnected")
        elif self._failure_mode == "invalid_reading":
            return float('nan')

        # Add noise if configured
        import random
        noise = random.uniform(-self._noise_level, self._noise_level)
        return self._temperature + noise

    def set_temperature(self, temperature):
        """Set the simulated temperature (for testing)"""
        self._temperature = temperature

    def add_noise(self, noise_level):
        """Add random noise to readings"""
        self._noise_level = noise_level

    def simulate_failure(self, failure_type):
        """Simulate sensor failures"""
        self._failure_mode = failure_type

    def clear_failure(self):
        """Clear simulated failures"""
        self._failure_mode = None

class MockHeater:
    def __init__(self):
        self._power = 0.0
        self._total_energy = 0.0
        self._failure_mode = None

    def set_power(self, power_percentage):
        """Set heater power (0-100%)"""
        if self._failure_mode == "stuck_on":
            return  # Ignore commands when stuck
        elif self._failure_mode == "no_response":
            raise ConnectionError("Heater not responding")

        self._power = max(0, min(100, power_percentage))

    def get_power(self):
        """Get current power setting"""
        return self._power

    def get_total_energy(self):
        """Get total energy consumed (for testing efficiency)"""
        return self._total_energy

    def simulate_failure(self, failure_type):
        """Simulate heater failures"""
        self._failure_mode = failure_type
        if failure_type == "stuck_on":
            self._power = 100.0  # Stuck at full power

    def clear_failure(self):
        """Clear simulated failures"""
        self._failure_mode = None

Comprehensive unit tests

# test_temperature_controller.py - Unit tests using fixtures
import unittest
from temperature_controller import TemperatureController, SafetyException
from test_fixtures import MockTemperatureSensor, MockHeater

class TestTemperatureController(unittest.TestCase):
    def setUp(self):
        """Set up test fixtures before each test"""
        self.sensor = MockTemperatureSensor(initial_temperature=20.0)
        self.heater = MockHeater()
        self.controller = TemperatureController(self.sensor, self.heater)

    def test_initial_state(self):
        """Test controller starts in correct initial state"""
        status = self.controller.get_status()
        self.assertFalse(status['enabled'])
        self.assertEqual(status['target'], 20.0)
        self.assertEqual(status['current'], 20.0)
        self.assertEqual(status['heater_power'], 0)

    def test_target_temperature_validation(self):
        """Test target temperature range validation"""
        # Valid temperatures should be accepted
        self.assertTrue(self.controller.set_target(25.0))
        self.assertEqual(self.controller.target_temperature, 25.0)

        # Invalid temperatures should be rejected
        self.assertFalse(self.controller.set_target(5.0))   # Too low
        self.assertFalse(self.controller.set_target(80.0))  # Too high
        self.assertEqual(self.controller.target_temperature, 25.0)  # Unchanged

    def test_enable_disable(self):
        """Test controller enable/disable functionality"""
        # Start disabled
        self.assertFalse(self.controller.is_enabled)

        # Enable
        self.controller.enable()
        self.assertTrue(self.controller.is_enabled)

        # Disable should turn off heater
        self.heater.set_power(50)  # Set some power
        self.controller.disable()
        self.assertFalse(self.controller.is_enabled)
        self.assertEqual(self.heater.get_power(), 0)

    def test_heating_control(self):
        """Test proportional heating control"""
        self.controller.set_target(30.0)
        self.sensor.set_temperature(20.0)  # 10°C below target
        self.controller.enable()

        self.controller.update()

        # Should apply proportional heating (error * 10)
        expected_power = 10 * 10  # 10°C error * gain of 10
        self.assertEqual(self.heater.get_power(), min(100, expected_power))

    def test_no_heating_when_at_target(self):
        """Test heater turns off when at target temperature"""
        self.controller.set_target(25.0)
        self.sensor.set_temperature(25.0)
        self.controller.enable()

        self.controller.update()

        self.assertEqual(self.heater.get_power(), 0)

    def test_no_heating_when_too_hot(self):
        """Test heater turns off when above target"""
        self.controller.set_target(25.0)
        self.sensor.set_temperature(30.0)  # Above target
        self.controller.enable()

        self.controller.update()

        self.assertEqual(self.heater.get_power(), 0)

    def test_safety_limit_enforcement(self):
        """Test emergency shutdown at safety limit"""
        self.controller.set_target(30.0)
        self.controller.enable()

        # Simulate temperature exceeding safety limit
        self.sensor.set_temperature(85.0)

        with self.assertRaises(SafetyException) as context:
            self.controller.update()

        # Controller should be disabled and heater off
        self.assertFalse(self.controller.is_enabled)
        self.assertEqual(self.heater.get_power(), 0)
        self.assertIn("safety limit", str(context.exception))

    def test_disabled_controller_ignores_updates(self):
        """Test disabled controller doesn't control heater"""
        self.controller.set_target(40.0)
        self.sensor.set_temperature(20.0)
        # Keep controller disabled

        self.controller.update()

        self.assertEqual(self.heater.get_power(), 0)

    def test_power_limiting(self):
        """Test heater power is limited to 100%"""
        self.controller.set_target(50.0)
        self.sensor.set_temperature(10.0)  # Large error
        self.controller.enable()

        self.controller.update()

        # Even with large error, power should not exceed 100%
        self.assertEqual(self.heater.get_power(), 100)

class TestTemperatureControllerWithFailures(unittest.TestCase):
    """Test controller behaviour with simulated device failures"""

    def setUp(self):
        self.sensor = MockTemperatureSensor(initial_temperature=20.0)
        self.heater = MockHeater()
        self.controller = TemperatureController(self.sensor, self.heater)

    def test_sensor_disconnection(self):
        """Test handling of sensor connection failure"""
        self.controller.enable()
        self.sensor.simulate_failure("disconnected")

        with self.assertRaises(ConnectionError):
            self.controller.update()

    def test_sensor_invalid_reading(self):
        """Test handling of invalid sensor data"""
        self.controller.enable()
        self.sensor.simulate_failure("invalid_reading")

        # Controller should handle NaN readings gracefully
        # Implementation detail: this might raise an exception or use a default
        with self.assertRaises((ValueError, TypeError)):
            self.controller.update()

    def test_heater_failure(self):
        """Test detection of heater communication failure"""
        self.controller.set_target(30.0)
        self.controller.enable()
        self.heater.simulate_failure("no_response")

        with self.assertRaises(ConnectionError):
            self.controller.update()

    def test_sensor_noise_handling(self):
        """Test controller stability with noisy sensor readings"""
        self.controller.set_target(25.0)
        self.controller.enable()
        self.sensor.add_noise(0.5)  # ±0.5°C noise

        # Run multiple updates with noise
        power_readings = []
        for _ in range(10):
            self.sensor.set_temperature(24.0)  # Slightly below target
            self.controller.update()
            power_readings.append(self.heater.get_power())

        # Power should be generally consistent despite noise
        avg_power = sum(power_readings) / len(power_readings)
        self.assertGreater(avg_power, 5)  # Should be heating
        self.assertLess(avg_power, 20)    # But not too much

# Test runner
if __name__ == '__main__':
    # Run all tests
    unittest.main(verbosity=2)

Testability patterns

Designing systems for testability requires specific architectural patterns that separate concerns and enable dependency injection.

Dependency injection pattern

class RobotArm:
    """Example of dependency injection for testability"""

    def __init__(self, motor_controller, position_sensor, safety_monitor):
        # Dependencies injected at construction
        self.motor = motor_controller
        self.sensor = position_sensor
        self.safety = safety_monitor
        self.current_position = 0.0
        self.target_position = 0.0

    def move_to(self, target_position):
        """Move arm to target position with safety checks"""
        if not self.safety.is_position_safe(target_position):
            raise SafetyException(f"Target position {target_position} is not safe")

        self.target_position = target_position
        self._execute_move()

    def _execute_move(self):
        """Internal move execution logic"""
        self.current_position = self.sensor.read_position()
        error = self.target_position - self.current_position

        if abs(error) < 0.1:  # Close enough
            self.motor.stop()
        elif error > 0:
            self.motor.move_forward(min(100, abs(error) * 50))
        else:
            self.motor.move_backward(min(100, abs(error) * 50))

# Mock implementations for testing
class MockMotorController:
    def __init__(self):
        self.current_command = "stop"
        self.power_level = 0

    def move_forward(self, power):
        self.current_command = "forward"
        self.power_level = power

    def move_backward(self, power):
        self.current_command = "backward"
        self.power_level = power

    def stop(self):
        self.current_command = "stop"
        self.power_level = 0

class MockPositionSensor:
    def __init__(self, position=0.0):
        self._position = position

    def read_position(self):
        return self._position

    def set_position(self, position):
        self._position = position

class MockSafetyMonitor:
    def __init__(self):
        self.safe_range = (-100, 100)

    def is_position_safe(self, position):
        return self.safe_range[0] <= position <= self.safe_range[1]

    def set_safe_range(self, min_pos, max_pos):
        self.safe_range = (min_pos, max_pos)

# Test using dependency injection
def test_robot_arm_movement():
    """Test robot arm with injected mock dependencies"""
    motor = MockMotorController()
    sensor = MockPositionSensor(position=10.0)
    safety = MockSafetyMonitor()

    arm = RobotArm(motor, sensor, safety)

    # Test normal movement
    arm.move_to(20.0)
    assert motor.current_command == "forward"
    assert motor.power_level > 0

    # Test safety limits
    safety.set_safe_range(-50, 50)
    try:
        arm.move_to(100.0)  # Outside safe range
        assert False, "Should have raised SafetyException"
    except SafetyException:
        pass  # Expected

Integration testing with fixture orchestration

# test_system_integration.py - Testing multiple subsystems together
class GreenhouseIntegrationTest(unittest.TestCase):
    """Integration tests using multiple coordinated fixtures"""

    def setUp(self):
        # Create an integrated mock environment
        self.environment = MockGreenhouseEnvironment()

        # Create subsystem fixtures
        self.temp_sensor = MockTemperatureSensor(20.0)
        self.humidity_sensor = MockHumiditySensor(60.0)
        self.fan = MockFan()
        self.heater = MockHeater()

        # Connect fixtures to environment
        self.environment.add_sensor(self.temp_sensor)
        self.environment.add_sensor(self.humidity_sensor)
        self.environment.add_actuator(self.fan)
        self.environment.add_actuator(self.heater)

        # Create system under test
        self.greenhouse_controller = GreenhouseController(
            temp_sensor=self.temp_sensor,
            humidity_sensor=self.humidity_sensor,
            fan=self.fan,
            heater=self.heater
        )

    def test_coordinated_climate_control(self):
        """Test temperature and humidity control working together"""
        # Set targets
        self.greenhouse_controller.set_temperature_target(25.0)
        self.greenhouse_controller.set_humidity_target(70.0)

        # Simulate hot, dry conditions
        self.environment.set_conditions(temperature=30.0, humidity=40.0)

        # Run control cycle
        self.greenhouse_controller.update()

        # Should activate fan for cooling and humidity
        self.assertGreater(self.fan.get_speed(), 0)
        # Should not heat when too hot
        self.assertEqual(self.heater.get_power(), 0)

    def test_system_recovery_after_failure(self):
        """Test system behaviour during and after component failure"""
        self.greenhouse_controller.enable()

        # Simulate sensor failure
        self.temp_sensor.simulate_failure("disconnected")

        # System should detect failure and enter safe mode
        self.greenhouse_controller.update()
        self.assertTrue(self.greenhouse_controller.is_in_safe_mode())

        # Restore sensor
        self.temp_sensor.clear_failure()

        # System should recover
        self.greenhouse_controller.update()
        self.assertFalse(self.greenhouse_controller.is_in_safe_mode())

class MockGreenhouseEnvironment:
    """Orchestrates multiple fixtures to simulate realistic environment"""

    def __init__(self):
        self.sensors = []
        self.actuators = []
        self.temperature = 20.0
        self.humidity = 60.0

    def add_sensor(self, sensor):
        self.sensors.append(sensor)

    def add_actuator(self, actuator):
        self.actuators.append(actuator)

    def set_conditions(self, temperature=None, humidity=None):
        """Update environmental conditions"""
        if temperature is not None:
            self.temperature = temperature
            # Update all temperature sensors
            for sensor in self.sensors:
                if hasattr(sensor, 'set_temperature'):
                    sensor.set_temperature(temperature)

        if humidity is not None:
            self.humidity = humidity
            # Update all humidity sensors
            for sensor in self.sensors:
                if hasattr(sensor, 'set_humidity'):
                    sensor.set_humidity(humidity)

    def simulate_time_step(self, dt=1.0):
        """Simulate environmental dynamics over time"""
        # Simple thermal model
        heating_effect = sum(
            actuator.get_power() * 0.01 
            for actuator in self.actuators 
            if hasattr(actuator, 'get_power')
        )

        cooling_effect = sum(
            actuator.get_speed() * 0.005
            for actuator in self.actuators
            if hasattr(actuator, 'get_speed')
        )

        self.temperature += (heating_effect - cooling_effect) * dt

        # Update sensors with new conditions
        self.set_conditions(self.temperature, self.humidity)

Try it

Exercise 1: sensor validation testing

Create unit tests for a pressure sensor that must validate readings are within expected ranges and handle sensor failures gracefully.

Requirements:

  • Normal range: 0-1000 kPa

  • Must detect readings outside physical limits

  • Must handle sensor disconnection

  • Must provide default safe reading during failures

Sample Solution
class PressureSensor:
    def __init__(self, min_pressure=0, max_pressure=1000):
        self.min_pressure = min_pressure
        self.max_pressure = max_pressure
        self.last_valid_reading = 0

    def read_pressure(self):
        # Implementation would read from actual hardware
        pass

    def validate_reading(self, raw_reading):
        if raw_reading < self.min_pressure or raw_reading > self.max_pressure:
            raise ValueError(f"Pressure {raw_reading} outside valid range")
        return raw_reading

    def get_safe_reading(self):
        try:
            reading = self.read_pressure()
            validated = self.validate_reading(reading)
            self.last_valid_reading = validated
            return validated
        except (ConnectionError, ValueError):
            return self.last_valid_reading  # Return last known good value

class MockPressureSensor(PressureSensor):
    def __init__(self):
        super().__init__()
        self._pressure = 500
        self._connected = True

    def read_pressure(self):
        if not self._connected:
            raise ConnectionError("Sensor disconnected")
        return self._pressure

    def set_pressure(self, pressure):
        self._pressure = pressure

    def disconnect(self):
        self._connected = False

class TestPressureSensor(unittest.TestCase):
    def setUp(self):
        self.sensor = MockPressureSensor()

    def test_valid_readings(self):
        self.sensor.set_pressure(500)
        self.assertEqual(self.sensor.get_safe_reading(), 500)

    def test_invalid_high_reading(self):
        self.sensor.set_pressure(1500)  # Above max
        # Should return last valid (0 initially)
        self.assertEqual(self.sensor.get_safe_reading(), 0)

    def test_disconnected_sensor(self):
        self.sensor.set_pressure(300)
        self.sensor.get_safe_reading()  # Store valid reading
        self.sensor.disconnect()
        # Should return last valid reading
        self.assertEqual(self.sensor.get_safe_reading(), 300)
Exercise 2: actuator control testing

Design tests for a servo motor controller that must handle position commands, speed limits, and safety boundaries.

Requirements:

  • Position range: -180 to +180 degrees

  • Maximum speed: 90 degrees/second

  • Must reject commands outside safe range

  • Must stop immediately on safety signal

Sample Solution
class ServoController:
    def __init__(self, min_angle=-180, max_angle=180, max_speed=90):
        self.min_angle = min_angle
        self.max_angle = max_angle
        self.max_speed = max_speed
        self.current_position = 0
        self.target_position = 0
        self.is_moving = False
        self.emergency_stop = False

    def move_to(self, target_angle):
        if self.emergency_stop:
            return False
        if not (self.min_angle <= target_angle <= self.max_angle):
            return False
        self.target_position = target_angle
        self.is_moving = True
        return True

    def emergency_stop_activate(self):
        self.emergency_stop = True
        self.is_moving = False

    def update(self, dt):
        if not self.is_moving or self.emergency_stop:
            return

        error = self.target_position - self.current_position
        if abs(error) < 1:  # Close enough
            self.is_moving = False
            return

        # Calculate movement step with speed limit
        max_step = self.max_speed * dt
        step = min(max_step, abs(error)) * (1 if error > 0 else -1)
        self.current_position += step

class TestServoController(unittest.TestCase):
    def setUp(self):
        self.servo = ServoController()

    def test_valid_move_command(self):
        self.assertTrue(self.servo.move_to(90))
        self.assertEqual(self.servo.target_position, 90)
        self.assertTrue(self.servo.is_moving)

    def test_invalid_move_command(self):
        self.assertFalse(self.servo.move_to(200))  # Outside range
        self.assertEqual(self.servo.target_position, 0)  # Unchanged
        self.assertFalse(self.servo.is_moving)

    def test_emergency_stop(self):
        self.servo.move_to(90)
        self.servo.emergency_stop_activate()
        self.assertFalse(self.servo.is_moving)
        self.assertFalse(self.servo.move_to(45))  # Commands rejected
Exercise 3: system integration test

Create an integration test for a conveyor belt system with multiple sensors and actuators working together.

Components:

  • Speed sensor (encoder)

  • Safety sensor (light curtain)

  • Motor controller

  • Emergency stop button

Sample Solution
class ConveyorSystem:
    def __init__(self, speed_sensor, safety_sensor, motor, estop_button):
        self.speed_sensor = speed_sensor
        self.safety_sensor = safety_sensor
        self.motor = motor
        self.estop_button = estop_button
        self.target_speed = 0
        self.enabled = False

    def start(self, speed):
        if self.estop_button.is_pressed():
            return False
        if not self.safety_sensor.is_clear():
            return False
        self.target_speed = speed
        self.enabled = True
        return True

    def stop(self):
        self.enabled = False
        self.target_speed = 0
        self.motor.set_speed(0)

    def update(self):
        # Emergency stop override
        if self.estop_button.is_pressed():
            self.stop()
            return

        # Safety sensor check
        if not self.safety_sensor.is_clear():
            self.stop()
            return

        # Speed control
        if self.enabled:
            current_speed = self.speed_sensor.read_speed()
            error = self.target_speed - current_speed
            adjustment = error * 0.1  # Simple proportional control
            new_speed = current_speed + adjustment
            self.motor.set_speed(max(0, min(100, new_speed)))

class TestConveyorIntegration(unittest.TestCase):
    def setUp(self):
        self.speed_sensor = MockSpeedSensor()
        self.safety_sensor = MockSafetySensor(clear=True)
        self.motor = MockMotor()
        self.estop = MockEstopButton(pressed=False)

        self.conveyor = ConveyorSystem(
            self.speed_sensor, self.safety_sensor, 
            self.motor, self.estop
        )

    def test_normal_operation(self):
        # Should start successfully
        self.assertTrue(self.conveyor.start(50))
        self.conveyor.update()
        self.assertGreater(self.motor.current_speed, 0)

    def test_safety_sensor_stops_system(self):
        self.conveyor.start(50)
        self.safety_sensor.set_clear(False)  # Block sensor
        self.conveyor.update()
        self.assertEqual(self.motor.current_speed, 0)
        self.assertFalse(self.conveyor.enabled)

    def test_emergency_stop_override(self):
        self.conveyor.start(50)
        self.estop.press()
        self.conveyor.update()
        self.assertEqual(self.motor.current_speed, 0)
        self.assertFalse(self.conveyor.enabled)

Recap

Unit testing mechatronic subsystems requires specialized approaches to handle hardware dependencies and ensure reliable system behaviour:

Test fixtures: mock devices provide controlled, predictable substitutes for hardware components, enabling fast and repeatable testing without physical devices.

Testability patterns: dependency injection and interface abstraction separate control logic from hardware implementation, making systems easier to test and maintain.

Comprehensive testing: covers normal operation, edge cases, failure scenarios, and safety requirements to ensure robust system behaviour.

Integration testing: coordinates multiple fixtures to test subsystem interactions and system-level behaviour in realistic scenarios.

Benefits: early bug detection, documented behaviour, confidence in changes, and reduced reliance on expensive hardware for development and testing.

Effective testing strategies enable reliable mechatronic systems by validating control logic, safety features, and system integration before deploying to physical hardware.

See also 10.1 Simulations and prototypes for testing for simulation-based testing approaches.