PLC Ladder Logic Testing with CODESYS: A Practical Guide
Testing PLC programs is one of the most underinvested areas in industrial software engineering. A typical automation project ships after manual bench testing with a handful of scenarios, with no regression suite to protect against future changes. When a firmware update or logic modification breaks an edge case, you find out from a production alarm at 2 AM. This guide covers systematic testing for IEC 61131-3 programs using CODESYS.
IEC 61131-3 and CODESYS Overview
IEC 61131-3 defines five PLC programming languages: Ladder Diagram (LD), Structured Text (ST), Function Block Diagram (FBD), Instruction List (IL, deprecated), and Sequential Function Chart (SFC). CODESYS (Controller Development System) is the dominant IDE and runtime implementing this standard, used by dozens of hardware vendors including Beckhoff, Wago, Pilz, and Schneider Electric under their own branding.
CODESYS 3.5 SP17+ includes the CODESYS Test Manager — a framework for writing and running unit tests directly inside the IDE, with results reportable to CI systems via XML.
Structuring PLC Code for Testability
Before writing tests, you need testable code. The primary pattern is Function Block (FB) decomposition — encapsulating logic into reusable blocks with explicit inputs and outputs.
Untestable pattern (monolithic program):
(* PLC_PRG — everything in one place *)
IF iSensor1 AND NOT iSensor2 THEN
qMotor1 := TRUE;
tonDelay(IN := TRUE, PT := T#2S);
IF tonDelay.Q THEN
qValve1 := TRUE;
END_IF
END_IFTestable pattern (decomposed into function blocks):
FUNCTION_BLOCK FB_ConveyorControl
VAR_INPUT
bSensorEntry : BOOL; (* Object detected at entry *)
bSensorExit : BOOL; (* Object detected at exit *)
bEmergencyStop : BOOL; (* E-stop active *)
tTransferDelay : TIME := T#2S;
END_VAR
VAR_OUTPUT
bMotorRun : BOOL;
bValveOpen : BOOL;
bFaultActive : BOOL;
END_VAR
VAR
tonDelay : TON;
eState : E_ConveyorState;
END_VAR
(* State machine implementation *)
CASE eState OF
E_ConveyorState.IDLE:
bMotorRun := FALSE;
bValveOpen := FALSE;
IF bSensorEntry AND NOT bEmergencyStop THEN
eState := E_ConveyorState.TRANSFERRING;
tonDelay(IN := FALSE);
END_IF
E_ConveyorState.TRANSFERRING:
bMotorRun := TRUE;
tonDelay(IN := TRUE, PT := tTransferDelay);
IF tonDelay.Q THEN
bValveOpen := TRUE;
eState := E_ConveyorState.DISCHARGING;
END_IF
IF bEmergencyStop THEN
eState := E_ConveyorState.FAULT;
END_IF
E_ConveyorState.DISCHARGING:
IF bSensorExit THEN
bValveOpen := FALSE;
bMotorRun := FALSE;
eState := E_ConveyorState.IDLE;
END_IF
E_ConveyorState.FAULT:
bMotorRun := FALSE;
bValveOpen := FALSE;
bFaultActive := TRUE;
END_CASEUnit Testing with CODESYS Test Manager
The CODESYS Test Manager uses the CmpUnitTestManager library. Tests are structured text programs (or FBs) that call Assert methods.
Writing Your First Test
METHOD TestConveyorNormalCycle : BOOL
VAR
fbConveyor : FB_ConveyorControl;
i : INT;
END_VAR
// Initialize
fbConveyor.bSensorEntry := FALSE;
fbConveyor.bSensorExit := FALSE;
fbConveyor.bEmergencyStop := FALSE;
fbConveyor.tTransferDelay := T#100MS; // Short delay for testing
// Cycle 1: no input — motor should be off
fbConveyor();
AssertFalse(fbConveyor.bMotorRun, 'Motor should be off in IDLE');
AssertFalse(fbConveyor.bValveOpen, 'Valve should be closed in IDLE');
AssertFalse(fbConveyor.bFaultActive, 'No fault in IDLE');
// Cycle 2: object detected — motor should start
fbConveyor.bSensorEntry := TRUE;
fbConveyor();
AssertTrue(fbConveyor.bMotorRun, 'Motor should start when entry sensor triggered');
// Simulate passage of time (100ms delay) by cycling the FB
// CODESYS Test Manager supports virtual time advancement
FOR i := 1 TO 20 DO
fbConveyor();
END_FOR
// After delay, valve should open
AssertTrue(fbConveyor.bValveOpen, 'Valve should open after transfer delay');
// Object exits: sensor exit triggered
fbConveyor.bSensorExit := TRUE;
fbConveyor();
fbConveyor.bSensorExit := FALSE;
fbConveyor();
// Should return to idle
AssertFalse(fbConveyor.bMotorRun, 'Motor should stop after discharge');
AssertFalse(fbConveyor.bValveOpen, 'Valve should close after discharge');
TestConveyorNormalCycle := TRUE;Testing Timer Behavior
Timer testing is tricky because TON, TOF, and TP blocks depend on cycle time. The recommended approach is to use a mock timer interface:
INTERFACE I_Timer
METHOD SetInput : VOID (bIN : BOOL; tPT : TIME)
METHOD PROP Q : BOOL GET
METHOD PROP ET : TIME GET
END_INTERFACE
FUNCTION_BLOCK FB_MockTimer IMPLEMENTS I_Timer
VAR
bSimulateQ : BOOL := FALSE;
tSimulateET : TIME := T#0S;
END_VAR
METHOD SetInput : VOID (bIN : BOOL; tPT : TIME)
// In mock, caller controls Q and ET directly
END_METHOD
METHOD PROP Q : BOOL GET
Q := bSimulateQ;
END_METHOD
// In tests, set fbMock.bSimulateQ := TRUE to simulate timer expiryUsing dependency injection for timers makes your function blocks testable without actual time passage.
Hardware-in-the-Loop (HIL) Testing
Software simulation is fast and cheap; HIL connects a real PLC to simulated I/O. When to use each:
| Scenario | Software Sim | HIL |
|---|---|---|
| Logic unit tests | ✓ | — |
| Timing-critical sequences | Limited | ✓ |
| Communication protocol testing | Partial | ✓ |
| Safety function certification (IEC 62061) | — | ✓ |
| CI pipeline integration | ✓ | Limited |
For HIL setups, tools like dSPACE, National Instruments VeriStand, and Beckhoff TwinCAT provide I/O simulation modules that connect to real PLC hardware.
A simpler approach using Python and pymodbus for Modbus-based PLCs:
from pymodbus.client import ModbusTcpClient
import time
client = ModbusTcpClient("192.168.1.10", port=502)
client.connect()
def test_motor_start_on_sensor():
"""HIL test: assert motor coil (coil 0) activates when sensor input (register 0) is set."""
# Simulate sensor trigger by writing to holding register
client.write_register(0, 1) # Sensor = TRUE
time.sleep(0.1) # One scan cycle
result = client.read_coils(0, 1) # Read motor output
assert result.bits[0], "Motor did not start when sensor triggered"
# Clear sensor
client.write_register(0, 0)
time.sleep(0.1)
print("PASS: Motor start on sensor trigger")
def test_emergency_stop_overrides_motor():
"""E-stop (register 1) must override running motor."""
client.write_register(0, 1) # Sensor = TRUE (motor should run)
time.sleep(0.1)
client.write_register(1, 1) # E-stop = TRUE
time.sleep(0.1)
result = client.read_coils(0, 1)
assert not result.bits[0], "Motor did not stop on E-stop"
print("PASS: E-stop overrides motor")
test_motor_start_on_sensor()
test_emergency_stop_overrides_motor()
client.close()State Machine Testing Patterns
State machines are the backbone of PLC logic. Testing them requires exercising every valid transition and verifying that invalid transitions are rejected.
(* Test all valid state transitions for FB_ConveyorControl *)
METHOD TestStateTransitions : BOOL
VAR
fbConveyor : FB_ConveyorControl;
END_VAR
// IDLE → TRANSFERRING: via entry sensor
ResetFB(fbConveyor);
fbConveyor.bSensorEntry := TRUE;
fbConveyor();
AssertEquals_INT(
Expected := E_ConveyorState.TRANSFERRING,
Actual := fbConveyor.eState,
Message := 'Should transition to TRANSFERRING on sensor entry'
);
// IDLE → should NOT transition on exit sensor alone
ResetFB(fbConveyor);
fbConveyor.bSensorExit := TRUE;
fbConveyor();
AssertEquals_INT(
Expected := E_ConveyorState.IDLE,
Actual := fbConveyor.eState,
Message := 'Exit sensor alone should not cause transition from IDLE'
);
// Any state → FAULT on E-stop
ResetFB(fbConveyor);
fbConveyor.bSensorEntry := TRUE;
fbConveyor(); // → TRANSFERRING
fbConveyor.bEmergencyStop := TRUE;
fbConveyor();
AssertEquals_INT(
Expected := E_ConveyorState.FAULT,
Actual := fbConveyor.eState,
Message := 'E-stop should cause FAULT from any running state'
);
TestStateTransitions := TRUE;CI Integration
CODESYS Test Manager can export results in JUnit XML format. Combine with a CI pipeline using CODESYS Automation Server or command-line build tools:
# .github/workflows/plc-tests.yml
name: PLC Unit Tests
on: [push, pull_request]
jobs:
test:
runs-on: self-hosted # Requires CODESYS installation
steps:
- uses: actions/checkout@v3
- name: Build PLC project
run: |
codesys --profile "CODESYS V3.5 SP17" \
--runscript build_and_test.py \
--scriptargs "project=ConveyorControl.project"
- name: Publish test results
uses: EnricoMi/publish-unit-test-result-action@v2
with:
files: "**/TestResults/*.xml"The build script (build_and_test.py) uses the CODESYS scripting API to compile, deploy to a SoftPLC, run tests, and export results.
Common Pitfalls
Pitfall 1: Testing only the happy path Ladder logic is often written optimistically. Always write tests for:
- Counter overflow (WORD counter reaching 65535)
- Timer with PT = T#0S (should Q immediately or never?)
- Multiple simultaneous sensor inputs
Pitfall 2: Ignoring scan cycle order In a single PLC scan, outputs updated early in the program are visible to logic later in the same scan. Tests must account for this — calling a FB once may not be enough to propagate state.
Pitfall 3: Using real timers in unit tests A test suite with 200 timer-based tests, each waiting 2 seconds, takes 6+ minutes. Mock timers or virtual time advancement is essential.
Pitfall 4: Not testing fault recovery Most PLC programs have a FAULT or ERROR state. Test the recovery path: after an E-stop is cleared and reset is pressed, does the machine correctly return to IDLE (not skip states)?
Key Takeaways
- Decompose PLC programs into Function Blocks with explicit I/O to enable unit testing
- Use CODESYS Test Manager for in-IDE test execution with JUnit XML output for CI integration
- Mock timers using interface injection to avoid slow, real-time-dependent tests
- HIL testing is required for timing-critical sequences and protocol-level validation
- Test every state machine transition, including invalid inputs and fault recovery paths