Falvotech.
Conquering the world is easy — what do you do with it afterwards?

L I N K S


⇐ An Opinion Offering Arguments Against Boycotting British Petroleum

⇒ Quick Bash Tip: Environment Variables as Aliases


Unit Tests Enhance Code Familiarity
Samuel A. Falvo II
kc5tja -at- arrl.net
2010 Jun 22 17:33 PDT

Many programmers, including myself, suffer maintenance problems when revisiting code which either isn't theirs or hasn't been touched in extended periods of time, despite the on-going appeal for good quality commenting in software. Miller's Law offers the most likely explanation why, for working on one project necessarily results in forgetting many details of other past projects. Using test-driven development techniques ameliorates much of the need for commenting for documentation by providing a source of examples known to work. Since tests must be kept synchronized against the production code, the maintainer can place a larger degree of confidence on the tests as a source of truth for how to use an interface than any comments found (or not found) in the production source code.

Recently, I felt sufficient motivation to work on my amateur optical networking software; the current state of which requires me to finish implementing an HDLC network stack before I can continue. When I last touched the code last year, it handled SABM, UA, and DM frame types. I needed it to also support the DISC frame type, so that applications can cleanly disconnect from their servers. To make this change, I needed to re-acquaint myself with my own code, code which I haven't touched in over 8 months. Here's the code as it looked prior to a few days ago:

: I-frame               drop ;
: S-frame               drop ;

: s@                    .src swap +c@ 127 and ;
: d@                    .dest swap +c@ 127 and ;
: stations              dup s@ swap d@ ;

: notConnecting?        stations connecting? 0= ;
: +connecting           dup notConnecting? if r> drop then ;
: +ua                   .control over +c@ kUA xor if r> drop then ;
: UA                    +ua +connecting  dup stations connected drop  reusable r> drop ;

: +dm                   .control over +c@ kDM xor if r> drop then ;
: +established          dup stations disconnected? if r> drop then ;
: DM                    +dm +established  dup stations disconnected drop  reusable r> drop ;

: +sabm                 .control over +c@ kSABM xor if r> drop then ;
: replyDM               stations swap nextFBuf swap over +tail  swap over +tail  kDM over +tail dup +crc sendable ;
: +connectable          dup d@ unconnectable? if dup replyDM r> drop then ;
: +authorized           dup stations unauthorized? if dup replyDM r> drop then ;
: replyUA               stations swap nextFBuf swap over +tail  swap over +tail  kUA over +tail  dup +crc sendable ;
: SABM                  +sabm +connectable +authorized  dup replyUA  dup stations connected drop  reusable r> drop ;

: U-frame               ( one of ) UA DM SABM ( else ) reusable ;


create table0
    ' I-frame , ' I-frame , ' S-frame , ' U-frame ,
: handler               .control swap +c@ 3 and cells table0 + @ ;
: dispatch              dup handler execute ;

Now that I have implemented DISC support, the software looks like this:

: I-frame               drop ;
: S-frame               drop ;

: s@                    .src swap +c@ 127 and ;
: d@                    .dest swap +c@ 127 and ;
: stations              dup s@ swap d@ ;

: replyDISC             stations swap nextFBuf swap over +tail swap over +tail kDISC over +tail dup +crc sendable ;
: replyDM               stations swap nextFBuf swap over +tail swap over +tail kDM over +tail dup +crc sendable ;
: replyUA               stations swap nextFBuf swap over +tail swap over +tail kUA over +tail dup +crc sendable ;

: +connectable          dup d@ unconnectable? if dup replyDM r> drop then ;
: +authorized           dup stations unauthorized? if dup replyDM r> drop then ;

: dm1                   dup stations disconnected drop reusable ;
: dm2                   dup replyDM dm1 ;
: sabm1                 +connectable +authorized  dup replyUA  dup stations connected drop  reusable ;
: sabm-disconnecting    dup replyDISC dup stations disconnecting drop  reusable ;
: ua-connecting         dup stations connected drop reusable ;

create table1
            ( Disconnected            Connecting          Connected           Disconnecting )
   ( DM )   ' reusable ,            ' dm1 ,             ' dm1 ,             ' dm1 ,
 ( SABM )   ' sabm1 ,               ' reusable ,        ' sabm1 ,           ' sabm-disconnecting ,
 ( DISC )   ' dm2 ,                 ' dm2 ,             ' dm2 ,             ' dm2 ,
   ( UA )   ' reusable ,            ' ua-connecting ,   ' reusable ,        ' dm1 ,
( Other )   ' reusable ,            ' reusable ,        ' reusable ,        ' reusable ,

: DM                    dup kDM = IF drop 0 r> drop then ;
: SABM                  dup kSABM = IF drop 4 cells r> drop then ;
: DISC                  dup kDISC = IF drop 8 cells r> drop then ;
: UA                    dup kUA = IF drop 12 cells r> drop then ;
: class                 .control swap +c@ ( one of ) DM SABM DISC UA ( else ) drop 16 cells ;
: U-frame               dup class table1 + over stations linkHandler + @ execute ;

create table0
    ' I-frame , ' I-frame , ' S-frame , ' U-frame ,
: handler               .control swap +c@ 3 and cells table0 + @ ;
: dispatch              dup handler execute ;

Notice that no comments exist anywhere in the code. How did I know what to change, where to change it, and how could I change it without breaking anything else in only a day?

Take for instance the word dispatch. Despite how simple it appears, its execution may produce one of over 144 different possible outcomes. It exists to react to externally-received HDLC frames and update a computer's networking state accordingly. There exists 3 kinds of frames, 4 types of U-frames we're interested in, each link can be in one of four (as of this writing) states, and negative testing dictates testing with three different combinations of station addresses, some known-good, some known-bad. This leads us to the possibility of a single word having over 144 different execution behaviors. How do you describe these behaviors in natural language? Simply describing the scope of the problem took this entire paragraph; this explains why formal specification documents tend to be so large.

I rely heavily on my unit and integration tests to provide several pieces of information to me:

  • What does the procedure actually do? (Answered either in good test names or in the context established by good assert messages.)
  • What execution environment do I need to ensure exists before I call a particular routine? (Answered in a test's setup and teardown procedures.)
  • What parameters do I need (and what order do I pass them in?) to achieve the goal I'm seeking? (Answered in the individual test code itself.)
  • What results are generated, if any, which need special attention? (Answered in the subsequent asserts.)
  • Under what conditions are errors generated? (Answer in any negative test code.)

Here's what a typical unit test module for this project looks like:

include config
include dlc

: s             dlc0 ;
: t100.1    s   1 2 disconnected 0<> abort" How can we drop a DLC that wasn't added yet?" ;
: t100.2    s   #dlcs 1- 0 do i i connecting drop loop   #dlcs #dlcs connecting 0= abort" Have room for #dlcs DLCs." ;
: t100.3    s   #dlcs 0 do i i connecting drop loop   #dlcs 1+ dup connecting 0<> abort" Have limited DLC records." ;

: t100          t100.1 t100.2 t100.3 ;

: s             dlc0  1 2 connecting 0= abort" #dlcs is too small" ;
: t101.1    s   nextDlc @ /row <> abort" DLC table should have one row." ;
: t101.2    s   1 2 disconnected 0= abort" I should have been able to drop a known-good DLC record" 
                nextDlc @ 0<> abort" DLC table should have zero records" ;
: t101.3    s   2 1 disconnected 0<> abort" Dropping non-existent DLC doesn't make sense." ;
: t101.4    s   1 2 isDlc? 0= abort" DLC 1 2 should be recognized" ;
: t101.5    s   2 1 isDlc? 0<> abort" DLC 2 1 should not be recognized" ;

: t101          t101.1 t101.2 t101.3 t101.4 t101.5 ;

: s             dlc0  1 2 connecting 0= abort" #dlcs is too small" ;
: t102.1    s   1 2 isDlc? 0= abort" DLC 1 and 2 are related!"  ;
: t102.2    s   1 2 connecting? 0= abort" DLC 1 and 2 should be connecting!" ;
: t102.3    s   nextDlc @ 1 2 connecting drop nextDlc @ <> abort" DLCs 1 and 2's relationship shouldn't be any surprise" ;
: t102.4    s   1 3 connecting? 0<> abort" DLC 1 and 3 have no relationship -- how can they be connecting?" ;
: t102.5    s   1 2 connected drop  1 2 connecting? 0<> abort" I told 1 and 2 to connect!"
                                    1 2 connected 0= abort" DLCs 1 and 2 should be connected" ;
: t102          t102.1 t102.2 t102.3 t102.4 t102.5 ;

: s             dlc0 1 2 connected 0= abort" 103: #dlcs too small"  ;
: t103.1    s   1 2 isDlc? 0= abort" DLC 1 and 2 should be connected"  ;
: t103.2    s   1 2 connecting? 0<> abort" DLC 1 and 2 are not connectING"  ;
: t103.3    s   1 2 connected? 0= abort" DLC 1 and 2 must be connected"  ;
: t103          t103.1 t103.2 t103.3 ;

: s             dlc0  1 2 connected 0= abort" 104: #dlcs too small" ;
: t104.1    s   1 2 disconnected? 0<> abort" 1 2 are connected; why report as disconnected?" ;
: t104.2    s   1 3 disconnected? 0= abort" Relationship between 1 and 3 never established; they MUST be disconnected." ;
: t104.3    s   1 2 disconnected drop  1 2 disconnected? 0= abort" Explicit disconnection requires predicate to be true" ;
: t104          t104.1 t104.2 t104.3 ;

: s             dlc0 ;
: t105.1    s   1 2 disconnected drop   1 2 linkHandler 0<> abort" 105.1: Disconnected handler expected at offset 0" ;
: t105.2    s   1 2 connecting drop     1 2 linkHandler 1 cells <> abort" 105.2: Connecting handler expected at offset 1" ;
: t105.3    s   1 2 connected drop      1 2 linkHandler 2 cells <> abort" 105.3: Connected handler expected at offset 2" ;
: t105.4    s   1 2 disconnecting drop  1 2 linkHandler 3 cells <> abort" 105.4: Disconnecting handler expected at offset 3" ;
: t105          t105.1 t105.2 t105.3 t105.4 ;

: tt            t100 t101 t102 t103 t104 t105 ;

Without going into detail into how these unit tests work or are structured1, anyone fluent in Forth will see that the above questions can be answered by reviewing the relevant unit test code. For example, t104 demonstrates how to denote that DLCs 1 and 2 are connected, and confirms that doing so doesn't have any weird side effects. Note also how the code demonstrates the relationship between declarations (1 2 connected) and their corresponding queries (1 2 connected? yields true, while 1 2 connecting? yields false).

To help ensure the correctness of software which the tests cannot reach directly, I make use of the Declarative, Imperative, then Interrogative pattern.

I used to lament how contemporary programming books rarely, if ever, provide complete program listings anymore. Lack of program listings you can physically type removes an important step in learning how to use a library or other software interface. I used to think that the solution was to create a new interest in such documents, having explored all manner of tools such as JavaDoc-like tools to Literate Programming. None of these approaches worked for me. Not only were they taking too long to write, they proved much too difficult to synchronize against the product being delivered.

Recently, I realized that the value offered by those old magazines and books with typeable programs were not so much in the articles (though, I loved the extra insights offered by them), but instead the programs which served as tutorials and tests against interfaces already present on your computer. Writing tests per the test-driven development style virtually guarantees that every little feature your software offers has some kind of test, and hence example, associated with it. Better still, applying the concept to integration tests, where several software units are exercised together, often results in magazine-quality tutorial material automatically. The only thing missing would be a code walk-through to explain what's happening each step of the way (particularly for those not familiar with the programming language used), which can be placed in the article's exposition.

Indeed, writing integration tests often prove difficult without consulting at least a few of the unit tests to verify your code makes calls correctly. For example, below you'll see the integration test for the amateur optical network's DLC, DLCR, dispatcher, serial I/O, and other support libraries. Looking at the following code sample, notice how many modules work together to form the HDLC stack! Reviewing the unit tests for each of the modules used proved invaluable in writing the following code:

warning off

\ This program requires that the Arduino microcontroller
\ come pre-loaded with a byte loopback program.  Thus,
\ any frames we send out are echoed directly back to the
\ PC via the RS-232 port.


include config
include fbuf
include phy-rs232
include framer
include dlc
include dlcr
include dispatcher
include deframer


: start-all     framer0 start-framer  deframer0 start-deframer ;
: stop-all      stop-deframer stop-framer ;
: reset         dlc0 dlcr0 fbuf0 start-all ;

: connectOK?    1 = swap 2 = and ;
: listen        ['] connectOK? 1 connectable ;

: nextFBuf      nextFBuf dup 0= abort" OUT OF FBUFS" ;
: sabm          nextFBuf $81 over +tail 2 over +tail kSABM over +tail dup +crc sendable ;
: -connected    1 2 connected? if drop r> drop then ;
                ( NOTE: because SwiftForth optimizes the call to -connected as a JMP, we need only ONE )
                ( continuation removal in -connected. )
: failed        1 2 connecting drop  sabm dup ms -connected ;
: connection    100 begin failed 2* dup 2000 > until drop 1 2 disconnected drop stop-all serial) -1 abort" SABM time-out exceeded." ;


\ This word demonstrates the steps that need to be taken on the client, the server,
\ and steps in common to both.  As you can see, it's *WAY* easier than BSD sockets!!
\ Station 1 is the server.  Station 2 is the client.

: run   ( client )              ( common to both )                  ( server )
                                (serial reset
                                                                    listen

        connection                                                  ( wait for connection here... )

        1 2 connected?                                              2 1 connected?
                                and 0= abort" Half-connected"
        ." Connected!" cr                                          ." Server too!" cr
                                ( do stuff here )

                                stop-all serial)
;

Stop concerning yourself over how to document your code. Stop bothering with comments that you know will go obsolete as soon as you type the opening comment token. Forget literate programming unless you're writing a thesis or a book. Write unit and integration tests instead. Not only will you save time in writing, you'll also save time in re-acquainting yourself with your old code.


1  I used to have a more pretty Forth unit test execution engine which would have made these tests much easier to read, and perhaps to maintain. Unfortunately, the source code for that library hasn't been recovered from the old Serendipity blog database yet, as of the writing of this article. Besides, I've found great utility in invoking individual tests by name (e.g., t104.2 or t104) as well as by entire suite. Instead of trying to reconstruct a test framework that lacked useful features from broken memories, I decided to just stick with brute force using nothing but Forth itself as my framework. While I lament the lack of adequate naming for test cases and specifications (imagine having to constantly type in dlc-should-be-connected every time you wanted to invoke that test!), I'm quite pleased with the results. Besides, grepping for test numbers (instead of names) turns out to be more useful in practice.