CS 112 Lab 1: Test-Driven Development and Data Structures

Objectives:

  1. Build and use a data structure.
  2. Practice building classes.
  3. Practice test-driven development.

Introduction

Your best friend -- Phil M. Buff -- has an extensive DVD collection. Phil's collection is so big, he's having trouble remembering which movies he owns.

The good news is that Phil already has a text file containing the names of the movies, their directors, and the year they were made. Phil has asked you to write a program that reads his text file, and then lets him:

Design

To make a start at solving this problem, we will

  1. Create a class named Movie, capable of storing the information about one movie; and
  2. Create a class named MovieCollection that stores the entire collection of Movie objects in Phil's collection, and provides methods to perform the operations above.
We will also write test classes and a testing application to try to ensure that each of these classes is correct.

Getting Started

Create a new project named lab1 in which to store the files related to this project. The first few entries in Phil's text file are as follows:

12 Angry Men
1957
Sidney Lumet

12 Monkeys
1995
Terry Gilliam

2001: A Space Odyssey
1968
Stanley Kubrick

2010: T. Year We Make Contact
1984
Peter Hyams

T. 300 Spartans
1962
Rudolph Maté

The structure of this information (title, year, and director, each on separate lines; movies separated by a blank line) will determine the behavior of some of our methods.

Test-Driven Development

To build this application, we are going to use a methodology called test-driven development. The basic idea is that, as you think of a method that a class should have, you
  1. Write a test to evaluate whether or not the method works;
  2. Write the method; and
  3. Run the test to see whether or not the method is working correctly. If the method fails the test, fix the method until it passes the test.
For each class in our application (e.g., Movie, we will create a test class (e.g., MovieTester) that stores and runs the tests for that class. Let's get started!

The Movie Class

From the structure of Phil's text file, our Movie class needs to store these attributes about a movie:

So our Movie class will need four data members or instance variables. Since instance variables should always be declared as private:, we can start by creating the following class declaration in Movie.h:

/* Movie.h
 * Student Name:
 * Date:
 * Begun by: Joel Adams, for CS 112 at Calvin College.
 */
 
#ifndef MOVIE_H_
#define MOVIE_H_

#include <string>
using namespace std;

class Movie {
public:
   Movie();
private:
   string myTitle;
   int    myYear;
   string myDirector;
};

#endif /*MOVIE_H_*/

Since a C++ class consists of two files -- a header file whose name ends in .h, and an implementation file whose name ends in .cpp -- we also need to create Movie.cpp:

/* Movie.cpp
 * Student Name:
 * Date:
 * Begun by: Joel Adams, for CS 112 at Calvin College.
 */
 
 #include "Movie.h"

The Movie.cpp file is where we will define the methods of our Movie class. We don't have any methods to place there yet, but we will shortly...

The MovieTester Class

Next -- before we build any methods -- we create a class to store the tests we will run on the methods of our Movie class. Create a new class named MovieTester that looks like this at the outset:

/* MovieTester.h
 * Student Name:
 * Date:
 * Begun by: Joel Adams, for CS 112 at Calvin College.
 */

#ifndef MOVIETESTER_H_
#define MOVIETESTER_H_

#include "Movie.h"
#include <iostream>
using namespace std;

class MovieTester {
public:
   void runTests() const;
};
  
#endif /*MOVIETESTER_H_*/

So far, our MovieTester class contains a prototype or declaration of a single method named runTests(). (We'll be adding other method prototypes shortly.) Generally speaking, C++ header (.h) files contain class and method/function declarations; function definitions are placed in the implementation (.cpp) files.

Since a C++ class consists of two files -- the header file and the implementation file -- we also need to create MovieTester.cpp:

/* MovieTester.cpp
 * Student Name:
 * Date:
 * Begun by: Joel Adams, for CS 112 at Calvin College.
 */
 
#include "MovieTester.h"
 
void MovieTester::runTests() const {
   cout << "Testing Movie class..." << endl;
   cout << "All tests passed!" << endl;
}

Note that since we have no tests to run (yet), we just provide a "stub" definition for our runTests() method for now.

The Testing Application

To actually run the tests, we create a source file named tester.cpp containing a simple main() function:

/* tester.cpp tests the classes in our project.
 *
 */
 
#include "MovieTester.h"
#include <iostream>
using namespace std;

int main() {
    MovieTester mt;
    mt.runTests();
}

At this point, you should able to compile tester.cpp without errors, and run it to produce the messages:

Testing Movie class...
All tests passed!

If you have errors, find and fix them before continuing.

Testing the Default Constructor

Our Movie class's default constructor should initialize each instance variable to a default value. If we choose "empty" or zero values as our default values, we might build a test method in MovieTester.cpp to test our constructor as follows:

void MovieTester::testDefaultConstructor() const {
    cout << "- Default Constructor ... " << flush;
    Movie m;
    assert( m.getTitle() == "" );
    assert( m.getYear() == 0 );
    assert( m.getDirector() == "" );
    cout << " Passed!" << endl;
}
That is, we expect to be able to send a Movie messages like getTitle(), getYear(), and so on, to access the values of its instance variables, so we use those to test their values.

Next, we can add a prototype for this method to our MovieTester class:

class MovieTester {
public:
   void runTests() const;
   void testDefaultConstructor() const;
};

You will also need to #include any header files this method requires (e.g., <cassert>), and then invoke this method in our MovieTester::runTests() method (in MovieTester.cpp):

void MovieTester::runTests() const {
   cout << "Testing Movie class..." << endl;
   testDefaultConstructor();
   cout << "All tests passed!" << endl;
}

Even after we do all of that, we still get the following error message:

../MovieTester.h: In member function `void MovieTester::testDefaultConstructor()':
../MovieTester.h:21: error: 'class Movie' has no member named 'getTitle'
../MovieTester.h:22: error: 'class Movie' has no member named 'getYear'
../MovieTester.h:23: error: 'class Movie' has no member named 'getDirector'

The problem is that our Movie class does not yet provide the methods to access its members. To eliminate these errors, our next step is to go back to Movie and add protoypes and definitions for the missing methods.

In Movie.h, we add the prototypes:

class Movie {
public:
	Movie();
	string getTitle() const;
	int    getYear() const;
	string getDirector() const;
private:
	string myTitle;
	int    myYear;
	string myDirector;
};

And in Movie.cpp, we define these methods:

#include "Movie.h"

string Movie::getTitle() const {
	return myTitle;
}

int Movie::getYear() const {
	return myYear;
}

string Movie::getDirector() const {
	return myDirector;
}
At this point, our project should compile without any errors. However, it will generate a linking error:
/usr/bin/ld: Undefined symbols:
Movie::Movie()
collect2: ld returned 1 exit status
make: *** [lab1] Error 1
make: Target `all' not remade because of errors.
ld is the name of the linker, that links function and method calls to their definitions. Anytime you see ld in an error message, the compiler found a prototype for the function or method, but the linker was unable to find a definition for the indicated method -- in this case, the default constructor Movie() -- the method we are trying to test (and that we have not yet written). So let's write it!

Defining the Default Constructor

Since we have written the test our default constructor must pass, we refer back to it, and define our default constructor in Movie.cpp in a way that passes the test:
#include "Movie.h"

Movie::Movie() {
	myTitle = "";
	myYear = 0;
	myDirector = "";
}
...

Then we add a prototype for it to our Movie class in Movie.h:

class Movie {
public:
	Movie();
	string getTitle() const;
	int    getYear() const;
	string getDirector() const;
private:
	string myTitle;
	int    myYear;
	string myDirector;
};
With that, all of our classes should compile without error. When we run them, we should see:
Testing class Movie...
- Default Constructor ...  Passed!
All tests passed!
Congratulations! You've just written your first C++ methods using test-driven development!

Brief Aside

The key to test-driven development is sometimes called programming by intent -- you have to know what you intend a method to do, write a test-method that thoroughly tests your intent, and then write the simplest possible method that passes your test.

Test-driven development is the sort of thing that gets easier with practice, so we are starting with "simple" methods before we progress to more complicated ones.

As we will see, test-driven development also supports regression-testing -- making sure that changes you make to a class in one method do not cause another method to "break".

Testing the Explicit-Value Constructor

In addition to a default constructor, most classes should provide an explicit-value constructor that lets the programmer initialize its members to specific values. For example, a test-method for such a constructor might look like this:

void MovieTester::testExplicitConstructor() const {
    cout << "- Explicit Constructor... " << flush;
    Movie m("Bambi", 1942, "David Hand");
    assert( m.getTitle() == "Bambi" );
    assert( m.getYear() == 1942 );
    assert( m.getDirector() == "David Hand" );
    cout << "Passed!" << endl;
}

Define that method in MovieTester.cpp, then place a prototype for it method in your MovieTester class. Finally, invoke it in your runTests() method:

void MovieTester::runTests() const {
   cout << "Testing class Movie..." << endl;
   testDefaultConstructor();
   testExplicitConstructor();
   cout << "All tests passed!" << endl;
}
then the only compilation error we should see is because the compiler cannot (yet) find a prototype (or definition) for our explicit-value constructor:
../MovieTester.h: In member function `void MovieTester::testExplicitConstructor() const':
../MovieTester.h:31: error: no matching function for call to 'Movie::Movie(const char [6], int, const char [11], int)'
../Movie.h:25: note: candidates are: Movie::Movie()
../Movie.h:11: note:                 Movie::Movie(const Movie&)
That means it's time to define it!

The Explicit-Value Constructor

Once again, we write the simplest possible method that satisfies our test. Place this definition in Movie.cpp:

Movie::Movie(const string& title, int year, const string& director) {
	myTitle = title;
	myYear = year;
	myDirector = director;
}
Then place its prototype in our Movie class. Now our test-program should compile and run correctly:
Testing class Movie...
- Default Constructor... Passed!
- Explicit Constructor... Passed!
All tests passed!

To see what happens when you make a mistake, simulate making a mistake by "commenting out" one of the lines in our constructor:

Movie::Movie(const string& title, int year, const string& director) {
    myTitle = title;
//  myYear = year;
    myDirector = director;
}
Now when you run your method, the explicit-value constructor test will fail:
Testing class Movie...
- Default Constructor... Passed!
- Explicit Constructor... ../MovieTester.h:34: failed assertion `m.getYear() == 1942'
By checking whether or not your methods pass the tests, test-driven development provides a methodology to help you keep from making mistakes.

"Uncommment" the line in your constructor and make sure that your code passes the tests before continuing.

An Input Method

Since our movies are stored in a text file, it appears that we will need a method to fill a Movie with data from a file, or more precisely, from an ifstream to a file. To test such a method, we might first create a simple test file named movieTest.txt, containing a few sample movies organized like that of Phil's text file:
Gone with the Wind
1939
Victor Fleming

Star Wars
1977
George Lucas

We can then define a method in MovieTester.cpp to test our input method, which we will call readFrom():

void MovieTester::testReadFrom() const {
	cout << "- readFrom()... " << flush;
	ifstream fin("movieTest.txt");
	assert( fin.is_open() );
	Movie m;
	m.readFrom(fin);
	assert( m.getTitle() == "Gone with the Wind" );
	assert( m.getYear() == 1939 );
	assert( m.getDirector() == "Victor Fleming" );
	string blankLine;
	getline(fin, blankLine);
	m.readFrom(fin);
	assert( m.getTitle() == "Star Wars" );
	assert( m.getYear() == 1977 );
	assert( m.getDirector() == "George Lucas" );
	cout << "Passed!" << endl;
}

As before, we must (a) place a prototype for this method in our MovieTester class, (b) invoke it in MovieTester::runTests(), and (c) add any #include directives it needs (e.g., <fstream>). When the only error we see is the compiler being unable to find our readFrom() method, we are ready to define it!

Note that our test method invokes readFrom() multiple times. This is a good idea, as invoking a method multiple times will sometimes exhibit errors that do not show up with a single invocation of the method.

The Input Method

Now that we know what our input method has to do to pass the test, we write that method. We might try the following definition:

void Movie::readFrom(istream& in) {
	getline(in, myTitle);
	in >> myYear;
	getline(in, myDirector);
}
Then add a prototype for this method to the Movie class. Your program should compile correctly. However when we try to run it, one of our assertions fails:
- Default Constructor... Passed!
- Explicit Constructor... Passed!
- readFrom()... ../MovieTester.h:47: failed assertion `m.getDirector() == "Victor Fleming"'
The problem here is a subtle issue in our readFrom() method: The getline() method stops when it sees a newline or linefeed character, and it consumes the newline or linefeed. The >> operator we are using to read myYear stops when it sees any white space characters, including a newline or linefeed character, but it leaves such characters unconsumed in the istream. So the problem is that >> is leaving the newline unconsumed in the istream, and when the subsequent call to getline() begins reading, the first thing it encounters is that newline. As a result, myDirector is receiving the empty string as its value, causing our assertion to fail.

We thus need to "clean out" the newline that follows the year, so that our final call to getline() can read the director's name into myDirector. One way to "clean out" such end of line characters is to use the getline() function after we have read myYear:

void Movie::readFrom(istream& in) {
	getline(in, myTitle);
	in >> myYear;
	string newLine; getline(in, newLine);
	getline(in, myDirector);
}
Now, when we compile and run, we pass all our tests:
Testing class Movie...
- Default Constructor... Passed!
- Explicit Constructor... Passed!
- readFrom()... Passed!
All tests passed!

Once we have passed all our tests, we can continue to the next operation.

Testing An Output Method

Since one of the required operations is to print all the movies with a given word in their title, we also need an operation to output a movie. Since C++ output is via ostream objects, we will pass our operation an ostream to which a Movie can write its information. The ostream class is a superclass of the ofstream class, so this will allow us to use the same method for writing both to the console (via cout) and to a file (via an ofstream). We will name our method writeTo().

Testing such a method using an assert() takes a bit more effort:

void MovieTester::testWriteTo() const {
   cout << "- writeTo()... " << flush;
   // one movie
   Movie m1("Raiders of the Lost Ark", 1981, "Steven Spielberg");
   ofstream fout("writeTo.txt");
   m1.writeTo(fout);
   Movie m2;
   fout.close();
   ifstream fin("writeTo.txt");
   m2.readFrom(fin);
   assert( m2.getTitle() == "Raiders of the Lost Ark" );
   assert( m2.getYear() == 1981 );
   assert( m2.getDirector() == "Steven Spielberg" );
   fin.close();
   cout << " 1 " << flush;
   // two movies
   Movie m3("Blade Runner", 1982, "Ridley Scott");
   fout.open("writeTo.txt");
   m1.writeTo(fout);                          // write 1st movie
   fout << "\n";                              // write a blank line
   m3.writeTo(fout);                          // write 2nd movie
   fout.close();
   fin.open("writeTo.txt");
   Movie m4, m5;
   m4.readFrom(fin);                          // read 1st movie
   string blankline; getline(fin, blankline); // eat the blank line
   m5.readFrom(fin);                          // read 2nd movie
   fin.close();
   assert( m4.getTitle() == "Raiders of the Lost Ark" );
   assert( m4.getYear() == 1981 );
   assert( m4.getDirector() == "Steven Spielberg" );
   assert( m5.getTitle() == "Blade Runner" );
   assert( m5.getYear() == 1982 );
   assert( m5.getDirector() == "Ridley Scott" );
   cout << " 2 " << flush;
   cout << "Passed!" << endl;
}
Two things are worth noting about this test:
  1. To automate the test, we open an ofstream to a file and use our method to write a movie to that file. After closing the stream, we then open an ifstream to the file and use our readFrom() method to read the movie from the file into a new Movie variable. We can then assert what the values in that movie should be. By using files instead of interactive I/O, this approach automates the test, instead of requiring the user to enter values manually each time the test is performed.
  2. To thoroughly test the readFrom() and writeTo() methods, we have multiple tests: one checking that the methods work correctly using one value, and another that they work correctly using two values.

After placing a prototype in our MovieTester class and invoking this method in runTests(), the only error we should generate is due to writeTo() not being defined:

../MovieTester.h: In member function `void MovieTester::testWriteTo() const':
../MovieTester.h:66: error: 'class Movie' has no member named 'writeTo'
So we are now ready to define that method!

The Output Method

Since the test tells us what we expect our method to do, we just have to define our method in a way that passes the test:
void Movie::writeTo(ostream& out) const {
	out << myTitle << '\n'
	    << myYear  << '\n'
	    << myDirector << '\n';
}
Define this method, then place a prototype for it in your Movie class; then compile and run the test-application. If all is well, you should pass all the tests.

Congratulations! You have built a reasonably complicated Movie class!

Testing the MovieCollection Class

Our next class is the MovieCollection class, in which we will store the movies of our friend Phil's collection. To test this new class, we will create a CollectionTester class, similar to the MovieTester class we built previously. We might start with this declaration:

/* CollectionTester.h tests the MovieCollection class.
 * Student Name:
 * Date:
 * Begun by: Joel Adams, for CS 112 at Calvin College.
 */

#ifndef COLLECTIONTESTER_
#define COLLECTIONTESTER_

class CollectionTester {
public:
    void runTests() const;
};

#endif /*COLLECTIONTESTER_*/

and

/* CollectionTester.cpp defines the MovieCollection test-methods.
 * Student Name:
 * Date:
 * Begun by: Joel Adams, for CS 112 at Calvin College.
 */
 
#include "CollectionTester.h"
 
void CollectionTester::runTests() const {
   cout << "Testing class MovieCollection..." << endl;
   cout << "All tests passed!" << endl;
}

This is sufficient that we can now create a CollectionTester object in our tester.cpp file's main() method, and send it the runTests() message:

    #include "MovieTester.h"
    #include "CollectionTester.h"

    int main() {
    MovieTester mt;
    mt.runTests();
    CollectionTester ct;
    ct.runTests();
}

Note that we do not replace our MovieTester tests. We want to continue to continue to re-run the Movie tests each time we test our MovieCollection, to make sure that nothing we write "breaks" something that worked previously.

This kind of testing -- in which we rerun all of our tests every time we modify the system to ensure that our modifications haven't broken something -- is called regression testing. Regression testing is a powerful technique for writing reliable software.

At this point, you should be able to run your test-application and pass all of the tests.

Testing the MovieCollection Constructor

If we think about our problem a bit, the DVD collection is stored in a file. It really doesn't make much sense to build a default-constructor (at least not yet) for our MovieCollection class. Instead, we will build an explicit constructor that takes the name of a data file as its parameter, and then loads the collection using the data from that file.

To test our constructor, we create a (small) test file named testCollection.txt, containing just a few movies. We can then write a test-method for our MovieCollection constructor:

void CollectionTester::testConstructor() const {
   cout << "- constructor..." << flush;
   MovieCollection mc("testCollection.txt");
   cout << " Passed!" << endl;
}

invoke it in runTests():

void CollectionTester::runTests() const {
   cout << "Testing class MovieCollection..." << endl;
   testConstructor();
   cout << "All tests passed!" << endl;
}

and place its prototype in our CollectionTester class:

class CollectionTester {
public:
    void testConstructor() const;
    void runTests() const;
};

The compiler will generate an error because we have not yet defined a MovieCollection class. That means it's time to do so!

The MovieCollection Class

A simple declaration for class MovieCollection is as follows:

#ifndef MOVIECOLLECTION_H_
#define MOVIECOLLECTION_H_

#include <string>
using namespace std;

class MovieCollection {
public:
   MovieCollection(const string& fileName);
};

#endif /*MOVIECOLLECTION_H_*/
This should eliminate all compilation errors, but may leave a linking error (unless your IDE has created a MovieCollection stub for you). While our class declares the MovieCollection constructor, it does not yet defined that constructor. Before we do so, let's complete the test-method for our constructor.

Our test-method needs to do something with mc -- to somehow determine whether or not it is getting initialized correctly. Since we want to be able to find the movies by specific directors, we might extend testDefaultConstructor() as follows:

void CollectionTester::testConstructor() const {
    cout << "- constructor..." << flush;
    MovieCollection mc("testCollection.txt");
    // case of 1 movie
    vector<Movie> v1 = mc.searchByDirector("Hand");
    assert( v1.size() == 1 );
    assert( v1[0].getTitle() == "Bambi" );
    cout << " 1 " << flush;
    // case of 2 movies
    vector<Movie> v2 = mc.searchByDirector("Spielberg");
    assert( v2.size() == 2 );
    assert( v2[0].getTitle() == "Jaws" );
    assert( v2[1].getTitle() == "Raiders of the Lost Ark" );
    cout << " 2 " << flush;
    // case of no movies
    vector<Movie> v3 = mc.searchByDirector("Hitchcock");
    assert( v3.size() == 0 );
    cout << " 3 " << flush;
    cout << " Passed!" << endl;
}

Note that a test-method should test all aspects of an operation -- in this case:

Note also that our test method assumes the existence of a searchByDirector() method, which will return all of the movies in our collection that were directed by a given director. To accomplish this, our method assumes the movies are returned in a vector -- an array-like C++ standard structure that allows us to access any of its individual movies via an index value. To pass this test, we must (a) define the constructor, and (b) define the searchByDirector() method.

The MovieCollection Constructor

Before we build the constructor, we have to decide how we are going to store the collection. One way we might do so is using the STL vector class-template. To do so, we can add a vector<Movie> instance variable to our MovieCollection class:

/* MovieCollection.h ...
 * ...
 */

#include <string>
#include "Movie.h"
#include <vector>
#include <cassert>
using namespace std;

class MovieCollection {
public:
	MovieCollection(const string& fileName);
private:
	vector<Movie> myMovies;
};
Given this structure, our constructor can now do its job as follows:
/* MovieCollection.cpp
 * ...
 */
 
#include "MovieCollection.h"
#include <fstream>
using namespace std;

MovieCollection::MovieCollection(const string& fileName) {
	ifstream fin( fileName.c_str() );
	assert( fin.is_open() );
	
	Movie aMovie;
	do {
		aMovie.readFrom(fin);
		string blankLine;
		getline(fin, blankLine);
		myMovies.push_back(aMovie);
	} while ( !fin.eof() );
	fin.close();
}
This just leaves the searchByDirector() method for us to define.

The searchByDirector() Method

To build our searchByDirector() method, we might:

  1. declare an empty vector v in which to store our results;
  2. iterate through the Movies in myMovies, checking whether the string returned by getDirector() contains the name we receive via the parameters, and if it does, appending that Movie to v.
  3. Once we have sequenced through the entire collection, we can return the v.
To accomplish step 2, we can use a for loop containing an if statement that uses the string::find() method:

vector<Movie> MovieCollection::searchByDirector(const string& directorName) const {
   vector<Movie> v;
   for (unsigned i = 0; i < myMovies.size(); i++) {
      if ( myMovies[i].getDirector().find(directorName) != string::npos ) {
         v.push_back( myMovies[i] );
      }
   }
   return v;
}
This should remove the remaining errors, so that when you compile and run the test-application, all tests should be passed:
...
Testing Movie class...
- Default Constructor ...  Passed!
- Explicit Constructor... Passed!
- readFrom()... Passed!
- writeTo()...  1  2 Passed!
All tests passed!
Testing class MovieCollection...
- constructor... 1  2  3  Passed!
All tests passed!

Congratulations! You now have a working data structure that we can use to solve (a part of) Phil's problem!

This week's project is to finish the data structure and solve the rest of Phil's problem.

Turn In

The folder /home/cs/112/current should contain a folder with your user-name. You can check by typing this command into a terminal window:

$ ls /home/cs/112/current
Each week's lab and project must be copied to your folder, so that the grader can grade it. If your project is not in your folder within /home/cs/112/current, the grader will not be able to find it, and you will receive a zero!

To illustrate, suppose that your user-name is abc1, that you are currently in your home folder, that your Eclipse workspace is named cs112, that your cs112 folder is within your home folder, and that your lab1 folder is inside your cs112 folder (i.e., /home/abc1/cs112/lab1). Then you should enter:

$ cd cs112

to change directory to the folder "above" your lab1 folder, and then enter a command like this:

$ cp -r lab1 /home/cs/112/current/abc1

where you replace abc1 with your user-name. The cp -r command will recursively copy lab1 and all of its subfolders into your folder. You can check that this worked by listing the contents of your folder:

$ ls /home/cs/112/current/abc1
lab1

and then verifying that your folder contains a copy of the today's files:

$ ls /home/cs/112/current/abc1/lab1
... contents of lab1 should appear ...

When your folder within current contains a copy of your lab work, you are all done. Congratulations!

If time permits, you may begin working on this week's project.


CS > 112 > Labs > 01


This page maintained by Joel Adams.