Table of Contents

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).


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 protected members 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 DataRow attributes - 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 .plcproj file, 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):

  1. Patches TcUnit parameters on the test .plcproj (xUnit result path, buffer sizes, and similar).
  2. Assigns isolated CPU cores on the test target from --netid / target-netid (same behaviour as generated ./tests projects).
  3. 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 a tests/.Zeugwerk/config.json exists, the generated/manual ./tests flow takes precedence.
  • --test-framework applies to Option A embedded tests only, not to in-solution TcUnit.

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.