Test driven development in TwinCAT – Part 4

In the previous post we defined the general layout of our unit tests, and also did the implementation of the tests for two of the five function block that we’re going to use to verify the functionality of parsing IO-Link events. What we’ve got left is to create test cases for the parsing of the text identity and the timestamp of the diagnostic event. Then we also want to have a few tests that closes the loop and verifies the parsing of a complete diagnosis history message.

FB_DiagnosticMessageTextIdentityParser_Test

The only input for the text identity are two bytes that together make up an unsigned integer (0-65535), which is the result (output) of this parser. It’s enough to make three test cases; one for low/medium/max. This means we’ll need three booleans to keep track of whether our unit tests have failed or not. We accomplish to test the three values by changing the two bytes that make up the unsigned integer. The header of the function block unit test:

FUNCTION_BLOCK FB_DiagnosticMessageTextIdentityParser_Test
VAR_OUTPUT
    bSuccess : BOOL;
END_VAR
VAR
    fbDiagnosticMessageTextIdentityParser : FB_DiagnosticMessageTextIdentityParser;
    nTextIdentity : UINT;

    bSuccess_fbDiagnosticMessageTextIdentityParser_IdentityLow : BOOL;
    bSuccess_fbDiagnosticMessageTextIdentityParser_IdentityHigh : BOOL;
    bSuccess_fbDiagnosticMessageTextIdentityParser_IdentityMed : BOOL;
END_VAR
VAR CONSTANT
    // @TEST-FIXTURE TextIdentity#Low
    cnTextIdentityBufferByte1_IdentityLow : BYTE := 16#00; // 0 = no text identity
    cnTextIdentityBufferByte2_IdentityLow : BYTE := 16#00;
    canTextIdentityBuffer_IdentityLow : ARRAY[1..2] OF BYTE := [cnTextIdentityBufferByte1_IdentityLow,
                                                                cnTextIdentityBufferByte2_IdentityLow];
    // @TEST-RESULT TextIdentity#Low
    cnTextIdentity_IdentityLow : UINT := 0;

    // @TEST-FIXTURE TextIdentity#High
    cnTextIdentityBufferByte1_IdentityHigh : BYTE := 16#FF; // 0xFFFF = 65535
    cnTextIdentityBufferByte2_IdentityHigh : BYTE := 16#FF;
    canTextIdentityBuffer_IdentityHigh : ARRAY[1..2] OF BYTE := [cnTextIdentityBufferByte1_IdentityHigh,
                                                                 cnTextIdentityBufferByte2_IdentityHigh];
    // @TEST-RESULT TextIdentity#High
    cnTextIdentity_IdentityHigh : UINT := 65535;

    // @TEST-FIXTURE TextIdentity#Med
    cnTextIdentityBufferByte1_IdentityMed : BYTE := 16#C4; // 0x86C4 = 34500
    cnTextIdentityBufferByte2_IdentityMed : BYTE := 16#86;
    canTextIdentityBuffer_IdentityMed : ARRAY[1..2] OF BYTE := [cnTextIdentityBufferByte1_IdentityMed,
                                                                cnTextIdentityBufferByte2_IdentityMed];
    // @TEST-RESULT TextIdentity#Med
    cnTextIdentity_IdentityMed : UINT := 34500;
END_VAR

We instantiate the function block that we are going to test (fbDiagnosticMessageTextIdentityParser), store the result (nTextIdentity) and prepare three test-results (bSuccess_Low/Med/High). Then we prepare the three test-fixtures and results for all three tests.

And as usual our body runs the tests and stores the results:

// @TEST-RUN TextIdentity#Low
fbDiagnosticMessageTextIdentityParser(anTextIdentityBuffer := canTextIdentityBuffer_IdentityLow,
                                      nTextIdentity => nTextIdentity);
// @TEST-ASSERT TextIdentity#Low
bSuccess_fbDiagnosticMessageTextIdentityParser_IdentityLow := (nTextIdentity = cnTextIdentity_IdentityLow);

// @TEST-RUN TextIdentity#High
fbDiagnosticMessageTextIdentityParser(anTextIdentityBuffer := canTextIdentityBuffer_IdentityHigh,
                                      nTextIdentity => nTextIdentity);
// @TEST-ASSERT TextIdentity#High
bSuccess_fbDiagnosticMessageTextIdentityParser_IdentityHigh := (nTextIdentity = cnTextIdentity_IdentityHigh);

// @TEST-RUN TextIdentity#Med
fbDiagnosticMessageTextIdentityParser(anTextIdentityBuffer := canTextIdentityBuffer_IdentityMed,
                                      nTextIdentity => nTextIdentity);
// @TEST-ASSERT TextIdentity#Med
bSuccess_fbDiagnosticMessageTextIdentityParser_IdentityMed := (nTextIdentity = cnTextIdentity_IdentityMed);


bSuccess := bSuccess_fbDiagnosticMessageTextIdentityParser_IdentityLow AND
            bSuccess_fbDiagnosticMessageTextIdentityParser_IdentityHigh AND
            bSuccess_fbDiagnosticMessageTextIdentityParser_IdentityMed;

 

FB_DiagnosticMessageTimeStampParser_Test

The eight bytes that make up the timestamp can be either the distributed clock (DC) from EtherCAT, or a local clock in the device itself. In the global case we want to parse the DC-time, while in the local case we just want to take the DC clock from the current task time (Again, the local clock could be extracted from the EtherCAT-slave, but for the sake of simplicity we’ll use the task DC-clock). Because the local/global-flag is read from the “Flags”-FB, this information needs to be provided into the timestamp-FB, and is therefore an input to the FB. What this means is that if the timestamp is local, the eight bytes don’t matter as we’ll get the time from the task. For the timestamp-FB it’s enough with two test cases, one testing it with a local timestamp and the other with a global timestamp. The local timestamp unit test result has to be created in runtime.

FUNCTION_BLOCK FB_DiagnosticMessageTimeStampParser_Test
VAR_OUTPUT
    bSuccess : BOOL;
END_VAR
VAR
    fbDiagnosticMessageTimeStampParser : FB_DiagnosticMessageTimeStampParser;
    sTimeStamp : STRING(29);

    bSuccess_fbDiagnosticMessageTextIdentityParser_LocalTimeStamp : BOOL;
    bSuccess_fbDiagnosticMessageTextIdentityParser_GlobalTimeStamp : BOOL;
END_VAR
VAR CONSTANT
    // @TEST-FIXTURE local time stamp
    canTimeStampBuffer_LocalTimeStamp : ARRAY[1..8] OF BYTE := [8(16#00)];
    
    // @TEST-FIXTURE global time stamp
    cnTimeStampBufferByte1_GlobalTimeStamp : BYTE := 16#C0; // 0x07C76560A71025C0 = '2017-10-05-14:15:44.425035200'
    cnTimeStampBufferByte2_GlobalTimeStamp : BYTE := 16#25;
    cnTimeStampBufferByte3_GlobalTimeStamp : BYTE := 16#10;
    cnTimeStampBufferByte4_GlobalTimeStamp : BYTE := 16#A7;
    cnTimeStampBufferByte5_GlobalTimeStamp : BYTE := 16#60;
    cnTimeStampBufferByte6_GlobalTimeStamp : BYTE := 16#65;
    cnTimeStampBufferByte7_GlobalTimeStamp : BYTE := 16#C7;
    cnTimeStampBufferByte8_GlobalTimeStamp : BYTE := 16#07;
    canTimeStampBuffer_GlobalTimeStamp : ARRAY[1..8] OF BYTE := [cnTimeStampBufferByte1_GlobalTimeStamp,
                                                                 cnTimeStampBufferByte2_GlobalTimeStamp,
                                                                 cnTimeStampBufferByte3_GlobalTimeStamp,
                                                                 cnTimeStampBufferByte4_GlobalTimeStamp,
                                                                 cnTimeStampBufferByte5_GlobalTimeStamp,
                                                                 cnTimeStampBufferByte6_GlobalTimeStamp,
                                                                 cnTimeStampBufferByte7_GlobalTimeStamp,
                                                                 cnTimeStampBufferByte8_GlobalTimeStamp];
    // @TEST-RESULT global time stamp
    csTimeStamp_GlobalTimeStamp : STRING(29) := '2017-10-05-14:15:44.425035200'; // T_DCTime64 = 16#07C76560A71025C0
END_VAR

For the local timestamp case, we can see that we setup the test-fixture for the eight bytes to zeros, as this data is not necessary for the local timestamp case. For the global timestamp test-fixture, we created eight bytes of data representing the date/time “2017-10-05-14:15:44.425035200”. As our timestamp-FB returns a string, this is exactly the string that we expect to get as a test-result. You might be asking yourself “how on earth is it possible to know that 0x07C76560A71025C0 equals 2017-10-05-14:15:44.425035200”? What I did was to create a little program that just printed the current actual DC-time by using F_GetActualDCTime64 in combination with DCTIME64_TO_STRING. Because the T_DCTIME64-type that is returned from F_GetActualDcTime64() is an alias for a primitive type, it’s easy to convert it into a byte-array.

The body of the test-FB needs to run these two tests:

// @TEST-RUN local time stamp
fbDiagnosticMessageTimeStampParser(anTimeStampBuffer := canTimeStampBuffer_LocalTimeStamp,
                                   bIsLocalTime := TRUE,
                                   sTimeStamp => sTimeStamp);

// @TEST-ASSERT local time stamp
bSuccess_fbDiagnosticMessageTextIdentityParser_LocalTimeStamp := (
    sTimeStamp = DCTIME64_TO_STRING(in := F_GetCurDcTaskTime64()));

// @TEST-RUN global time stamp
fbDiagnosticMessageTimeStampParser(anTimeStampBuffer := canTimeStampBuffer_GlobalTimeStamp,
                                   bIsLocalTime := FALSE,
                                   sTimeStamp => sTimeStamp);

// @TEST-ASSERT global time stamp
bSuccess_fbDiagnosticMessageTextIdentityParser_GlobalTimeStamp := (sTimeStamp = csTimeStamp_GlobalTimeStamp);

bSuccess := bSuccess_fbDiagnosticMessageTextIdentityParser_LocalTimeStamp AND
            bSuccess_fbDiagnosticMessageTextIdentityParser_GlobalTimeStamp;

Note that the assertion of the local time stamp is based on getting the current DC-task time by utilizing the F_GetCurDcTaskTime64(), thus we’re making sure that if the diagnosis message tells us that the timestamp is a local clock, we check that our FB returns this.

FB_DiagnosticMessageParser_Test

The final test-FB that we need is the one that ties the bag together and uses all the other four. The FB_DiagnosticMessageParser function block will be the one where we send in all the bytes that we receive from the IO-Link master, and that will output the struct that we can present to the operator or send further up in the chain. One could argue that because we already have unit tests for the other four function blocks, we don’t need to have unit tests for this one. By having unit tests for this “umbrella” function block, we add an additional level of confidence that our code is working properly. Additionally, we can also make sure that combinations of different diagnosis messages are parsed correctly.

To have maximum variation we want to try to vary all parameters as much as possible. I’m not going to write down all variations here, but will only show one example of a test case that we want to run and assert. I’ll try to explain it as detailed as possible, thus it should thus be easy for you to add any test cases that you find necessary. As usual, header first:

FUNCTION_BLOCK FB_DiagnosticMessageParser_Test
VAR_OUTPUT
    bSuccess : BOOL;
END_VAR
VAR
    fbDiagnosticMessageParser : FB_DiagnosticMessageParser;
    stDiagnosticMessage : ST_DIAGNOSTICMESSAGE;
    fbDiagnosticMessageComparator : FB_DiagnosticMessageComparator;

    bSuccess_fbDiagnosticMessageParser_ManufacturerSpecificMessage : BOOL;
END_VAR

The function block “FB_DiagnosticMessageParser” is a very simple FB that compares every data element of the struct “ST_DIAGNOSTICMESSAGE”, which we’ll later use when we’re doing the assertion. The boolean “bSuccess_fbDiagnosticMessageParser_ManufacturerSpecificMessage” holds the result of the unit test that we’re going to run. Here we can add any additional test-result booleans for any additional tests that we want to run (which is something we would like to do).

VAR CONSTANT
// @TEST-FIXTURE ManufacturerSpecificMessage
    cnDiagnosticBufferByte1_ManufacturerSpecificMessage : BYTE := 16#90; // 0xE290 = Manufacturer Specific
    cnDiagnosticBufferByte2_ManufacturerSpecificMessage : BYTE := 16#E2;
    cnDiagnosticBufferByte3_ManufacturerSpecificMessage : BYTE := 16#30; // 0x0000 = Code 0
    cnDiagnosticBufferByte4_ManufacturerSpecificMessage : BYTE := 16#75;
    cnDiagnosticBufferByte5_ManufacturerSpecificMessage : BYTE := 2#0000_0000; // Global time stamp & info message 
    cnDiagnosticBufferByte6_ManufacturerSpecificMessage : BYTE := 16#02; // Number of parameters = 2
    cnDiagnosticBufferByte7_ManufacturerSpecificMessage : BYTE := 16#A8; // 0x61A8, Text id as reference to ESI file = 10#25000
    cnDiagnosticBufferByte8_ManufacturerSpecificMessage : BYTE := 16#61;
    cnDiagnosticBufferByte9_ManufacturerSpecificMessage : BYTE := 16#C8; // Timestamp from DC clock, 16#07C8D11492616FC8 = '2017-10-10-05:20:39.893037000'
    cnDiagnosticBufferByte10_ManufacturerSpecificMessage : BYTE := 16#6F;
    cnDiagnosticBufferByte11_ManufacturerSpecificMessage : BYTE := 16#61;
    cnDiagnosticBufferByte12_ManufacturerSpecificMessage : BYTE := 16#92;
    cnDiagnosticBufferByte13_ManufacturerSpecificMessage : BYTE := 16#14;
    cnDiagnosticBufferByte14_ManufacturerSpecificMessage : BYTE := 16#D1;
    cnDiagnosticBufferByte15_ManufacturerSpecificMessage : BYTE := 16#C8;
    cnDiagnosticBufferByte16_ManufacturerSpecificMessage : BYTE := 16#07;

    canDiagnosticBuffer_ManufacturerSpecificMessage : ARRAY[1..28] OF BYTE := [
                                                               cnDiagnosticBufferByte1_ManufacturerSpecificMessage,
                                                               cnDiagnosticBufferByte2_ManufacturerSpecificMessage,
                                                               cnDiagnosticBufferByte3_ManufacturerSpecificMessage,
                                                               cnDiagnosticBufferByte4_ManufacturerSpecificMessage,
                                                               cnDiagnosticBufferByte5_ManufacturerSpecificMessage,
                                                               cnDiagnosticBufferByte6_ManufacturerSpecificMessage,
                                                               cnDiagnosticBufferByte7_ManufacturerSpecificMessage,
                                                               cnDiagnosticBufferByte8_ManufacturerSpecificMessage,
                                                               cnDiagnosticBufferByte9_ManufacturerSpecificMessage,
                                                               cnDiagnosticBufferByte10_ManufacturerSpecificMessage,
                                                               cnDiagnosticBufferByte11_ManufacturerSpecificMessage,
                                                               cnDiagnosticBufferByte12_ManufacturerSpecificMessage,
                                                               cnDiagnosticBufferByte13_ManufacturerSpecificMessage,
                                                               cnDiagnosticBufferByte14_ManufacturerSpecificMessage,
                                                               cnDiagnosticBufferByte15_ManufacturerSpecificMessage,
                                                               cnDiagnosticBufferByte16_ManufacturerSpecificMessage];

    // @TEST-RESULT ManufacturerSpecificMessage
    cstDiagnosticMessage_ManufacturerSpecificMessage : ST_DIAGNOSTICMESSAGE :=
        (stDiagnosticCode := (eDiagnosticCodeType := E_DIAGNOSTICCODETYPE.ManufacturerSpecific, nCode := 30000),
        stFlags := (eDiagnostisType := E_DIAGNOSISTYPE.InfoMessage, eTimeStampType := E_TIMESTAMPTYPE.Global,
                    nNumberOfParametersInDiagnosisMessage := 2),
        nTextIdentityReferenceToESIFile := 25000,
        sTimeStamp := '2017-10-10-05:20:39.893037000');
END_VAR

As usual, we’ll declare the test-fixture and the expected test-results for that fixture. I’ve commented all the bytes so that it’s obvious what information is being stored in every byte. Note that the test-result is a structured representation of all the diagnostic history message bytes. These 16 bytes are the sum of all the other bytes used for the other four function block parsers.

That’s our tests

And that’s it. We’re finished with the unit tests. Notice that we still have not written a single line of code for the implementing part. We’ve defined the inputs/outputs and the accompanying data structures for our function blocks. We’ve also created all the test cases. All of the code you’ve written so far is excellent documentation for any other developer that would try to understand the implementing code in the future. Not only that, all your test cases also form the acceptance criteria for the implementation code. You’ve basically said “I require my code to pass these tests, and these tests must pass for my code to do what I want it to do”. Note that all the tests above can be executed at anytime. Done any change to your code? Just re-run the tests and make sure you haven’t broken anything. Fantastic, isn’t it?

In part five of this series we are going to start with the actual implementation of the required functionality, in which our goal is to eventually make the tests pass. See you next week!

Algorithm icon by Freepik from www.flaticon.com

Your email address will not be published. Required fields are marked *

*