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:
- Write a new test
- Write just enough code to make the test pass
- Improve the structure of the code and the test
- 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:
- Years divisible by four are leap years, unless...
- Years also divisible by 100 are not leap years, except...
- 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
- instance variables
- private firstName:string
- private lastName:string
- private payday:Date
- accessors and mutators for each instance variable
- incrementPayDay():void which moves the payday to two-weeks later
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.