|
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:
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 |