How to Write Unit Tests in SIMATIC AX — A Step-by-Step Guide for TIA Portal Engineers

March 27, 2026 · simatic-axtia-portalplc-programmingsiemensunit-testingaxunits7-1500

How to Write Unit Tests in SIMATIC AX — A Step-by-Step Guide for TIA Portal Engineers

In the previous article, I showed what unit testing is and why it matters. Eight tests. One command. All passing — no PLC needed.

Now let us actually write those tests. Step by step, from an empty file to a passing test suite.

Everything in this article is real code from my demo project. If you have SIMATIC AX installed, you can follow along.

Who this is for: PLC programmers and automation engineers who work in TIA Portal and are exploring SIMATIC AX. You should have read Part 1: Unit Testing in SIMATIC AX Explained first — it covers what unit testing is and why it matters. This article picks up where that one left off.

This is the sixth post in my SIMATIC AX series. Previous posts: You Don’t Need AX Code IDE, Two Types of SIMATIC AX Projects, SIMATIC AX Project Structure Explained, Namespaces in SIMATIC AX Explained, and Unit Testing in SIMATIC AX Explained.

What You Need Before Starting

You need a SIMATIC AX project with at least one function block to test. Any function block will do — but to follow along with the examples in this article, here is the temperature controller I have been using throughout this series.

It is a simple ON/OFF controller with hysteresis and fault detection:

NAMESPACE Otomakeit.Demo.TempControl

    FUNCTION_BLOCK TempController

        VAR_INPUT
            i_rActualTemp  : REAL;    // Measured temperature
            i_rSetpoint    : REAL;    // Desired temperature setpoint
            i_rHysteresis  : REAL;    // Hysteresis band (both sides of setpoint)
            i_xEnable      : BOOL;    // Enable the controller
        END_VAR

        VAR_OUTPUT
            q_xHeating     : BOOL;    // Heating output
            q_xFault       : BOOL;    // Fault flag (temperature out of range)
            q_rError       : REAL;    // Control error: setpoint - actual
        END_VAR

        VAR
            _xHeatingState : BOOL;    // Latched heating state
        END_VAR

        VAR CONSTANT
            TEMP_MIN_VALID : REAL := REAL#-50.0;
            TEMP_MAX_VALID : REAL := REAL#500.0;
        END_VAR

        // Fault detection — sensor out of valid range
        IF i_rActualTemp < TEMP_MIN_VALID OR i_rActualTemp > TEMP_MAX_VALID THEN
            q_xFault := TRUE;
        ELSE
            q_xFault := FALSE;
        END_IF;

        // Error calculation
        q_rError := i_rSetpoint - i_rActualTemp;

        // Hysteresis control logic
        IF i_xEnable AND NOT q_xFault THEN
            IF i_rActualTemp < (i_rSetpoint - i_rHysteresis) THEN
                _xHeatingState := TRUE;
            END_IF;
            IF i_rActualTemp > (i_rSetpoint + i_rHysteresis) THEN
                _xHeatingState := FALSE;
            END_IF;
            q_xHeating := _xHeatingState;
        ELSE
            _xHeatingState := FALSE;
            q_xHeating := FALSE;
        END_IF;

    END_FUNCTION_BLOCK

END_NAMESPACE

Put this in src/Control/TempController.st in your project. Your project structure should look like this:

my-project/
├── apax.yml
├── src/
│   └── Control/
│       └── TempController.st
└── test/
    └── TempController_Test.st      ← this is what we are building

The test/ folder is where AxUnit looks for tests. If it does not exist yet, create it.

One more thing — your apax.yml already includes AxUnit as part of the standard @ax/sdk dependency. No extra packages to install.

The Anatomy of a Test File

Let me show you the complete test file first, then break down every piece.

USING AxUnit.Assert;
USING Otomakeit.Demo.TempControl;

NAMESPACE Otomakeit.Demo.Tests

    {TestFixture}
    CLASS TestTempController

        VAR
            _fb    : TempController;
            _clean : TempController;
        END_VAR

        {TestSetup}
        METHOD PUBLIC TestSetup
            _fb := _clean;
        END_METHOD

        {Test}
        METHOD PUBLIC WhenTempBelowLowerThreshold_HeatingTurnsOn
            _fb(
                i_rActualTemp  := REAL#94.0,
                i_rSetpoint    := REAL#100.0,
                i_rHysteresis  := REAL#5.0,
                i_xEnable      := TRUE
            );
            Equal(actual := _fb.q_xHeating, expected := TRUE);
            Equal(actual := _fb.q_xFault,   expected := FALSE);
        END_METHOD

    END_CLASS

END_NAMESPACE

That is a working test. Let me walk through each section.

The USING Statements

USING AxUnit.Assert;
USING Otomakeit.Demo.TempControl;

Two imports. The first brings in the AxUnit assertion methods — Equal, NotEqual, LessThan, and so on. The second imports the namespace where your function block lives.

If you have read the namespaces article, this is the same USING syntax. You are telling the compiler: “I need things from these namespaces.”

Without the first line, you would have to write AxUnit.Assert.Equal(...) every time. With it, you just write Equal(...).

The Test Namespace

NAMESPACE Otomakeit.Demo.Tests

Tests live in their own namespace. Convention: take your project namespace and add .Tests. This keeps test code separate from production code — they will never accidentally mix.

The Test Fixture

{TestFixture}
CLASS TestTempController

The {TestFixture} pragma tells AxUnit: “This class contains tests.” Without it, AxUnit ignores the class entirely.

The class name follows the pattern Test + the name of the function block you are testing. One class per function block. One file per class.

If you are coming from TIA Portal, a CLASS in Structured Text is similar to a FUNCTION_BLOCK — it holds variables and methods. The difference is that here, the “methods” are your individual tests.

The Variables

VAR
    _fb    : TempController;
    _clean : TempController;
END_VAR

Two instances of the function block you are testing. The first one (_fb) is the one you actually call in your tests. The second one (_clean) is never modified — it stays at its default values forever.

Why two? This is the setup for the reset pattern, which I explain next.

The Reset Pattern — Most Important Part

{TestSetup}
METHOD PUBLIC TestSetup
    _fb := _clean;
END_METHOD

The {TestSetup} pragma tells AxUnit: “Run this method before every single test.”

What it does: it copies the clean, never-touched instance into _fb. This resets the function block to its default state — all variables zeroed out, no residual state from the previous test.

This is critical. Without it, your tests affect each other. If Test A turns heating ON, then Test B starts with heating already ON. That means Test B might pass or fail depending on whether Test A ran first. Tests that depend on execution order are useless — you can never trust them.

The reset pattern guarantees every test starts from the same clean state. It does not matter if you run one test or all eight. It does not matter what order they run in. Each test is independent.

In TIA Portal terms: imagine if you could reset your function block’s instance data block to all zeros before every test scenario. That is exactly what this does — but automatically.

Your First Test

{Test}
METHOD PUBLIC WhenTempBelowLowerThreshold_HeatingTurnsOn
    _fb(
        i_rActualTemp  := REAL#94.0,
        i_rSetpoint    := REAL#100.0,
        i_rHysteresis  := REAL#5.0,
        i_xEnable      := TRUE
    );
    Equal(actual := _fb.q_xHeating, expected := TRUE);
    Equal(actual := _fb.q_xFault,   expected := FALSE);
END_METHOD

Three parts to every test:

1. The name. WhenTempBelowLowerThreshold_HeatingTurnsOn — this is not just a label. It is the specification. When you read the test results, this name tells you exactly what was tested and what the expected outcome is. Write names that read like sentences.

2. The action. Call the function block with specific inputs. Notice the typed literals — REAL#94.0, not just 94.0. SIMATIC AX requires explicit types. The setpoint is 100, hysteresis is 5, so the lower threshold is 95. An actual temperature of 94 is below that threshold.

3. The assertions. Equal(actual := _fb.q_xHeating, expected := TRUE) — check that the output matches what you expect. If it does not, the test fails and tells you exactly which assertion did not hold.

That is it. Setup, action, assertion. Every test follows this pattern.

The Assert Methods You Will Use

AxUnit gives you five assertion methods. In practice, you will use Equal about 90% of the time.

Equal — the workhorse:

Equal(actual := _fb.q_xHeating, expected := TRUE);
Equal(actual := _fb.q_rError, expected := REAL#20.0);

NotEqual — when you care that something is NOT a specific value:

NotEqual(actual := _fb.q_rError, expected := REAL#0.0);

LessThan and GreaterThan — for range checks:

LessThan(actual := _fb.q_rError, expected := REAL#50.0);
GreaterThan(actual := elapsed, expected := TIME#0S);

InRange — for values that should fall within bounds:

InRange(actual := scaledValue, lowerBound := REAL#99.5, upperBound := REAL#100.5);

All five take named parameters. Always actual first, then expected (or lowerBound/upperBound for InRange).

Testing Stateful Behavior — Multiple Calls

Some behavior only shows up after multiple calls. Hysteresis is the perfect example — you need to drive the function block into one state before you can test the transition.

Here is the hysteresis band test from my demo:

{Test}
METHOD PUBLIC WhenTempWithinHysteresisBand_HeatingStateMaintained
    // Step 1: Drive heating ON (below lower threshold = 95)
    _fb(
        i_rActualTemp  := REAL#94.0,
        i_rSetpoint    := REAL#100.0,
        i_rHysteresis  := REAL#5.0,
        i_xEnable      := TRUE
    );
    Equal(actual := _fb.q_xHeating, expected := TRUE);

    // Step 2: Temperature rises into band (97 = between 95 and 105)
    // Heating should remain ON — no change
    _fb(
        i_rActualTemp  := REAL#97.0,
        i_rSetpoint    := REAL#100.0,
        i_rHysteresis  := REAL#5.0,
        i_xEnable      := TRUE
    );
    Equal(actual := _fb.q_xHeating, expected := TRUE);
END_METHOD

First call: push the temperature below the lower threshold so heating turns ON. Second call: move the temperature into the hysteresis band (between 95 and 105). Heating should stay ON because we are still within the band.

This is testing real control logic. The kind of thing you would check with a watch table in TIA Portal — but here it is automated, repeatable, and self-documenting.

What to Test vs What to Leave for Commissioning

This is where practical experience matters. Not everything can or should be tested with AxUnit.

Test these things — they work great in unit tests:

  • Control logic decisions (if this input, then that output)
  • State machine transitions (idle to running to fault to reset)
  • Fault detection (sensor out of range, interlock conditions)
  • Calculations (error values, scaling, setpoint processing)
  • Enable/disable behavior (disabled means safe state)
  • Edge cases (what happens at the boundary values)

Leave these for commissioning on real hardware:

  • Timers. This is the biggest gotcha. In unit tests, timers never advance. A TON timer output is always FALSE because there is no PLC scan cycle — no time passes between calls. If your logic uses a timer, test the logic around the timer, not the timer itself.
  • Hardware I/O. Tests run on your PC. There is no real analog input card, no digital output. You simulate hardware inputs by setting values directly — which is great for testing your logic, but it does not prove the wiring works.
  • Communication. Modbus, Profinet, S7 connections — too hardware-dependent for unit tests.
  • Scan cycle behavior. Unit tests do not simulate cyclic execution. If your code depends on being called every 100 milliseconds, that timing does not exist in the test.

The split is clean: unit tests prove your logic is correct. Commissioning proves your system is connected correctly. Both are necessary. But one you can do at your desk every day, and the other requires a trip to the panel.

Running Your Tests

One command:

apax test

Run it from your project folder (where apax.yml is). AxUnit compiles your tests, runs them on your PC, and reports results:

Test Results: 9 passed, 0 failed

If a test fails, you get the test name, the assertion that failed, and the actual vs expected values. No guessing.

Some useful options:

  • apax test -c — run with code coverage report (shows which lines of your function block were exercised by tests)
  • apax test -f "=WhenDisabled" — run only tests matching a filter (useful when debugging one specific test)

That is the workflow. Write code, write tests, run apax test. Change code, run apax test again. If nothing broke, keep going. If something broke, fix it now — not three weeks later at commissioning.

Putting It All Together — A Complete Test File

Here is the full test file from my TempController demo. Eight tests, eight scenarios, all passing:

USING AxUnit.Assert;
USING Otomakeit.Demo.TempControl;

NAMESPACE Otomakeit.Demo.Tests

    {TestFixture}
    CLASS TestTempController

        VAR
            _fb    : TempController;
            _clean : TempController;
        END_VAR

        {TestSetup}
        METHOD PUBLIC TestSetup
            _fb := _clean;
        END_METHOD

        {Test}
        METHOD PUBLIC WhenTempBelowLowerThreshold_HeatingTurnsOn
            _fb(
                i_rActualTemp  := REAL#94.0,
                i_rSetpoint    := REAL#100.0,
                i_rHysteresis  := REAL#5.0,
                i_xEnable      := TRUE
            );
            Equal(actual := _fb.q_xHeating, expected := TRUE);
            Equal(actual := _fb.q_xFault,   expected := FALSE);
        END_METHOD

        {Test}
        METHOD PUBLIC WhenTempAboveUpperThreshold_HeatingTurnsOff
            _fb(
                i_rActualTemp  := REAL#94.0,
                i_rSetpoint    := REAL#100.0,
                i_rHysteresis  := REAL#5.0,
                i_xEnable      := TRUE
            );
            Equal(actual := _fb.q_xHeating, expected := TRUE);

            _fb(
                i_rActualTemp  := REAL#106.0,
                i_rSetpoint    := REAL#100.0,
                i_rHysteresis  := REAL#5.0,
                i_xEnable      := TRUE
            );
            Equal(actual := _fb.q_xHeating, expected := FALSE);
        END_METHOD

        {Test}
        METHOD PUBLIC WhenTempWithinHysteresisBand_HeatingStateMaintained
            _fb(
                i_rActualTemp  := REAL#94.0,
                i_rSetpoint    := REAL#100.0,
                i_rHysteresis  := REAL#5.0,
                i_xEnable      := TRUE
            );
            Equal(actual := _fb.q_xHeating, expected := TRUE);

            _fb(
                i_rActualTemp  := REAL#97.0,
                i_rSetpoint    := REAL#100.0,
                i_rHysteresis  := REAL#5.0,
                i_xEnable      := TRUE
            );
            Equal(actual := _fb.q_xHeating, expected := TRUE);
        END_METHOD

        {Test}
        METHOD PUBLIC WhenDisabled_HeatingIsOff
            _fb(
                i_rActualTemp  := REAL#50.0,
                i_rSetpoint    := REAL#100.0,
                i_rHysteresis  := REAL#5.0,
                i_xEnable      := FALSE
            );
            Equal(actual := _fb.q_xHeating, expected := FALSE);
        END_METHOD

        {Test}
        METHOD PUBLIC WhenTempAboveMaxValid_FaultIsSet
            _fb(
                i_rActualTemp  := REAL#501.0,
                i_rSetpoint    := REAL#100.0,
                i_rHysteresis  := REAL#5.0,
                i_xEnable      := TRUE
            );
            Equal(actual := _fb.q_xFault,   expected := TRUE);
            Equal(actual := _fb.q_xHeating, expected := FALSE);
        END_METHOD

        {Test}
        METHOD PUBLIC WhenTempBelowMinValid_FaultIsSet
            _fb(
                i_rActualTemp  := REAL#-51.0,
                i_rSetpoint    := REAL#100.0,
                i_rHysteresis  := REAL#5.0,
                i_xEnable      := TRUE
            );
            Equal(actual := _fb.q_xFault,   expected := TRUE);
            Equal(actual := _fb.q_xHeating, expected := FALSE);
        END_METHOD

        {Test}
        METHOD PUBLIC ErrorEqualsSetpointMinusActual
            _fb(
                i_rActualTemp  := REAL#80.0,
                i_rSetpoint    := REAL#100.0,
                i_rHysteresis  := REAL#5.0,
                i_xEnable      := TRUE
            );
            Equal(actual := _fb.q_rError, expected := REAL#20.0);
        END_METHOD

        {Test}
        METHOD PUBLIC WhenActualExceedsSetpoint_ErrorIsNegative
            _fb(
                i_rActualTemp  := REAL#120.0,
                i_rSetpoint    := REAL#100.0,
                i_rHysteresis  := REAL#5.0,
                i_xEnable      := TRUE
            );
            Equal(actual := _fb.q_rError, expected := REAL#-20.0);
        END_METHOD

    END_CLASS

END_NAMESPACE

Eight tests. Each one tests a single, clearly named scenario. Each one starts with a clean function block instance. Each one ends with explicit assertions that either pass or fail — no human interpretation needed.

A Brief Word About Test-Driven Development

There is a practice in the IT world called test-driven development — TDD for short. The idea is: write the test first, before writing the actual code. The test defines what the code should do. Then you write the minimum code to make the test pass.

For PLC code, this might look like:

  1. Write a test: “When temperature is below lower threshold, heating turns ON”
  2. Run the test — it fails (the function block does not exist yet)
  3. Create the function block with just enough logic to pass that test
  4. Run the test — it passes
  5. Write the next test, repeat

I am not going to say you should always work this way. But the idea has merit — especially for complex control logic where you want to think through the behavior before you write the code.

At the very least, writing tests alongside your code (not months later) makes them much easier to write. You already know what the code is supposed to do. You already have the edge cases in your head. Capture them in tests while it is all fresh.

What is Next

This series on unit testing in SIMATIC AX has two articles so far:

  1. What unit testing is — and why TIA Portal engineers should care (read Part 1)
  2. How to write tests — the step-by-step guide you just read

There is more to explore — code coverage reports, testing state machines, debugging failed tests in VS Code, integrating tests into automated build pipelines. But the fundamentals are here. Create a test fixture, write tests with clear names, assert on outputs, run apax test. That is the workflow.

If you want to try it yourself and run into questions, connect with me on LinkedIn. I am still learning too — always happy to compare notes.

Planning a new project? Message us to see how we can help.