Introduction
This tutorial discusses the design of a robust, reusable class. We’ll design the interface, write unit tests, and use the Boost Operators library to reduce the amount of code we have to write.
The specific case we’re going to tackle is a version number handling class. Applications written for Windows typically use the Major.Minor.Build.Revision
format. So we’re going to design a class that can manipulate these version numbers.
This tutorial uses a couple of the Boost libraries. Boost is a collection of free, peer-reviewed, portable C++ source libraries. If you’ve never looked at Boost, you’re in for a treat. We’re only going to look at the Operators and Test libraries in this article. I hope that when you see some of the power, you’ll be inspired to look at how you can use some of the other libraries in your projects.
Background
As a consultant, I see a lot of code. I've seen a lot of classes that are only designed to perform in the one specific case that was needed, where with almost no extra effort, a complete class could have been created and placed in a library for reuse. Having a class that supports copies, assignment, and comparisons correctly greatly facilitates using the class in STL collections.
I’ve also done work for the FAA and other agencies which required full test suites, starting with unit testing. In the commercial world, I rarely see a coordinated unit testing mentality. It’s usually just one or two programmers in the group that do tests more on an ad hoc basis because they have the discipline to do so. Having a suite of unit tests that travel with the code provides a solid basis for both maintaining the code and for later refactoring, either for performance or reusability reasons.
Determine Requirements
Now, let’s start by making a list what we want our AppVersion
class to do.
- Various construction methods, i.e., a default constructor, a constructor with the versions specified, and a copy constructor
- Assignment
- Various accessors
- A full set of comparison operators
- Serialization
- Debugging support
Create the Project Structure
For this article, we’ll have two projects in our solution. A library containing our AppVersion
class and a unit test project. You can easily imagine that our library would contain a large collection of classes and our unit test project would contain tests for each of them. We’ll set up our projects to support adding more classes in future articles. By placing the tests in a separate project, they don't burden the users of the library with extra stuff they don't need, but it does allow the tests to travel with the source for the library.
Our directory structure will look like the following:
Core
|
+--Core
+--Tests
We're using Visual Studio 2003 for this project. But everything we're doing will work in Visual Studio 6. The source for this article contains both a VS7 solution and a VS6 workspace.
Create the Library
We'll start by creating a library (use "Win32 Project" under "Visual C++ Projects" in the wizard) called Core
and selecting "Create directory for Solution." Be sure to select "Static Library" and "MFC support".
Since we’re designing our class for use in an MFC project, we’re going to derive it from CObject
. This gives us some added advantages of debugging and serialization support. And we'll add some member variables to hold the four parts of the version number.
class AppVersion : public CObject
{
public :
AppVersion();
virtual ~AppVersion();
private :
unsigned long m_Major;
unsigned long m_Minor;
unsigned long m_Build;
unsigned long m_Revision;
};
Setup the Test Environment
In keeping with best practices, we’re going to write the unit tests first. Let's start by setting up the test environment. We're going to use the Boost Test Library. There are many unit test libraries available on the web. We're going to use Boost since there are other capabilities of the Boost libraries that we're going to use later. Download the latest version from http://boost.org and add the directory to the include path in Visual Studio. The test library is implemented as a collection of templates. We only need to provide a couple of functions and the templates do the rest. So we add another project to our solution called Tests
that is simply a Win32 console application with MFC support.
We need to do some housekeeping tasks, such as setting the Tests
project to depend on the Core
project. We also need to add the Boost Test Library headers. The templates provide a main function so the _tmain
provided by the Visual Studio wizard is not needed. We only need to provide an implementation of init_unit_test_suite
. This function performs initialization of MFC and sets up the list of tests to run. The new version of Tests.cpp looks like this:
#include "stdafx.h"
#include "boost/test/included/unit_test_framework.hpp"
#include "Tests.h"
#ifdef _DEBUG
#define new DEBUG_NEW
#endif
CWinApp theApp;
using namespace std;
using boost::unit_test_framework::test_suite;
void AppVersionTests(test_suite* CoreTestSuite);
test_suite* init_unit_test_suite(int argc, char* argv[])
{
if (!AfxWinInit(::GetModuleHandle(NULL), NULL, ::GetCommandLine(), 0))
{
printf("Fatal Error: MFC initialization failed\n");
return (0);
}
test_suite* CoreTestSuite = BOOST_TEST_SUITE("Core Tests");
AppVersionTests(CoreTestSuite);
return (CoreTestSuite);
}
void AppVersionTests(test_suite* CoreTestSuite)
{
}
We created the function AppVersionTests
to contain our tests and added it to the suite of tests.
There are a couple of important settings that need to be made to the compiler options. "Run-Time Type Info" needs to be turned on and the "C++ Exceptions" setting changed to /EHa under the Advanced options. We should now be able to build the complete solution.
We're also going to add running the tests to the Post-Build Event so the test suite will be run every time the solution is built. This way, any problems will cause the build to fail. Add "$(TargetPath)" (including the quotes) as the Post-Build Event command line. At this point, when the solution is built, the output window should end with:
Running Unit Tests
*** No errors detected
Write Some Tests
Now for the tests. We're first going to move the AppVersionTests
function to a separate file. This simplifies adding tests, particularly since we're setting things up for a whole library of routines and tests.
The first tests are to simply construct our AppVersion
object using its various constructors. So we'll create a function to perform these tests and add it to the list of tests.
void AppVersionConstructorTests(test_suite* CoreTestSute)
{
AppVersion a;
AppVersion b(1, 2, 3, 4);
AppVersion c(b);
}
CoreTestSuite->add(BOOST_TEST_CASE(&AppVersionConstrutorTests));
The BOOST_TEST_CASE
line simply adds the function to the list of tests to run. But since we haven't created these constructors, this won't even compile. So let's go create these constructors.
class AppVersion : public CObject
{
AppVersion();
AppVersion(unsigned long Major,
unsigned long Minor,
unsigned long Build,
unsigned long Revision);
AppVersion(const AppVersion& b);
};
AppVersion::AppVersion() :
CObject(),
m_Major(0),
m_Minor(0),
m_Build(0),
m_Revision(0)
{
}
AppVersion::AppVersion(unsigned long Major, unsigned long Minor,
unsigned long Build, unsigned long Revision) :
CObject(),
m_Major(Major),
m_Minor(Minor),
m_Build(Build),
m_Revision(Revision)
{
}
AppVersion::AppVersion(const AppVersion& b) :
m_Major(b.m_Major),
m_Minor(b.m_Minor),
m_Build(b.m_Build),
m_Revision(b.m_Revision)
{
ASSERT_VALID(&b);
}
When we now compile this, we'll see:
Running Unit Tests
Running 1 test case...
*** No errors detected
So far, so good.
Add Debugging Support
Since AppVersion
was derived from CObject
, let’s take advantage of the debugging support it provides.
CObject
contains an AssertValid
debugging function that allows anyone to check that the object is in a valid state. Personally, I like to check that an object is valid as the first statement in any member function of the object. I also like to check all parameters that are passed in by callers of a class. It adds some work, but the couple of times it catches an error more than pays for itself. In this case, AppVersion
really doesn't have an invalid state, since a version number can be any combination of numbers. So we'll just use the AssertValid
that CObject
provides.
The other debugging aid that CObject
provides is a Dump
method. We'll override the default Dump
method to write our state on request. If we ever have a memory leak or other error, the information will help to determine which object is involved in the problem.
#ifdef _DEBUG
void AppVersion::Dump(CDumpContext& dc) const
{
dc << "AppVersion:\n";
CObject::Dump(dc);
dc << "Major: " << m_Major << "\n";
dc << "Minor: " << m_Minor << "\n";
dc << "Build: " << m_Build << "\n";
dc << "Revision: " << m_Revision << "\n";
}
#endif
Add Accessors
Now that the basics are out of the way, it’s time to write more tests. But to check that the tests are working, some accessors are needed.
class AppVersion ...
unsigned long GetMajor() const;
unsigned long GetMinor() const;
unsigned long GetBuild() const;
unsigned long GetRevision() const;
unsigned long AppVersion::GetMajor() const
{
ASSERT_VALID(this);
return (m_Major);
}
unsigned long AppVersion::GetMinor() const
{
ASSERT_VALID(this);
return (m_Minor);
}
unsigned long AppVersion::GetBuild() const
{
ASSERT_VALID(this);
return (m_Build);
}
unsigned long AppVersion::GetRevision() const
{
ASSERT_VALID(this);
return (m_Revision);
}
A couple of things to note. As mentioned above, we take advantage of the debugging support CObject
provides by starting each member function with an ASSERT_VALID(this)
. This allows us to check that the object is in a valid state. Since these are only in the Debug build, there is no effect on the performance of Release code.
Now we can check that the constructor tests, and these accessors, work correctly.
Write More Tests
We add tests to really check that our constructors are working.
BOOST_CHECK_EQUAL(a.GetMajor(), 0);
BOOST_CHECK_EQUAL(a.GetMinor(), 0);
BOOST_CHECK_EQUAL(a.GetBuild(), 0);
BOOST_CHECK_EQUAL(a.GetRevision(), 0);
BOOST_CHECK_EQUAL(b.GetMajor(), 1);
BOOST_CHECK_EQUAL(b.GetMinor(), 2);
BOOST_CHECK_EQUAL(b.GetBuild(), 3);
BOOST_CHECK_EQUAL(b.GetRevision(), 4);
BOOST_CHECK_EQUAL(c.GetMajor(), 1);
BOOST_CHECK_EQUAL(c.GetMinor(), 2);
BOOST_CHECK_EQUAL(c.GetBuild(), 3);
BOOST_CHECK_EQUAL(c.GetRevision(), 4);
The macros are provided by the Boost Test Library and simple check if two values are equal.
Now we're getting the hang of writing tests. The hard part is setting up the framework. Once that is done, adding tests is pretty simple. You almost look forward to writing the tests and seeing them work correctly. It's much faster and simpler than trying to create the condition to test something in a large application.
Add an Assignment Operator
Lets add an assignment operator. First the test.
void AppVersionAssignmentTests
{
AppVersion a(1, 2, 3, 4);
AppVersion b;
b = a;
BOOST_CHECK_EQUAL(b.GetMajor(), 1U);
BOOST_CHECK_EQUAL(b.GetMinor(), 2U);
BOOST_CHECK_EQUAL(b.GetBuild(), 3U);
BOOST_CHECK_EQUAL(b.GetRevision(), 4U);
b = b;
BOOST_CHECK_EQUAL(b.GetMajor(), 1U);
BOOST_CHECK_EQUAL(b.GetMinor(), 2U);
BOOST_CHECK_EQUAL(b.GetBuild(), 3U);
BOOST_CHECK_EQUAL(b.GetRevision(), 4U);
}
CoreTestSuite->add(BOOST_TEST_CASE(&AppVersionAssignmentTests));
Then the code.
AppVersion& operator=(const AppVersion& b);
AppVersion& AppVersion::operator=(const AppVersion& b)
{
ASSERT_VALID(this);
ASSERT_VALID(&b);
m_Major = b.m_Major;
m_Minor = b.m_Minor;
m_Build = b.m_Build;
m_Revision = b.m_Revision;
return (*this);
}
One important thing to note in the implementation of operator=
is that we didn't check for self-assignment. In this object, there isn't anything that can fail since we're only copying simple integers. Implementing a correct exception-safe operator=
when the object has allocated data is for another article. But we do check that both the current object and the one we're copying from are valid.
Convert to Text
We're also going to provide a couple of functions to return the version number as a string
. These are implemented as non-member functions since they can be implemented with only the public
interface.
#include <iostream>
const CString GetText(const AppVersion& AV);
std::ostream& operator<<(std::ostream& os, const AppVersion& AV);
const CString GetText(const AppVersion& AV)
{
ASSERT_VALID(&AV);
CString Result;
Result.Format("%lu.%lu.%lu.%lu",
AV.GetMajor(), AV.GetMinor(),
AV.GetBuild(), AV.GetRevision());
return (Result);
}
ostream& operator<<(ostream& os, const AppVersion& AV)
{
os << static_cast<LPCTSTR>(GetText(AV));
return (os);
}
void AppVersionTextTests(void)
{
AppVersion a(1, 2, 3, 4);
BOOST_CHECK_EQUAL(GetText(a), "1.2.3.4");
ostringstream os;
os << a;
BOOST_CHECK_EQUAL(os.str(), "1.2.3.4");
}
Add Comparison Operators
So this class is neat and everything, but all it does is hold four numbers and return them. What is needed are some comparison operators so we can easily compare two versions to determine which one is newer. We might also want to put them into an STL container and sort them. Our first thought would be to provide an implementation of all the comparison operators.
- operator<
- operator<=
- operator==
- operator!=
- operator>=
- operator>
That will be a lot of code to write and get correct. If we think about this a moment, we realize that a lot of the operators could be implemented in terms of some of the other operators. That will reduce the amount of code and help keep all of the operations correct, but we can do even better!
Once again, Boost has a solution. There is a set of operator templates that provide implementations of operators with only a few provided by the class. Since we're only concerned with comparison operators, we're only going to use a small subset of what is available.
To use the Boost Operators library, our class needs to be derived from some of the Boost templates.
#include "boost\operators.hpp"
class AppVersion : public CObject,
public boost::totally_ordered<AppVersion>
{
...
Since we're only concerned with comparisons, we'll use the totally_ordered
template. The only functions it requires are operator<
and operator==
. It provides all of the other comparison operators.
First the tests.
void AppVersionComparisonTests(void)
{
BOOST_CHECK_EQUAL(AppVersion(0, 0, 0, 0), AppVersion(0, 0, 0, 0));
BOOST_CHECK_EQUAL(AppVersion(0, 0, 0, 1), AppVersion(0, 0, 0, 1));
BOOST_CHECK_EQUAL(AppVersion(0, 0, 1, 0), AppVersion(0, 0, 1, 0));
BOOST_CHECK_EQUAL(AppVersion(0, 1, 0, 0), AppVersion(0, 1, 0, 0));
BOOST_CHECK_EQUAL(AppVersion(1, 0, 0, 0), AppVersion(1, 0, 0, 0));
BOOST_CHECK_EQUAL(AppVersion(0, 0, 0, 0) < AppVersion(0, 0, 0, 0), false);
BOOST_CHECK_EQUAL(AppVersion(0, 0, 0, 0) <= AppVersion(0, 0, 0, 0), true);
BOOST_CHECK_EQUAL(AppVersion(0, 0, 0, 0) == AppVersion(0, 0, 0, 0), true);
BOOST_CHECK_EQUAL(AppVersion(0, 0, 0, 0) != AppVersion(0, 0, 0, 0), false);
BOOST_CHECK_EQUAL(AppVersion(0, 0, 0, 0) >= AppVersion(0, 0, 0, 0), true);
BOOST_CHECK_EQUAL(AppVersion(0, 0, 0, 0) > AppVersion(0, 0, 0, 0), false);
BOOST_CHECK_EQUAL(AppVersion(1, 2, 3, 4) < AppVersion(1, 2, 3, 4), false);
BOOST_CHECK_EQUAL(AppVersion(1, 2, 3, 4) <= AppVersion(1, 2, 3, 4), true);
BOOST_CHECK_EQUAL(AppVersion(1, 2, 3, 4) == AppVersion(1, 2, 3, 4), true);
BOOST_CHECK_EQUAL(AppVersion(1, 2, 3, 4) != AppVersion(1, 2, 3, 4), false);
BOOST_CHECK_EQUAL(AppVersion(1, 2, 3, 4) >= AppVersion(1, 2, 3, 4), true);
BOOST_CHECK_EQUAL(AppVersion(1, 2, 3, 4) > AppVersion(1, 2, 3, 4), false);
BOOST_CHECK_EQUAL(AppVersion(1, 2, 3, 4) < AppVersion(1, 2, 3, 5), true);
BOOST_CHECK_EQUAL(AppVersion(1, 2, 3, 4) <= AppVersion(1, 2, 3, 5), true);
BOOST_CHECK_EQUAL(AppVersion(1, 2, 3, 4) == AppVersion(1, 2, 3, 5), false);
BOOST_CHECK_EQUAL(AppVersion(1, 2, 3, 4) != AppVersion(1, 2, 3, 5), true);
BOOST_CHECK_EQUAL(AppVersion(1, 2, 3, 4) >= AppVersion(1, 2, 3, 5), false);
BOOST_CHECK_EQUAL(AppVersion(1, 2, 3, 4) > AppVersion(1, 2, 3, 5), false);
BOOST_CHECK_EQUAL(AppVersion(1, 2, 3, 4) < AppVersion(1, 2, 4, 4), true);
BOOST_CHECK_EQUAL(AppVersion(1, 2, 3, 4) <= AppVersion(1, 2, 4, 4), true);
BOOST_CHECK_EQUAL(AppVersion(1, 2, 3, 4) == AppVersion(1, 2, 4, 4), false);
BOOST_CHECK_EQUAL(AppVersion(1, 2, 3, 4) != AppVersion(1, 2, 4, 4), true);
BOOST_CHECK_EQUAL(AppVersion(1, 2, 3, 4) >= AppVersion(1, 2, 4, 4), false);
BOOST_CHECK_EQUAL(AppVersion(1, 2, 3, 4) > AppVersion(1, 2, 4, 4), false);
BOOST_CHECK_EQUAL(AppVersion(1, 2, 3, 4) < AppVersion(1, 3, 3, 4), true);
BOOST_CHECK_EQUAL(AppVersion(1, 2, 3, 4) <= AppVersion(1, 3, 3, 4), true);
BOOST_CHECK_EQUAL(AppVersion(1, 2, 3, 4) == AppVersion(1, 3, 3, 4), false);
BOOST_CHECK_EQUAL(AppVersion(1, 2, 3, 4) != AppVersion(1, 3, 3, 4), true);
BOOST_CHECK_EQUAL(AppVersion(1, 2, 3, 4) >= AppVersion(1, 3, 3, 4), false);
BOOST_CHECK_EQUAL(AppVersion(1, 2, 3, 4) > AppVersion(1, 3, 3, 4), false);
BOOST_CHECK_EQUAL(AppVersion(1, 2, 3, 4) < AppVersion(2, 2, 3, 4), true);
BOOST_CHECK_EQUAL(AppVersion(1, 2, 3, 4) <= AppVersion(2, 2, 3, 4), true);
BOOST_CHECK_EQUAL(AppVersion(1, 2, 3, 4) == AppVersion(2, 2, 3, 4), false);
BOOST_CHECK_EQUAL(AppVersion(1, 2, 3, 4) != AppVersion(2, 2, 3, 4), true);
BOOST_CHECK_EQUAL(AppVersion(1, 2, 3, 4) >= AppVersion(2, 2, 3, 4), false);
BOOST_CHECK_EQUAL(AppVersion(1, 2, 3, 4) > AppVersion(2, 2, 3, 4), false);
}
And the code.
bool operator<(const AppVersion& b) const;
bool operator==(const AppVersion& b) const;
bool AppVersion::operator<(const AppVersion& b) const
{
ASSERT_VALID(this);
ASSERT_VALID(&b);
bool Result = false;
if (m_Major < b.m_Major)
{
Result = true;
}
else if (m_Major == b.m_Major)
{
if (m_Minor < b.m_Minor)
{
Result = true;
}
else if (m_Minor == b.m_Minor)
{
if (m_Build < b.m_Build)
{
Result = true;
}
else if (m_Build == b.m_Build)
{
if (m_Revision < b.m_Revision)
{
Result = true;
}
}
}
}
return (Result);
}
bool AppVersion::operator==(const AppVersion& b) const
{
ASSERT_VALID(this);
ASSERT_VALID(&b);
if ((m_Major == b.m_Major) &&
(m_Minor == b.m_Minor) &&
(m_Build == b.m_Build) &&
(m_Revision == b.m_Revision))
return (true);
return (false);
}
Wow, the templates really saved a lot of work. If you are curious and want to see what happens when a test fails, change the first test to compare if 0.0.0.0 is equal to 0.0.0.1.
Surprise, an error message appears in the build output describing the error.
Running Tests
Running 5 test case...
AppVersionTests.cpp(51): error in "AppVersionTests": test AppVersion(0, 0, 0, 0) ==
AppVersion(0, 0, 0, 1) failed [0.0.0.0 != 0.0.0.1]
*** 1 failure in test case "Core Tests"
Project : error PRJ0019: A tool returned an error code: "Running Unit Tests"
Not only does this describe the error, but you can also double-click on the error and be taken right to the failing test.
Serialization Support
The last thing to add is serialization support. Since AppVersion
is derived from CObject
, this is a fairly straightforward task. First the test.
void AppVersionSerializationTests(void)
{
TCHAR TempPath[_MAX_PATH];
if (GetTempPath(sizeof(TempPath) / sizeof(TCHAR), TempPath) == 0)
BOOST_ERROR("Could not obtain temporary directory.");
TCHAR TempFilename[_MAX_PATH];
if (GetTempFileName(TempPath, "Tst", 0, TempFilename) == 0)
BOOST_ERROR("Could not create temporary filename.");
CFile ArchiveFile;
if (ArchiveFile.Open(TempFilename, CFile::modeCreate |
CFile::modeWrite | CFile::shareExclusive | CFile::typeBinary) == 0)
BOOST_ERROR("Could not create temporary file.");
CArchive StoreArchive(&ArchiveFile, CArchive::store);
AppVersion SerOut(1, 2, 3, 4);
SerOut.Serialize(StoreArchive);
StoreArchive.Close();
ArchiveFile.Close();
AppVersion SerIn;
if (ArchiveFile.Open(TempFilename, CFile::modeRead |
CFile::shareExclusive | CFile::typeBinary) == 0)
BOOST_ERROR("Could not open temporary file.");
CArchive LoadArchive(&ArchiveFile, CArchive::load);
SerIn.Serialize(LoadArchive);
LoadArchive.Close();
ArchiveFile.Close();
BOOST_CHECK_EQUAL(SerOut, SerIn);
CFile::Remove(TempFilename);
}
Then the code.
IMPLEMENT_SERIAL(AppVersion, CObject, VERSIONABLE_SCHEMA | 1)
void AppVersion::Serialize(CArchive& ar)
{
ASSERT_VALID(this);
CObject::Serialize(ar);
ar.SerializeClass(GetRuntimeClass());
if (ar.IsStoring())
{
ar << m_Major;
ar << m_Minor;
ar << m_Build;
ar << m_Revision;
}
else
{
unsigned int Schema;
Schema = ar.GetObjectSchema();
switch (Schema)
{
case 1 :
ar >> m_Major;
ar >> m_Minor;
ar >> m_Build;
ar >> m_Revision;
break;
default :
AfxThrowArchiveException(CArchiveException::badSchema, "AppVersion");
break;
}
}
}
We're Done
A lot of topics were covered in this article fairly quickly. We only scratched the surface of the two Boost libraries that we used. More capabilities are provided in both these libraries and the other libraries in the Boost collection.
A couple of things we didn’t cover are testing of exceptions and Unicode. The only place our object can cause an exception is during serialization. Currently, the Boost Test library is written with only std::string
, not std::wstring
. Handling all of the conversion issues would have complicated this article. If there is interest, these can be covered in future installments. There are also a couple of warnings that appear in the Boost templates. The Boost libraries are constantly being improved, so I expect these issues to be addressed in future Boost releases and the compilers provide better template support.
The compiler-generated default constructor, copy constructor, and assignment operator could have been used since this object does not use dynamically allocated memory. I included them to clearly illustrate how the tests and code go hand in hand.
One useful technique when developing unit tests is the use of a code coverage tool. This is a great way of verifying that tests exist for all code paths.
I hope that this article inspires you to add unit tests to code that you write. Once the test structure is in place, adding tests as you go along is quick. By running the tests as part of the build, you never forget to test the code.
I hope you're also inspired to take a look at the other capabilities of the Boost libraries.
History
- August-15-2004: Original article
License
This article has no explicit license attached to it, but may contain usage terms in the article text or the download files themselves. If in doubt, please contact the author via the discussion board below.
A list of licenses authors might use can be found here.
Jim has been developing software for over 25 years. He is a consultant specializing in writing software for commercial products. He has developed software for embedded systems, device drivers, and windows applications.