PLC Ladder Logic Testing with CODESYS: A Practical Guide

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_IF

Testable 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_CASE

Unit 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 expiry

Using 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

Read more

Start now free