Teaching
670 S '05
 
Lectures
Lecture 1
Lecture 2
Lecture 3
Lecture 4
Lecture 5
Lecture 6
Lecture 7
Lecture 8
Lecture 9
Lecture 10
Lecture 11
Lecture 12
Lecture 13

Lecture 5: Examples and Tests

Interfaces: Purpose and Contract

Thus far, we have learned how to scale up the first two steps of the design recipe to larger projects. First, we analyze the problem with concept graphs and use cases. Second, we excerpt interfaces from these. An interface is a description of some actor or piece of data (what it represents) and what kind of actions we expect to happen on them. Each action consumes inputs and produces outputs.

For example, we may be working on a piece of software that deals with orders:


 
 +----------+
 | Customer |
 +----------+
    | 1 
    |
 ----------------------------------------
    |
    |
    v *
 +-------+ 1    * +-----------+
 | Order | -----> | OrderLine |
 +-------+ --+    +-----------+
             |    
             |    +-----------+
             +--> | TotalCost |
                  +-----------+


The little excerpt here identifies four concepts: a customer, an order, an orderline, and the total cost of an order.

A simple use case may proceed like this. The customer opens an order, adds several "lines" to the order, and then demands to know the total cost of the order.

The use case suggests that Orders are an important class of data in our program. Furthermore, we can see three important actions: opening an order, adding a line to the order, and adding up the total. Without even committing to a programming language, we can write down what this means in some reasonably rigorous way:


 interface for Order: 
  ___ open(...) 
  // creates a unique order, fills in details about the customer 
  
  ___ addOrderLine(this Order, ProductCode, Quantity)
  // add a line to this order 
  // this may also add a product description
  // and other information from a catalog 

  Amount getTotal(this Order)
  // compute the total cost of the order
  // w/o taking tax or shipping into account 

We can take this as an English description for a piece of code in a procedural, untyped language such as Scheme or we can easily turn into a piece of Java code. In either case, we have names for functionality we must implement, and we can finally begin to code.

Examples

Well, almost. If you remember the design recipe, you know that we really need to formulate examples and turn them into tests. To formulate examples, we still don't need to choose a language; it is doable at the conceptual level.

Example 1:

Given: an empty order
Expected: 0
The customer may request the total cost of the order right after opening the order.

Example 2:

Given: an order with one line, for a single product with price $99.99
Expected: $99.99

Making up examples for programs, small or large, helps us understand how the software is supposed to work and helps us formulate criteria for when we want to accept/reject code. Ideally, we make this process as automatic as possible so that we can apply it repeatedly and to intermediate solutions, just to conduct a reality check.

Tests First

For this reason, we translate the examples into executable tests. A unit test is a piece of code that exercises a unit of code and reports how the actual behavior of the code compares with the expected behavior. It is therefore imperative that a test specifies the expected result, the piece of program to be run (with input and proper setup), and a mechanism for comparing the expected outcome with the actual outcome.

A customer test (blackbox test) runs a complete program without any knowledge about its interior construction and ensures that an entire use case works as expected.

Testing Frameworks

In this day, most programming languages come with testing frameworks that help programmers formulate tests before they work on the code. For example, Java comes with jUnit, PLT Scheme comes with SchemeUnit (see planet.plt-scheme.org).

For illustrative purposes, let us look at how a "design recipe programmer" would develop the code for getTotal, the function that computes the total of an order. In Scheme, we may have expressed its signature as follows:


  ;; Order     = Listof[Orderline]
  ;; OrderLine = Amount 
  ;; Amount    = Number 

  ;; compute the total of this order
  ;; Order -> Amount
  (define (getTotal o) ...)

Using SchemeUnit, we can then express the above examples as follows:


  (define testcase1
    (make-test-case "testing basic getTotal"
                    (assert = (getTotal '()) 0)
                    ))
  
  (define testcase2
    (make-test-case "testing one book getTotal"
                    (assert = (getTotal '(99.99))
                            99.99)))

Later when we want to run the test cases, we form a test suite (a collection of independent test cases) and ask for a report:


  (test/text-ui 
   (make-test-suite "tests for getTotal"
                    testcase2
                    testcase1))

Of course, we can't run this test suite without completing the definition of getTotal but at least we are now ready to do so.

Here is what happens when we complete the definition in a bad way and run the test suite:


Failure:
-------- testing one book
getTotal#struct:object:...:89:8:18:4
name: "assert"
location: (#struct:object:...:89:8 18 4 468 129 #module-path-index)
expression: (assert = (gettotal '(99.99)) 99.99)
params: (#primitive:= 0 99.99)

1 success(es)  0 error(s)  1 failure(s) 

We see that one test case succeeded, one failed and where/why it failed.

Tests for Imperative Programming

When someone has designed the functions or methods of our interfaces such that they interact via effects on variables, testing becomes more complicated but it is still imperative to do so.

Consider the example of a queue whose interfaces demands changes to its internal state:


  ;; a queue of Xs
  (define queue<%>
    (interface ()
      enq ;; X -> Void
      ;; adds one X element to this queue

      deq ;; -> X
      ;; removes and returns one X element from this queue

      sze ;; -> Int
      ;; returns the number of Xs currently in this queue
      ))

  (define queue% 
    (class* object% (queue<%>) ...))

In particular, enq puts some item away and deq retrieves it and removes it from that secret place. Also, sze is a function that makes an observation about this hidden piece of data, but doesn't reveal its exact nature.

A test of these methods should, for example, ensure that enqing one item into an empty queue changes what sze observes:


  (define the-queue (new queue%))

  (define tc0-a
    (make-test-case "size of a empty queue"
                    (assert = 0 (send the-queue sze))))

  (define tc0-b
    (make-test-case "size of a one-enq queue"
                    (assert = 1 (send the-queue sze))
                    setup (send the-queue enq 11)
                    teardown (set! the-queue (new queue%))))

To accomplish this second goal, we need to add setup code that performs the enq operation and that happens before the assertion is evaluated. To make sure that other test cases work independently of this one, we also add teardown code that undoes the effects of the setup code.

A final consideration concerns the testing of exceptional behavior. A queue, for example, may raise an exception when a client tries to deq an item from an empty queue:


  (define-struct (exn:queue-mt exn) ())
  
  (define queue% 
    (class* object% (queue<%>)
      (super-new)
      ;; represents all the currently enq'ed elements
      (define state '())
      (define/public (enq x) (set! state (cons x state)))
      (define/public (deq) 
        (when (null? state)
          (raise (make-exn:queue-mt "Q is mt" 'stuff)))
        (let* ([rev (reverse state)]
               [head (car rev)]
               [tail (cdr rev)])
          (begin0 head
                  (set! state (reverse! tail)))))
               
      (define/public (sze) (length state))))

This implementation of queue<%> stores its items in a list, and if this list is empty when deq is called, the method throws an exception.

To test this behavior requires a slightly different setup:


  (define tc2
    (make-test-case "deq an mt queue"
                    (assert-exn exn:queue-mt? 
                                (lambda ()
				  (send the-queue deq)))))

More precisely, the test case must specify that an exception is expected, and when none is thrown, it will report this behavior as erroneous.

In summary, before you program find out what your language supports in terms of test suite languages and read up on its capabilities. If it doesn't, take the time to implement a minial framework yourself. The two references show that this isn't really difficult.

Bibliography

  • Beck. Test-Driven Development. Addison-Wesley, 2002.
  • Welsh and Culpepper. SchemeUnit: Unit Testing in Scheme.

  • last updated on Tue Jun 9 22:03:19 EDT 2009generated with PLT Scheme