Skip to main content

posts

Dependency Injection in PLC Programming Makes Testing Possible

A practical explanation of dependency injection in TwinCAT Structured Text and why interfaces make PLC code easier to test.

Testing PLC code is difficult when every function block creates its own world internally.

This often happens in older IEC 61131-3 projects. A hydraulic power unit function block instantiates its valve blocks directly. A winch function block instantiates its brake, clutch, sensors, and valves directly. The structure looks convenient at first because fewer variables need to be linked at the program level.

The cost appears later.

If the winch state machine depends on internally created brakes, clutches, sensors, and valves, then testing the winch means testing the whole physical system around it. The test requires hardware or a large simulation of everything inside the block. The logic cannot be tested in isolation because the dependencies are hidden inside the implementation.

That is the problem dependency injection solves.

The idea is simple:

A function block should not create every object it depends on. Some dependencies should be passed into it from the outside.

In TwinCAT, this becomes useful when combined with interfaces.

Start With the Dependency

Assume a controller needs a pressure value. The controller should not care whether the value comes from a real analog input, a fieldbus device, a simulation model, or a test object.

It only needs a sensor contract.

INTERFACE ISensor

The interface exposes one property:

PROPERTY Value : LREAL

The real sensor implements the interface.

FUNCTION_BLOCK PressureSensor IMPLEMENTS ISensor
VAR
    valueActual : LREAL;
END_VAR

The cyclic method can read hardware, apply scaling, or process diagnostics. The caller does not need to know those details.

METHOD update

valueActual := 123.4;

The property getter returns the measured value.

Value := valueActual;

Now a variable of type ISensor can refer to any function block that implements this interface.

VAR
    pressureSensor : PressureSensor;
    sensor : ISensor;
    pressure : LREAL;
END_VAR

sensor := pressureSensor;

IF sensor <> 0 THEN
    pressure := sensor.Value;
END_IF

This is already useful. The controller can depend on ISensor instead of PressureSensor. But the real benefit appears when testing starts.

Replace the Real Sensor

For a test, create a fake sensor that implements the same interface.

FUNCTION_BLOCK SensorFake IMPLEMENTS ISensor
VAR
    valueActual : LREAL;
END_VAR

The fake exposes the same Value property.

Value := valueActual;

It also provides a method to set test values.

METHOD setValue
VAR_INPUT
    value : LREAL;
END_VAR

valueActual := value;

The production code reads ISensor.Value. It does not know whether the instance is a real sensor or a fake sensor.

That is the useful abstraction. The controller depends on behavior, not on the concrete implementation.

The same pattern works for actuators. A valve can be represented by an interface.

INTERFACE IValve

For example:

METHOD open
METHOD close
PROPERTY IsOpen : BOOL

A real valve writes outputs. A fake valve stores the requested state in memory. The controller can use both through the same interface.

Inject the Dependency

The next step is to pass the required objects into the controller.

The controller stores interface references internally.

FUNCTION_BLOCK PressureController
VAR
    sensor : ISensor;
    valve : IValve;
    initialized : BOOL;
END_VAR

Use an explicit init() method to connect the dependencies.

METHOD init : BOOL
VAR_INPUT
    sensorInput : ISensor;
    valveInput : IValve;
END_VAR

IF sensorInput = 0 OR valveInput = 0 THEN
    initialized := FALSE;
    init := FALSE;
    RETURN;
END_IF

sensor := sensorInput;
valve := valveInput;
initialized := TRUE;
init := TRUE;

The cyclic logic uses only the interfaces.

METHOD update
VAR CONSTANT
    PRESSURE_LIMIT : LREAL := 100.0;
END_VAR

IF NOT initialized THEN
    RETURN;
END_IF

IF sensor <> 0 AND_THEN valve <> 0 THEN
    IF sensor.Value > PRESSURE_LIMIT THEN
        valve.open();
    ELSE
        valve.close();
    END_IF
END_IF

In the real application, pass the real objects.

VAR
    pressureSensor : PressureSensor;
    pressureValve : PressureValve;
    controller : PressureController;
    initialized : BOOL;
END_VAR

IF NOT initialized THEN
    initialized := controller.init(
        sensorInput := pressureSensor,
        valveInput := pressureValve);
END_IF

pressureSensor.update();
controller.update();

In a test application, pass fake objects.

VAR
    sensorFake : SensorFake;
    valveFake : ValveFake;
    controller : PressureController;
    initialized : BOOL;
END_VAR

IF NOT initialized THEN
    initialized := controller.init(
        sensorInput := sensorFake,
        valveInput := valveFake);
END_IF

sensorFake.setValue(120.0);
controller.update();

The controller code does not change. Only the objects passed into it change.

That is dependency injection in a PLC context. Not magic. Not a framework. Just a controlled way to provide dependencies from the outside instead of hiding them inside the block.

What This Changes

Dependency injection makes testing practical because the tested block can be separated from real hardware.

You can feed sensor values directly into a fake sensor. You can observe whether a fake valve was opened or closed. You can test transitions in a state machine without connecting a real hydraulic unit.

This changes the design pressure on the code. Function blocks must become smaller. Their interfaces must be clear. Dependencies must be visible. Large blocks that instantiate half the machine internally become harder to justify. Tragic, I know. The monster block had such character.

There are also practical limits.

All instances are still statically allocated. Dependency injection in TwinCAT does not mean dynamic object creation. It means that the application creates the instances and then passes interface references to the blocks that need them.

Interface references must be checked before use.

IF sensor <> 0 THEN
    pressure := sensor.Value;
END_IF

Initialization order also matters. The top-level application must create and initialize the objects in a defined order. Do not hide complex wiring in FB_init() unless there is a very specific reason. FB_init() is called automatically and also runs during online changes. For most application logic, an explicit init() method is easier to control and easier to test.

The interface does not remove the need for a protocol. If IValve.open() is called, the expected behavior must still be defined. Is it a command? A request? Is feedback required? How are errors reported? The compiler checks that the method exists. It does not understand your hydraulic concept. Rude, but consistent.

Conclusion

Dependency injection is valuable in PLC programming because it makes dependencies visible.

A controller that depends directly on a concrete sensor and a concrete valve is difficult to test without those objects. A controller that depends on ISensor and IValve can use real devices in the machine and fake devices in a test.

That makes testing before commissioning more realistic. It also improves the structure of the PLC application because function blocks become smaller and their contracts become clearer.

The practical rule is simple:

Create objects outside the block. Pass required behavior in through interfaces. Test the block with fake implementations.