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
it�s 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 it 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 your 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 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