Lab: Unit testing with RackUnit

Assigned
Wednesday, 29 September 2021
Summary
In the laboratory, you will explore the ways in which small tests can help you develop and update code. You will also familiarize yourself with the RackUnit unit testing library. You will also have the opportunity to think more broadly about testing.

In this lab, you will stay with your group of approximately four students and work collaboratively to explore testing and the rackunit library. Nominate a notetaker for the group; they will be responsible for gathering up the code you write into a file called testing.rkt and turning that file in to Gradescope on behalf of the group. Make sure that testing.rkt includes require declarations for the appropriate libraries:

(require csc151)
(require rackunit)
(require rackunit/text-ui)

Throughout this lab, we will provide procedure examples that may use language features we have not yet introduced in this course. That is fine! The purpose of this lab is to test code rather than write new code, so focus on the intended behavior of the function rather than its implementation. (Although we will briefly explore that it is sometimes helpful to know the implementation!)

Exercise 1: Roundtable

One person should serve as driver for this exercise. The remaining people in the group are navigators.

The driver should open up DrRacket, require both the csc151, rackunit, and the rackunit/text-ui packages in their file, and copy the following function:

;;; (range1 n) -> listof integer?
;;;   n : integer?
;;; Returns the list of numbers from 1 to n, inclusive.  If
;;; n is non-positive, then returns the empty list.
(define range1
  (lambda (n)
    (map (lambda (n) (+ n 1)) (range n))))

Define a series of tests for this procedure, each of which you write explicitly in the definitions pane. For example,

(test-equal? "A very small range" (range 1) '(1))

This is a good practice as you are developing your program so you can quickly know if your code meets the current set of tests.

As this example suggests, your tests should be defined using the test-* functions described in the RackUnit api.

To develop the tests, the navigators should go around and each volunteer a test case that the driver then transcribes into the definitions pane. Continue going around the circle of navigators identifying test cases until your group is satisfied with the set of tests. Each navigator should volunteer at least one test case. In your group, you should agree on when you all feel that you have reasonably validated the function’s behavior.

When you are done, the driver should send the completed function and its set of tests to the notetaker. The notetaker should then include this code in testing.rkt. The notetaker should also comment-out the tests by placing #| before the tests and |# after the tests so that we do not run the tests in the auto-grader.

Exercise 2: Positive and negative cases

One way to organize our tests is by exploring positive and negative test cases. A positive test case is an example that exercises when the function reports “yes”—e.g., returns true, computes a result—when the inputs are “good”. A negative test case is an example that exercises when the function reports “no”—e.g., returns false, returns an error value, does not modify the input—when the inputs are “bad”.

Like before, nominate a new driver; the remainder of the group will serve as navigators. The driver should share their screen and copy the following function to their buffer.

;;; (palindrome? str) -> boolean?
;;;   str : string?
;;; Returns true iff the string s is a palindrome, i.e., str is
;;; equal to its reversal.
(define palindrome?
  (lambda (str)
    (and (string? str)
         (string=? str (list->string (reverse (string->list str)))))))

As in the previous exercise, collaboratively develop a set of tests for this procedure. The navigators should go around and volunteer one test case at a time until you are satisfied with your test coverage. For this exercise, make sure to keep in mind the idea of positive and negative test cases.

When you are done, the driver should send the completed function and its test suite to the notetaker. The notetaker should then include this code in testing.rkt, commenting it out.

Exercise 3: Types and corners

Another way to organize our tests is by exploring the range of possible inputs. If the type of the input admits a finite set of values, we ought to test all those values directly. However, if an infinite set of values is possible, we need to be more judicious in what values we examine.

One way to do this is to identify corner and non-corner case values. Think of a corner case as an example input that exercises the “boundaries” of how the function ought to work. For example, if you are operating over a certain range of numbers, a corner case might be an input at the lower or upper end of that range. In contrast, the values in the middle of the range are non-corner case values. We expect that the function will likely operate in the same way over these non-corner values, so we would then surmise that we don’t have to test all of these non-corner values; a few of them will suffice!

Nominate a new driver; the remainder of the group will serve as navigators. The driver should share their screen and copy the following function to their buffer.

Note: dedup-adjacent, below, relies on aspects of Racket you do not yet know. That’s okay. You should focus on the documentation docs and the testing that might be appropriate given that documentation.

;;; (dedup-adjacent l) -> listof any?
;;;   l : listof any?
;;; Returns the original list l but with all duplicates found
;;; adjacent to each other removed from the list.  For example:
;;;   > (dedup-adjacent (list 3 4 7 8 1 1 0 9 9 9 6 5 5 2 4))
;;;   '(3 4 7 9 1 0 9 6 5 2 4)
(define dedup-adjacent
  (lambda (l)
    (cond 
      [(null? l) 
       null]
      [(null? (cdr l)) 
       l]
      [else
       (let ([c1 (car l)]
             [c2 (cadr l)]
             [rest (cddr l)])
         (if (equal? c1 c2)
             (dedup-adjacent (cons c2 rest))
             (cons c1 (dedup-adjacent (cons c2 rest)))))])))

a. As in the the previous exercises, collaboratively develop a set of tests for this function. The navigators should go around and volunteer one test case at a time until you are satisfied with your test coverage. For this exercise, keep in mind the idea of types and corner cases/edge cases.

b. Here’s a not-quite-correct version of dedup-adjacent. Do your tests identify the error? If not, you need more tests.

(define dedup-adjacent
  (lambda (l)
    (cond
      [(null? l)
       null]
      [(null? (cdr l))
       l]
      [else
       (let ([c1 (car l)]
             [c2 (cadr l)]
             [rest (cddr l)])
         (if (equal? c1 c2)
             (dedup-adjacent (cons c2 rest))
             (cons c1 (cons c2 (dedup-adjacent rest)))))])))

c. When you are done, the driver should send the completed function and its test suite to the notetaker. The notetaker should then include this code in testing.rkt, commenting it out.

Turning it in!

At this point, you are ready to turn in the lab. (That doesn’t mean that you’re done with the lab; just that you’ve done enough work to turn in.) Take a few minutes to make sure that the notetaker has everything. Then submit the work on Gradescope.

Note that at the end of class, the notetaker should send everything they have to all members of the team.

Exercise 4: Test-driven development

You will not turn in this part of the lab.

Tests don’t have to be created after you write your function! Because we frequently implement a function with examples in mind to begin with, it is useful to codify these examples as tests first and then use those tests to guide development. Such a development methodology is called test-driven development where the tests drive the design of the code.

Consider the following procedure description.

;;; (describe-triangle side1 side2 side3) -> string? or #f
;;;   side1 : rational?
;;;   side2 : rational?
;;;   side3 : rational?
;;; Describe the triangle whose three sides are as given.
;;; * If all three sides are equal, the description is "equilateral".
;;; * If exactly two sides are equal, the description is "isosceles".
;;; * If no two sides are equal, description is "scalene".
;;; * If the sides do not describe a triangle, return #f.  
;;;
;;; Note: Degenerate triangles are not real triangles, so parameters
;;; that describe a degenerate triangle will normally result in a
;;; return value of false (#f).

Here’s an incorrect implementation.

(define describe-triangle
  (lambda (side1 side2 side3)
    #f))

An incorrect implementation is enough to get us started writing tests.

a. As before, write a set of tests for this function.

b. Write your own version of describe-triangle. Make sure it passes your tests.

c. Post your version in the thread set up for posting versions. (If you can’t figure out the tread, ask @staff for help.) Include your group number so that others can contact you if they identify errors or have questions.

d. Test at least two of the other versions of describe-triangle. (If there are no other versions, temporarily skip to the next step and then come back here later.) If you find an error, let the authors know.

e. If you hear about an error in your own version, delete it from the thread, rename the old version (e.g., to describe-triangle-bad-01), fix the error, and then repost. You might also want to add some appropriate tests.

f. Once you believe that you have a sufficient set of tests that will approve all correct implementations and reject all incorrect implementations, reach out to the course staff, who may provide you with an additional version of describe-triangle to check.
While you are wayiting for a response, spend some time working on the current mini-project.

g. Share your tests and describe-triangle with the note-taker, who should incorporate them in the file.

Share!

If you have not done so yet, make sure that the Notetaker shares the current state of your work with other group members.