Tests¶
Two kinds of tests:
- ST tests in simulation (
cts test): tests written in ST, in a test application that only ever runs in simulation. For the logic of POUs. - JSON test plans (
cts verify --with-test): variable writes and reads against an application online, on a PLC or in simulation.
When the project already has its test application (cts test runs rather
than refuses), a new POU and its test take four steps: write the POU's .st
at the top of project-view/ (shared by every application), add its
Check/CheckReal calls to PRG_Tests.st, cts import, cts test. The
rest of this guide sets the test application up and explains the plans.
ST tests in simulation¶
The POUs in the POUs view are shared by every application of the project. A second device holds the tests:
PLC_Main the device of the real PLC
Plc Logic/Application the production application (active)
Test a second device, always in simulation
Plc Logic/TestApp
Library Manager with CmpLog (the runtime's log) and Standard
TestResult GVL: the counts cts reads
TestLog FUNCTION: writes one CTS| record to the log
Check, CheckReal FUNCTION: count and log one check
PRG_Tests PROGRAM: the tests
Task Configuration/TestTask calls PRG_Tests
POUs the project root, shared: the code under test
F_Clamp
In the sync folder the project root is the top of project-view/
(project-view/F_Clamp.st); the test application's objects are under
project-view/Test/Plc Logic/TestApp/.
cts test makes TestApp the active application, downloads it to the
simulation, starts it, waits until TestResult.Done is TRUE, reads the
counts, reads the simulation's log from the run's TEST_START record on,
logs TestApp out and makes the earlier application active again. A device
that is not in simulation is refused unless --plc says the tests may go to
that PLC: then TestApp is downloaded there (the deny list's download
applies) and the tests run on the real runtime. --repeat N runs them N
times, each run after the first started again by a warm reset when the deny
list allows reset_plc, else by another download (restart= on the line);
the line counts the failed runs (failed_runs=) and names each failing test
with its run, which finds a test that fails now and then. The records reach
the log a moment after TestResult.Done: each run waits up to 5 s for its
own TEST_START and as many results as the counts; a run whose TEST_START
does not come did not run (its counts are an earlier run's) and fails. Exit 0 when every test of every run passed.
The wait ends early when the application stops on an exception, leaves run
after it ran, or stops answering the reads of TestResult: the line says
which, how long after the start and how many tests had counted, and quotes
the exception the runtime logged (runtime: #### Exception: ...). A test
that never ends without an exception (an endless loop) holds its task until
--wait runs out; the error then says when the counts last moved. A watchdog
on the test task (Task Configuration, TestTask, Watchdog) turns such a loop
into an exception, which ends the wait at once. Known runtime lines in the
run's log come with a hint: (TLS contexts used up, an exception).
Every run leaves its log: the records the tests wrote and whatever the
runtime logged meanwhile (an exception, a watchdog), in the runtime's log
file format. It goes to --out (a file, or a folder for
<application>.log), else to <sync folder>/.git/cts/test/<application>.log;
the line names it as log=.
cts test # TestApp
cts test --app UnitTests --wait 60 --out results/
ok test passed=3 failed=0 application=TestApp device=Test ms=5210 log=C:\plant\.git\cts\test\TestApp.log
error test passed=2 failed=1 application=TestApp device=Test ms=5180 log=C:\plant\.git\cts\test\TestApp.log
FAIL F_Clamp cuts to hi got=11.0 want=10.0
--json lists every test as tests (name, status PASS/FAIL, fields)
and the run's log entries as log.
Set it up once from files, by copying the PLC's (the same device type is
fine) in project-view/:
- the device's
.cds-object.xmltoTest/, itsNamechanged toTestand itsSimulationModetoTrue; - its
Plc Logic/.cds-object.xmltoTest/Plc Logic/; - the application's
.cds-object.xmlandLibrary Manager.xmltoTest/Plc Logic/TestApp/, theNamechanged toTestApp; - its
Task Configuration/.cds-object.xml, and one task file asTask Configuration/TestTask.xml, itsNamechanged toTestTaskandPRG_Teststhe only POU in itsPouList.
Add the GVL, the functions and the program below as .st files in
TestApp/, run cts lib add "CmpLog, * (System)" --app TestApp (Standard,
for CONCAT, came with the copied Library Manager), then one cts import
creates it all, outer objects first, and writes the copies back with the
GUIDs the IDE gave them. A Name that differs from its file or folder is
refused before the IDE. Only the code a task calls is downloaded, so the
tests reach the shared POUs through PRG_Tests.
The result GVL, its names fixed:
{attribute 'qualified_only'}
VAR_GLOBAL
Done : BOOL; // TRUE once every test has run
Passed : UDINT;
Failed : UDINT;
END_VAR
The log records, one per runtime log entry (cts plc-log --cts reads the
same format; an empty result with no malformed records points to the raw log tail):
CTS|M|TEST_START|<application>| before the first test: the run's log starts here
CTS|M|TEST_PASS|<test>|
CTS|M|TEST_FAIL|<test>|k=v;k=v what the test saw, e.g. got=11.0;want=10.0
A record is cut at 127 characters, so keep test names short, and a value
holds neither | nor ;.
FUNCTION TestLog : BOOL
VAR_INPUT
code : STRING(20);
tag : STRING(100);
fields : STRING(100);
END_VAR
CmpLog.LogAdd2(CmpLog.LogConstants.STD_LOGGER, 0, CmpLog.LogClass.LOG_INFO, 0, 0,
CONCAT(CONCAT(CONCAT('CTS|M|', code), '|'), CONCAT(CONCAT(tag, '|'), fields)));
TestLog := TRUE;
One check, and one for numbers that logs what it got. It compares within a millionth (of the value, or absolute below 1): a computed LREAL rarely equals its decimal literal bit for bit.
FUNCTION Check : BOOL
VAR_INPUT
ok : BOOL;
name : STRING(100);
detail : STRING(100);
END_VAR
IF ok THEN
TestResult.Passed := TestResult.Passed + 1;
TestLog('TEST_PASS', name, '');
ELSE
TestResult.Failed := TestResult.Failed + 1;
TestLog('TEST_FAIL', name, detail);
END_IF
Check := ok;
FUNCTION CheckReal : BOOL
VAR_INPUT
got : LREAL;
want : LREAL;
name : STRING(100);
END_VAR
CheckReal := Check(ABS(got - want) <= 1.0E-6 * MAX(1.0, ABS(want)), name,
CONCAT(CONCAT('got=', LREAL_TO_STRING(got)), CONCAT(';want=', LREAL_TO_STRING(want))));
The tests, run once:
PROGRAM PRG_Tests
IF NOT TestResult.Done THEN
TestLog('TEST_START', 'TestApp', '');
CheckReal(F_Clamp(5, 0, 10), 5, 'F_Clamp keeps a value inside');
CheckReal(F_Clamp(-1, 0, 10), 0, 'F_Clamp lifts to lo');
CheckReal(F_Clamp(11, 0, 10), 10, 'F_Clamp cuts to hi');
TestResult.Done := TRUE;
END_IF
A new shared POU and its tests: create it at the project root, add its checks
to PRG_Tests, carry both into the IDE and run the tests.
# pou: the object kind; --kind: which POU
cts new pou F_Lerp --kind function --return-type REAL --parent . \
--text "VAR_INPUT a, b, t : REAL; END_VAR F_Lerp := a + (b - a) * t;"
# PRG_Tests.st: CheckReal(F_Lerp(0, 10, 0.5), 5, 'F_Lerp halfway');
cts import
cts test
cts test runs what the IDE holds, saved or not: no cts save is needed
between the import and the test, and the test saves nothing.
A test that needs several cycles (a timer, a state machine) keeps its own
step counter in PRG_Tests and sets Done when the last step has checked;
--wait (seconds, default 30) is how long cts waits for it.
On a PLC (--plc), switch off the test application's boot application
(TestApp -> Properties -> Boot application -> Implicit boot application on
download); cts test names it while it is on. Otherwise every download
leaves a boot application, and a restart of the runtime runs the tests
again, a test that never ends included, so the restart does not free the
PLC. To recover from such a loop: stop the runtime, move the test
application's boot files (<App>.app and <App>.crc in the runtime's
PlcLogic/<App>/) aside, start it, then cts test --plc downloads afresh.
JSON test plans¶
cts verify --with-test runs JSON test plans against a CODESYS online
application. The in-IDE agent runs them (test.run), one agent call per step:
login, start, variable writes and reads. A wait step does not hold the IDE,
which stays usable while a plan runs.
Location¶
Test plans live in the project sync folder:
<sync-folder>/
.test/
arithmetic.json
counter.json
project-view/
.dump/
Run one file:
cts verify --with-test --test-file arithmetic.json
Run all plans from .test/ in sorted order:
cts verify --with-test
The agent method directly:
cts raw run_tests --folder <sync-folder> --file arithmetic.json --timeout 120
Plan Object¶
Every plan names the CODESYS application under test, and that application must be the active one in the IDE: a plan for another application is refused, not switched to.
{
"name": "Arithmetic smoke test",
"application": "CI_CD_Application",
"ip": "192.0.2.10",
"gateway": "Gateway-1",
"start": false,
"timeout": 30000,
"continue_on_fail": false,
"tests": [
{
"name": "add",
"steps": [
{ "action": "wait", "ms": 100 }
]
}
]
}
| Field | Type | Required | Meaning |
|---|---|---|---|
name |
string | no | Human-readable plan name. |
application |
string | yes | CODESYS application name. |
ip |
string | no | PLC IP. If omitted, the project online configuration is used. |
gateway |
string | no | Gateway name, with ip. Default: Gateway-1. |
start |
bool | no | Start application before steps. Default: true. |
timeout |
number | no | Plan timeout in milliseconds. Default: 60000. |
continue_on_fail |
bool | no | Continue after failed tests. Default: false. |
reset |
string | no | "cold" performs one cold reset before the first plan that asks for it. |
tests |
array | yes | Test cases to execute. |
Before each plan, the agent logs in to the active application (cts connect:
no online change, no download), checks it is the plan's application, resets
and starts it as the plan says, then executes the tests in order.
Test Object¶
{
"name": "FB_Arithmetic add",
"timeout": 5000,
"continue_on_fail": false,
"steps": [
{ "action": "write", "variable": "MAIN.fbArith.rA", "value": 10.0 },
{ "action": "write", "variable": "MAIN.fbArith.rB", "value": 3.0 },
{ "action": "wait", "ms": 200 },
{ "action": "read", "variable": "MAIN.fbArith.rResult", "expected": 13.0, "tolerance": 0.001 }
]
}
| Field | Type | Required | Meaning |
|---|---|---|---|
name |
string | no | Human-readable test name. |
timeout |
number | no | Test timeout in milliseconds, within the plan's. |
continue_on_fail |
bool | no | Continue steps after a failed step. |
steps |
array | yes | Steps to execute in order. |
Steps¶
write¶
{ "action": "write", "variable": "MAIN.fbArith.rA", "value": 10.0 }
Writes one online variable. A JSON bool goes as TRUE/FALSE, a number as
its text; a string is the IEC literal as written (WORD#5, E_Mode.Run).
wait¶
{ "action": "wait", "ms": 200 }
Waits for the requested number of milliseconds (default 100), cut short at the timeout.
read¶
{
"action": "read",
"variable": "MAIN.fbArith.rResult",
"expected": 13.0,
"tolerance": 0.001
}
Reads one online variable. If expectation fields are present, the step also asserts the value.
Supported expectation fields:
| Field | Meaning |
|---|---|
expected |
Exact expected value. Numeric values use tolerance. |
expected_min |
Minimum accepted numeric value. |
expected_max |
Maximum accepted numeric value. |
tolerance |
Allowed absolute numeric delta. |
assert¶
{ "action": "assert", "variable": "MAIN.fbArith.xDone", "expected": true }
The same as read.
Values¶
A read gives the value as the IDE holds it: a BOOL as true/false, a number
as a number, anything else as the IDE's text. expected compares a bool with
a bool, a number within tolerance, and text ignoring case against the value
or the IDE's spelling of it (E_Mode.Run).
FB Testability Rule¶
For online tests, MAIN must pass FB instance fields through to the FB call.
Otherwise the PLC program overwrites values written by the CLI before the FB
can use them.
Wrong:
fbArith(rA := 0.0, rB := 0.0, eOp := 0, xExecute := FALSE);
Correct:
fbArith(rA := fbArith.rA, rB := fbArith.rB,
eOp := fbArith.eOp, xExecute := fbArith.xExecute);
Edge Triggers¶
Online writes are committed individually. The PLC may execute a cycle between
two writes. If an FB uses a rising-edge trigger, a fast false -> true
transition can be missed.
For FBs that are tested through the online API, prefer continuous execution:
IF xExecute THEN
rResult := rA + rB;
xDone := TRUE;
ELSE
xDone := FALSE;
END_IF
If rising-edge logic is required, add an explicit wait between reset and trigger writes:
{ "action": "write", "variable": "MAIN.fbArith.xExecute", "value": false },
{ "action": "wait", "ms": 100 },
{ "action": "write", "variable": "MAIN.fbArith.xExecute", "value": true }
Result¶
The CLI returns structured JSON.
Typical result shape:
{
"status": "SUCCESS",
"ok": true,
"summary": {
"ok": 3,
"not_ok": 0,
"total": 3,
"files": 2
},
"files": [
{
"file": "arithmetic.json",
"plan": "Arithmetic smoke test",
"status": "SUCCESS",
"ok": true,
"tests_ok": 2,
"tests_failed": 0,
"total_ms": 512
}
],
"results": []
}
results holds one report per plan: the tests, and per test its steps with
status, the value read and the error of a failed step.
Common Failures¶
| Error | Meaning | Fix |
|---|---|---|
Test plan must name its application |
Missing application. |
Add the exact CODESYS application name. |
... but the active application is '...' |
The plan's application is not the active one. | Make it the active application in the IDE. |
Not connected |
No PLC session. | Check the PLC address, then cts connect. |
read X: ... |
The variable is not in the online application. | Check the variable path; an unreferenced variable is not downloaded. |
| Assertion mismatch | PLC logic ran but value did not match. | Inspect the failing step in results. |