Unit tests
Both zkbuild-action (cloud) and zkmake (on-premises) discover and run unit tests automatically as part of the build. Three approaches are supported; choose one per repository (see Choosing an approach below).
Option A - Embedded tests (recommended)
Write test function blocks directly inside your PLC project. The build tool detects them automatically based on the interface they implement and generates the necessary test infrastructure - no separate project or config file needed.
This approach is recommended for several reasons:
- Tests live next to the code. Test FBs sit in the same namespace and folder as the production code, making it immediately obvious what is tested and easy to keep tests and implementation in sync.
- Tests are stripped from release builds. Because the test FBs implement a dedicated interface, the build tool can exclude them when compiling the release version of a library, keeping the shipped binary clean.
- No extra PLC Task required. For PLC libraries especially, adding a separate test project forces you to declare a Task just to run the tests. Embedded tests avoid this overhead entirely.
- Access to protected members. A test FB that extends a production FB (inheritance) can access
protectedmembers directly. Structured Text has no reflection or friend-class mechanism, so inheritance is the most practical way to reach internal state without making it fully public - and Option A makes this pattern natural. - DataRow support for parameterized tests. The build tool generates test infrastructure from your source, which enables
DataRowattributes - the same concept as parameterized tests in NUnit or JUnit. Each row becomes a separate named test case in the results, so you can cover many input combinations without duplicating assertion logic. This is the primary reason for the generator approach. - Single project to maintain. There is only one
.plcprojfile, one set of library references, and one place to look for both the implementation and its tests, which reduces project complexity and the risk of dependency drift between the two.
Supported interfaces:
| Interface | Package required | Notes |
|---|---|---|
ZCore.IUnittest |
Zeugwerk Framework | Recommended for Zeugwerk Framework users |
Testbench.IUnittest |
Testbench | Lightweight alternative without the full framework |
Do not mix both interfaces in the same project.
Test method signatures
Two method signatures are recognized. Use the one that matches your test's needs:
Stateless test - for pure logic that does not depend on time or scan cycles:
METHOD <TestName>
VAR_INPUT
assertions : ZCore.IAssertions; // or Testbench.IAssertions
END_VAR
Timed test - for tests that require actual PLC execution cycles (timers, state machines, real-time behaviour). This is the signature most real-world tests use because it avoids having to mock away all time dependencies:
METHOD <TestName>
VAR_INPUT
assertions : ZCore.IAssertions; // or Testbench.IAssertions
context : ZCore.ITestContext;
END_VAR
ITestContext gives the test method access to cycle information and helpers for advancing simulated time across multiple scan cycles within a single test run.
Any method with either of these signatures on a function block that implements the test interface is automatically treated as a test case.
DataRows - parameterized tests
The test generator approach unlocks DataRows, the primary reason for generating test infrastructure rather than writing it by hand. A DataRow attribute attaches a concrete set of input values to a test method; the generator produces a separate, named test case for every row, all sharing the same assertion logic.
{attribute 'DataRow(0, 0)'}
{attribute 'DataRow(100, 100)'}
{attribute 'DataRow(1000, 500)'} // clamped at 500
{attribute 'DataRow(-100, -100)'}
METHOD TestVelocityClamping
VAR_INPUT
assertions : ZCore.IAssertions;
input : DINT;
expected : DINT;
END_VAR
assertions.EqualsDint(expected, velocity_controller.Clamp(input), 'unexpected clamped value');
END_METHOD
Each DataRow generates its own entry in the JUnit XML results, so failures pinpoint the exact input combination rather than just the method name.
Example
FUNCTION_BLOCK MotionTest EXTENDS MotionController IMPLEMENTS ZCore.IUnittest
METHOD TestPositiveVelocity
VAR_INPUT
assertions : ZCore.IAssertions;
END_VAR
assertions.EqualsDint(100, MotionController_instance.SetVelocity(100), 'velocity should be clamped');
END_METHOD
METHOD TestNegativeVelocity
VAR_INPUT
assertions : ZCore.IAssertions;
END_VAR
assertions.EqualsDint(-100, MotionController_instance.SetVelocity(-100), 'negative velocity should be supported');
END_METHOD
{attribute 'DataRow(0, 200, 200)'}
{attribute 'DataRow(500, 200, 500)'}
{attribute 'DataRow(500, -200, 300)'}
METHOD TestPositionAfterMove
VAR_INPUT
assertions : ZCore.IAssertions;
context : ZCore.ITestContext;
start_pos : DINT;
move_distance : DINT;
expected_pos : DINT;
END_VAR
// context lets the test wait for the move to complete across real scan cycles
MotionController_instance.Position := start_pos;
MotionController_instance.Move(move_distance);
context.WaitUntil(condition := MotionController_instance.Done, timeout := T#2S);
assertions.EqualsDint(expected_pos, MotionController_instance.Position, 'wrong final position');
END_METHOD
Option B - Separate test project
Place a standalone TcUnit test project in a tests/ subfolder at the repository root. The build tool detects the tests/.Zeugwerk/config.json file and includes the test project in the test phase automatically.
my-repo/
├── .Zeugwerk/
│ └── config.json ← main project config
├── MyProject/
│ └── MyProject.plcproj
└── tests/
├── .Zeugwerk/
│ └── config.json ← test project config
└── MyProject_Tests/
└── MyProject_Tests.plcproj
This approach works well when you want to keep test code entirely separate, or when using TcUnit independently.
Option C - In-solution test project
Keep a standalone TwinCAT test application in the same solution as your library or application. The test project is a normal .tsproj / .plcproj in your repository (not under a generated tests/ folder). DevTools discovers it through the main .Zeugwerk/config.json by declaring the PLC type as UnitTestApplication.
This approach suits projects that already use native TcUnit (FB_TestSuite, TcUnit.RUN(), and related patterns) rather than Zeugwerk embedded IUnitTest function blocks.
my-repo/
├── .Zeugwerk/
│ └── config.json ← library + test PLC (type: UnitTestApplication)
├── MyLibrary/
│ └── MyLibrary.plcproj
├── MyLibrary.sln ← includes library and test projects
└── MyLibraryTest/
├── MyLibraryTest.tsproj
└── MyLibraryTests/
└── MyLibraryTests.plcproj
config.json
Add a second entry under projects for the test Visual Studio project. Set type to UnitTestApplication and declare TcUnit (or your test dependencies) under packages, plus library references under references:
{
"name": "MyLibraryTest",
"plcs": [
{
"name": "MyLibraryTests",
"version": "1.0.0.0",
"type": "UnitTestApplication",
"packages": [
{
"name": "TcUnit",
"version": null,
"distributor-name": "www.tcunit.org"
}
],
"references": {
"*": [
"Tc2_Standard=*",
"Tc2_System=*",
"Tc3_Module=*",
"[MyLibrary]=*"
]
}
}
]
}
The test project must be part of the same solution referenced at the top of config.json.
What the build does
During the release build (no separate testable-library pass):
- Patches TcUnit parameters on the test
.plcproj(xUnit result path, buffer sizes, and similar). - Assigns isolated CPU cores on the test target from
--netid/target-netid(same behaviour as generated./testsprojects). - Compiles the full solution, including the test application, via headless TcXaeShell.
During the test phase, DevTools deploys the built test application from the test project's _Boot folder to the configured target and collects JUnit/xUnit XML results. No test harness is generated and --test-framework is not used for this path.
When to prefer Option C
- You already maintain a TcUnit test project next to your library (forks, third-party libraries, legacy test suites).
- You want tests versioned and reviewed as normal solution source, not generated under
tests/. - You use standard TcUnit patterns rather than
ZCore.IUnitTest/Testbench.IUnitTest.
Limitations
- No automatic DataRow generation (Option A only).
- Do not combine with Option B (
tests/at repo root): if atests/.Zeugwerk/config.jsonexists, the generated/manual./testsflow takes precedence. --test-frameworkapplies to Option A embedded tests only, not to in-solutionTcUnit.
Choosing an approach
| Approach | Test style | Config | --test-framework |
|---|---|---|---|
| A - Embedded | ZCore.IUnitTest / Testbench.IUnitTest |
Main config.json only |
Yes (tcunit-zeugwerk default) |
B - tests/ folder |
TcUnit or Zeugwerk in a separate tree | tests/.Zeugwerk/config.json |
Yes (embedded tests in main PLC) |
| C - In-solution | Native TcUnit in same .sln |
UnitTestApplication in main config.json |
No |
Pick one primary strategy per repository. Option C is detected when no embedded tests and no tests/ config apply, but a UnitTestApplication PLC is declared.
Test results
Test results are written in JUnit XML format and are compatible with all major CI/CD reporting tools.
| Tool | Result file (generated / manual tests/) |
Result file (in-solution) |
|---|---|---|
zkbuild-action (cloud) |
archive/test/TcUnit_xUnit_results.xml |
archive/tests/<TestProjectFolder>/TcUnit_xUnit_results.xml |
zkmake (on-premises) |
tests\TcUnit_xUnit_results.xml |
tests\<TestProjectFolder>\TcUnit_xUnit_results.xml |
<TestProjectFolder> is the directory that contains the test .tsproj (e.g. TcOscatBasicTest).
For CI pipelines that run both layouts or use glob patterns, archive/tests/**/TcUnit_xUnit_results.xml or tests/**/TcUnit_xUnit_results.xml works for in-solution projects.
GitHub Actions
- name: Publish test results
uses: EnricoMi/publish-unit-test-result-action@v1
with:
files: archive/tests/**/TcUnit_xUnit_results.xml
For Option A/B only (single file under tests/):
files: archive/test/TcUnit_xUnit_results.xml
Jenkins
junit testResults: 'tests/**/TcUnit_xUnit_results.xml', allowEmptyResults: true
Azure DevOps
- task: PublishTestResults@2
inputs:
testResultsFormat: JUnit
testResultsFiles: tests/**/TcUnit_xUnit_results.xml
failTaskOnFailedTests: true
Skipping tests
Both tools support skipping tests for faster feedback when test results are not needed (e.g. draft PRs or when only producing build artifacts):
| Tool | Option |
|---|---|
zkbuild-action |
skip-test: true (action input) |
zkmake |
--skip-tests (command-line flag) |
Test framework selection (zkmake, Option A only)
When using embedded tests (Option A), the --test-framework option on the build command controls how tests are generated and executed. It does not apply to in-solution UnitTestApplication projects (Option C), which use the TcUnit package declared in config.json as-is.
Two values are supported for Option A:
| Value | Description |
|---|---|
tcunit-zeugwerk |
Recommended. Zeugwerk's fork of TcUnit that includes additional improvements (DataRow support, ITestContext, and other contributions) that have not yet been merged into the upstream TcUnit project. |
tcunit |
Plain upstream TcUnit. Use this if you prefer to stay on the official release or if your project already depends on it directly. |
The default is tcunit-zeugwerk. This option has no equivalent in zkbuild-action; the cloud backend selects the appropriate framework automatically.