Documentation

PLC Testing

Write and run .scltest suites for whole-program and unit-target checks.

PLC Testing Framework

The Siemens Language Support extension includes a dedicated testing framework for SCL / Structured Text. It allows you to write and run tests directly within VS Code, using a domain-specific language (.scltest) designed for PLC logic validation.

Concepts

The framework validates generated runtime semantics. It executes your PLC code by transpiling it to Go and running it as a native process.

Semantic Boundary

  • v1 Focus: Logic and state transitions. The source of truth is the generated Go runtime execution, not a cycle-accurate Siemens CPU or TIA Portal simulation.
  • Extensions: Integration with external systems via HTTP or OPC-UA is supported through extension points but is not a first-class feature of the v1 framework.

Getting Started

To enable testing, your project must have a .plc.json file at its root. This file defines the PLC scope and libraries.

Writing Tests

Each .scltest file is a small scenario file made of optional suite directives followed by one or more TEST_CASE blocks.

Authoring Flow

  1. Decide whether you are testing the whole program or a single FC/FB.
  2. Add TEST_ENTRY "..."; for whole-program tests when the entrypoint is not OB1.
  3. Add TEST_TARGET "..."; for unit tests that should run against one FC or FB.
  4. In each TEST_CASE, set the starting state explicitly with SET.
  5. Use HOLD when an input or tag must be re-driven before every scan executed by a wait step.
  6. Advance execution with WAIT_CYCLES, WAIT_TIME, or WAIT_UNTIL ... TIMEOUT ... depending on whether you want scan-based, duration-based, or condition-based waiting.
  7. Check the result with ASSERT <boolean-expr>;.

File Layout

TEST_ENTRY "MainProgram";

TEST_CASE "first scenario"
  SET "DB_Main".Enable := TRUE;
  WAIT_CYCLES 1;
  ASSERT "DB_Status".Ready = TRUE;
END_TEST_CASE

TEST_CASE "second scenario"
  SET "DB_Main".Enable := FALSE;
  WAIT_CYCLES 1;
  ASSERT "DB_Status".Ready = FALSE;
END_TEST_CASE

Choosing Paths

  • Whole-program tests read and write normal PLC designators such as "DB_Main".Counter or Motor_DB.Running.
  • Unit-target tests use UUT.
  • For FB targets, access members directly: UUT.Enable, UUT.Running, UUT.State.
  • For FC targets, use the function contract shape:
  • inputs: UUT.In.<name>
  • outputs: UUT.Out.<name>
  • inouts: UUT.InOut.<name>
  • return value: UUT.Result

Writing Stable Cases

  • Set every value your case depends on instead of assuming a previous case left the right state behind.
  • Use HOLD path := value; for transient inputs that the PLC logic clears or consumes each scan. Held assignments are replayed in declaration order before every scan triggered by WAIT_CYCLES, WAIT_TIME, and WAIT_UNTIL.
  • Use RELEASE path; to stop replaying one held designator while leaving the current memory value untouched.
  • Use RELEASE_ALL; to clear the held-assignment driver completely.
  • Use WAIT_CYCLES 1 after changing inputs when you expect the PLC logic to react on the next scan. Each requested cycle advances the deterministic test clock by 1ms, reapplies held assignments, and then runs one scan.
  • Use WAIT_TIME T#...; when the scenario is easier to express as a fixed duration. It advances the deterministic test clock by the full duration once, reapplies held assignments once, and then runs one scan.
  • Use WAIT_UNTIL <left> = <right> TIMEOUT T#...; when you want to wait for a condition but still fail deterministically if it never arrives. It checks the condition, then on each polling pass reapplies held assignments, runs one scan, and advances the deterministic test clock by 1ms until the condition matches or the timeout deadline is exceeded.
  • Use multiple waits and assertions when you want to check a sequence over time.
  • Prefer small, named scenarios over one large TEST_CASE with many unrelated checks.

Wait Semantics

  • WAIT_CYCLES N; runs N scan iterations. Each iteration does: advance the deterministic test clock by 1ms, replay active HOLDs, then execute one PLC scan.
  • WAIT_TIME T#...; does one time jump and one scan. It advances the deterministic test clock by the full duration, replays active HOLDs once, then executes one PLC scan.
  • WAIT_UNTIL ... TIMEOUT T#...; loops until the condition matches or the timeout expires. Each polling pass checks the condition first, then replays active HOLDs, executes one PLC scan, and finally advances the deterministic test clock by 1ms.
  • TIMEOUT is measured against the deterministic test clock, not wall-clock runtime. A long-running real test process does not by itself consume timeout budget.
  • Because WAIT_UNTIL checks its condition before the timeout comparison on each loop, a condition that becomes true exactly at the deadline still succeeds.

Assertions

  • Prefer ASSERT <boolean-expr>; for normal checks such as ASSERT UUT.Y > 10.0; or ASSERT UUT.Ready = TRUE;.
  • ASSERT accepts the normal .scltest expression language, so equality and comparison checks no longer need separate step keywords.
  • ASSERT_NEAR left := expected TOLERANCE delta; remains the dedicated tolerance-based form.

Editor Support

  • .scltest files provide syntax highlighting for suite directives, step keywords, comments, attributes, and time literals.
  • Hovering path segments shows the resolved type shape, including UUT contract paths such as UUT.In.<name>, UUT.Out.<name>, UUT.InOut.<name>, and UUT.Result.
  • Completions follow the test DSL structure: new files suggest TEST_TARGET / TEST_ENTRY, then TEST_CASE, and inside a case body they suggest step keywords and operand-oriented snippets.
  • Diagnostics reject invalid syntax and flag common semantic mistakes such as unknown TEST_TARGETs, invalid member paths, non-boolean ASSERT expressions, mismatched ASSERT_NEAR operands, and invalid WAIT_* operands.

Supported Authoring Modes

The framework supports two primary testing modes: Whole-Program and Unit-Target.

1. Whole-Program Testing

This mode tests the entire PLC application as it would run on a controller, starting from a specific Organization Block (OB).

Example: Default Entry (suite.scltest) By default, the framework starts execution at OB1.

// No TEST_TARGET or TEST_ENTRY means OB1 is the entrypoint
TEST_CASE "Increment Watchdog"
  SET "DB_Main".Watchdog := 0;
  WAIT_CYCLES 1;
  ASSERT "DB_Main".Watchdog = 1;
END_TEST_CASE

Example: Custom Entry (startup.scltest) You can specify a different Organization Block or entry point using TEST_ENTRY.

TEST_ENTRY "Main_OB";

TEST_CASE "System Startup"
  SET "Control_DB".SystemEnable := TRUE;
  WAIT_CYCLES 1;
  ASSERT "Status_DB".Ready = TRUE;
END_TEST_CASE

2. Unit-Target Testing

This mode isolates a specific Function (FC) or Function Block (FB) for unit testing. The framework automatically manages the instance data and execution harness.

Example: Function Block Unit Test (motor_fb.scltest)

TEST_TARGET "MotorControl_FB";

TEST_CASE "Start Motor"
  HOLD UUT.Start := TRUE;
  SET UUT.Stop := FALSE;
  WAIT_CYCLES 1;
  ASSERT UUT.Running = TRUE;
  RELEASE_ALL;
END_TEST_CASE

TEST_CASE "Stop Motor"
  SET UUT.Start := FALSE;
  SET UUT.Stop := TRUE;
  WAIT_CYCLES 1;
  ASSERT UUT.Running = FALSE;
END_TEST_CASE

Example: Function Unit Test (math_fc.scltest)

TEST_TARGET "Add_FC";

TEST_CASE "Addition"
  SET UUT.In.Left := 10;
  SET UUT.In.Right := 5;
  WAIT_CYCLES 1;
  ASSERT UUT.Result = 15;
END_TEST_CASE
  • UUT (Unit Under Test) refers to the isolated block instance.
  • For FBs: Members are accessed directly via UUT.<member_name>. This includes VAR_INPUT, VAR_OUTPUT, VAR_IN_OUT, and VAR (static) members.
  • For FCs: Input and output parameters are accessed via UUT.In.<name>, UUT.Out.<name>, or UUT.InOut.<name>. The return value is available via UUT.Result.

Test Directives

DirectiveDescription
TEST_TARGET "Name";Optional. Sets the unit-test target (FB/FC name).
TEST_ENTRY "Name";Optional. Sets the whole-program entry point (e.g. "OB1").
TEST_CASE "Name"Defines a new test case.
SET path := value;Forces a value into a tag or UUT field.
HOLD path := value;Forces a value now and re-applies it before every wait-driven scan until released.
RELEASE path;Stops replaying one held assignment.
RELEASE_ALL;Clears all held assignments for the current test case.
ASSERT <boolean-expr>;Verifies that the condition evaluates to TRUE. Preferred for equality, inequality, and range checks.
ASSERT_NEAR left := expected TOLERANCE delta;Verifies that the left expression is within the numeric tolerance of the expected value.
WAIT_CYCLES N;Executes N scan iterations; each one advances the deterministic test clock by 1ms, replays held assignments, and runs one PLC scan.
WAIT_TIME T#...;Advances the deterministic test clock by the full duration once, replays held assignments once, and runs one PLC scan.
WAIT_UNTIL ... TIMEOUT T#...;Repeatedly checks the condition, replays held assignments, runs one PLC scan, and advances the deterministic test clock by 1ms until the condition matches or the timeout expires.

Running Tests

  1. Open a .scltest file in VS Code.
  2. Use the Test Explorer in the Primary Side Bar to discover and run tests.
  3. Results are reported directly in the editor and the Test Results view.

CI Integration

Tests can be run from the command line with plccheck, which makes the same test suites usable outside VS Code in CI jobs or local terminal workflows.

npx plccheck test ./my-plc-project

For machine-readable CI output, emit the event stream and fail the job on the plccheck exit code:

npx plccheck test ./my-plc-project --events-json

Code Coverage

The testing framework can generate code coverage reports for SCL source files.

Generating Coverage Artifacts

Use the plccheck test command with coverage flags:

npx plccheck test ./my-plc-project --coverage-json coverage.json --coverage-lcov coverage.info

If you want Coverage Gutters to pick the LCOV file up without extra workspace settings, emit the default filename it already watches:

npx plccheck test ./my-plc-project --coverage-json coverage.json --coverage-lcov lcov.info
  • --coverage-json <file>: Writes a structured JSON report including statement, function, and branch coverage where runtime data is available. This is the preferred format for AI analysis and custom tooling.
  • --coverage-lcov <file>: Writes a standard LCOV tracefile for integration with existing coverage viewers (e.g., the Coverage Gutters VS Code extension), including matching function and branch records where runtime data is available.

Coverage JSON vs. event JSON

  • Use the raw --events-json flag to receive a real-time event stream of test execution on stdout.
  • Use --coverage-json <file> to write the finalized coverage summary to a separate file after the run completes.
  • Coverage artifacts are written after the run completes. The event stream remains separate from finalized coverage reports.

Path Resolution

Both JSON and LCOV artifacts use slash-separated paths relative to the PLC root (the directory containing .plc.json).

VS Code Coverage View

  • The VS Code Test Explorer shows the statement-focused file summary while retaining branch and function details for views that can display them.

Known Limitations

  • Runtime compatibility is strongest for the common SCL instructions and block patterns used by the supported test runtime.
  • Some lesser-used Siemens instructions and library blocks may not be implemented yet.
  • If your project depends on a specific missing instruction or block, send a feature request with a small reproducible example so it can be prioritized.