EECS 268 - Laboratory 3

20pts

Due: Sunday 9/11 at 11:59pm

The primary objective of this lab is to learn testing approaches to improve programming practice. Automated unit testing with assert() will be covered. Secondary objectives include a review of programming with objects, and managing header files and implementation files.

Testing and Debugging Discussion

No one writes perfect code the first time, every time.

Even after code compiles, it must be tested to verify that it works the way the programmer thinks it should work (unit and integration testing) and the way the client thinks it should work (acceptance testing).

Tests may reveal defects in the code and defects must be corrected. Often defects can be located and corrected simply by looking at the code and thinking about it. Sometimes defects are more difficult to find and fix. One approach with these types of defects is to insert output statements into the code. This approach however is not recommended as it is often very time-consuming and frustrating, plus it introduces changes to the very code that you are testing and debugging. Another approach is to use a debugger. A debugger is a tool that lets you "walk" through the program one line at a time and inspect variables (see their current values) as you go. Last week in lab, we discussed debugging and gained experience with the Data Display Debugger (DDD) software. Debuggers are useful tools for finding the causes of defects after we already know there is a defect. Testing is used to discover the existence of defects.

Entire programs can be tested directly by running the program with various inputs and checking for corresponding outputs. This process can be manual (by hand) or it can be automated using scripts, input/output files, file redirection, and diff tools. Individual units (e.g. functions) can also be tested directly by writing unit tests. We will look at a mechanism for writing automated unit tests in lab today. Automated unit tests can be written before and as you write your program (Test-First) or they can be written after you have written your program (Test-Last). The great thing about automated unit tests is that you can run them over and over again very quickly. If you make a change to your code, you can immediately run the tests to see if you broke anything.

Writing automated unit tests with assert()

Suppose you are writing a Date class. The code written so far might look like the following:

In file Date.h

class Date
{
public:
  Date();
  Date(int,int,int);
  int getMonth();
  int getDay();
  int getYear();
private:
  int month;
  int day;
  int year;
};

In file Date.cpp

#include "Date.h"

Date::Date()
{
  month = 0;
  day = 0;
  year = 0;
}

Date::Date(int m, int d, int y)
{
  month = m;
  day = d;
  year = y;
}

int Date::getMonth()
{
  return month;
}

int Date::getDay()
{
  return day;
}

int Date::getYear()
{
  return year;
}

Although this code is fairly simple and probably works correctly, how would we test it? One approach is to write automated unit tests using the assert() macro. assert() is defined in the standard include file cassert. assert() takes one parameter which is a boolean expression. If the expression evaluates to true, then nothing happens when the assert() is executed. If the expression evaluates to false, then the program halts at that point and prints a message indicating the line and the expression of the failing assert(). A test driver might look like the following:

In file driver.cpp

#include "Date.h"
#include <cassert>

void run_tests();

int main()
{
  run_tests();
}

void run_tests()
{
  { //test 1
    Date d;
    assert(d.getMonth() == 0);
    assert(d.getDay() == 0);
    assert(d.getYear() == 0);
  }
  { //test 2
    Date d(6,14,1924);
    assert(d.getMonth() == 6);
    assert(d.getDay() == 14);
    assert(d.getYear() == 1924);
  }
}
This test driver consists of two unit tests. Each test has been isolated in its own block and labelled with a comment. All of the unit tests have been separated in a function called run_tests(). This keeps the tests separate from the actual code, and makes it easy to turn off the tests if you want to (e.g. comment out the call to run_tests()). Note that you can still have main() perform whatever actions you would normally put in main().

Assignment Part 1
Create a directory for this lab. Copy the three files from above into this directory and create a Makefile to compile them (see instructions from Lab 1 if you need help with creating the Makefile). Compile and run the program. If there are no errors, then nothing is printed and you are returned to a prompt.

Assignment Part 2
Now introduce an error just to see what would happen. For instance, change the first assert to be

    assert(d.getMonth() == 3);
instead of

    assert(d.getMonth() == 0);
Compile and run the program again to see what is printed when the test fails.

Correct the test and make sure the tests all pass.

Assignment Part 3
What would happen if we put in a month larger than 12 or less than 1? Let's say that we want our Date class to set the month to 1 if it is given a month outside the range 1 to 12. Later we will learn about a better solution to this problem in the form of exceptions, but for now we will just set the month to 1 for bad input. Add a test that checks to see that giving a month larger than 12 results in the month being set to 1. Add the code so that if months are outside the range 1 to 12, then the month is set to 1. Add similar code to set day to 1 if it is outside the range 1 to 31, and add tests to see if it works. For now you may ignore leap years and assume that all months have 31 days.

Test-First Programming

Now suppose you want to add new functionality to your Date class such as a new member function that allows you to add a number of days to a Date. You have two choices. You could write the new member function first, then write additional tests to see if your function works correctly. Or you could write the test first, run it (it should fail, if it doesn't, ask why not), then implement the code to make the test pass.

Test-first programming (or test-driven development) involves the following steps:
  1. Write a new test
  2. Write just enough code to make the test pass
  3. Improve the structure of the code and the test
  4. repeat to 1
Completing the first three steps should be kept fairly short, maybe only about 15 minutes. That means your tests are very focused and specific, not too broad or complex.
Assignment Part 4
Write a test to call the function (that doesn't yet exist) that adds a number of days to a Date object. Run the test, see it fail, then write the simplest code you can think of to make it pass (don't worry about wrapping around to the next month or year in your first test, for example add 3 to January 2). Check the code and the test to see if they are the simplest and cleanest that they can be. Now write another simple test and make sure it works as well. If not, fix the code and/or the test. Now write another test, perhaps one that causes the month to increment (e.g. add 5 to March 30). Again, don't worry about leap years, but do account for different length months.
Months with 28 or 29 days: February
Months with 30 days: April, June, September, November
Months with 31 days: January, March, May, July, August, October, December

Continue this process in a test-first manner until you are happy with the new function and confident that it works correctly.

Test-Last Programming

Test-last programming is similar to test-first except the tests are written after the code is implemented, not before. Notice that a test-last approach focuses on verifying that the code you wrote works correctly. A test-first approach influences design decisions because you have not yet written the code. With a test-first approach, you decide the name of a function, its parameters, and its return types (i.e. its interface) as well as its expected behavior when you write the test. A test-last approach assumes you've already made these design decisions before you wrote the code that you are now testing.
Assignment Part 5
Use a test-last approach to add another function to the Date class that allows you to add a number of months to a Date (hint: you might have it call the function that adds a number of days).
Assignment Part 6
Using a test-first approach, add tests and the code to make sure that the two functions that you created above correctly handle leap years. If a test fails, remember that you can use DDD to step through your new code. The following rules define leap year calculation:
  1. Years divisible by four are leap years, unless...
  2. Years also divisible by 100 are not leap years, except...
  3. Years divisible by 400 are leap years.
Assignment Part 7
Using either a test-first or a test-last approach, define a new class called Person with the following members Be sure to define the class in a header file, e.g. Person.h, and implement it in a separate implementation file, e.g. Person.cpp. Be sure all instance variables are declared private. Adjust your Makefile accordingly. Use the same approach (test-first or test-last) until you are finished with this part.
Assignment Part 8
When you are finished with all of the above, submit all header files, implementation files and your Makefile as specified in the submission guidelines. Indicate whether you used a test-first or a test-last approach in Part 7 by adding either TL (for test-last) or TF (for test-first) to the name of your tar file. For example, FirstLastLab3TL.tar.gz for a test-last program.