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
- Decide whether you are testing the whole program or a single FC/FB.
- Add
TEST_ENTRY "...";for whole-program tests when the entrypoint is notOB1. - Add
TEST_TARGET "...";for unit tests that should run against one FC or FB. - In each
TEST_CASE, set the starting state explicitly withSET. - Use
HOLDwhen an input or tag must be re-driven before every scan executed by a wait step. - Advance execution with
WAIT_CYCLES,WAIT_TIME, orWAIT_UNTIL ... TIMEOUT ...depending on whether you want scan-based, duration-based, or condition-based waiting. - 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".CounterorMotor_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 byWAIT_CYCLES,WAIT_TIME, andWAIT_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 1after 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_CASEwith many unrelated checks.
Wait Semantics
WAIT_CYCLES N;runsNscan iterations. Each iteration does: advance the deterministic test clock by 1ms, replay activeHOLDs, 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 activeHOLDs 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 activeHOLDs, executes one PLC scan, and finally advances the deterministic test clock by 1ms.TIMEOUTis 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_UNTILchecks 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 asASSERT UUT.Y > 10.0;orASSERT UUT.Ready = TRUE;. ASSERTaccepts the normal.scltestexpression 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
.scltestfiles provide syntax highlighting for suite directives, step keywords, comments, attributes, and time literals.- Hovering path segments shows the resolved type shape, including
UUTcontract paths such asUUT.In.<name>,UUT.Out.<name>,UUT.InOut.<name>, andUUT.Result. - Completions follow the test DSL structure: new files suggest
TEST_TARGET/TEST_ENTRY, thenTEST_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-booleanASSERTexpressions, mismatchedASSERT_NEARoperands, and invalidWAIT_*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 includesVAR_INPUT,VAR_OUTPUT,VAR_IN_OUT, andVAR(static) members. - For FCs: Input and output parameters are accessed via
UUT.In.<name>,UUT.Out.<name>, orUUT.InOut.<name>. The return value is available viaUUT.Result.
Test Directives
| Directive | Description |
|---|---|
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
- Open a
.scltestfile in VS Code. - Use the Test Explorer in the Primary Side Bar to discover and run tests.
- 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-jsonflag 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.