C. Keith Ray

C. Keith Ray writes about and develops software in multiple platforms and languages, including iOS® and Macintosh®.
Keith's Résumé (pdf)

Monday, December 29, 2008

William Wake described the essence of an automated test as Arrange, Act, Assert. I've added "Erase", to account for the clean-up that some tests have to do. You might ask why "Erase" added to "Arrange, Act, Assert" and not some word starting with "A". I think starting with "eh?" is close enough. :-)

"The thing about elves is they've got no ... begins with m," Granny snapped her fingers irritably."

"Manners?"

"Hah! Right, but no."

"Muscle? Mucus? Mystery?"

"No. No. No. Means like ... seein' the other person's point of view."

Verence tried to see the world from a Granny Weatherwax perspective, and suspicion dawned.

"Empathy?"

"Right. None at all. Even a hunter, a good hunter, can feel for the quarry. That's what makes 'em a good hunter. Elves aren't like that. They're cruel for fun [...]"

—Terry Pratchett, Lords and Ladies


In C++, with certain test frameworks, a test might be specified in a manner something like the following in C++.



TEST(TestBlurImageFilter)
{
// Arrange
string outfileName = NewTempFileName("TestImageFilter");
Image* sourceImage = new Image("lena.png");

// Act
ImageFilter* filter = new BlurImageFilter();
filter->ProcessToFile(sourceImage, outfileName);

// Assert
AssertImagesEqual("expected_lena_blurred.png", outfileName);

// Erase
DeleteTempFile(outfileName);
delete filter;
delete sourceImage;
}


Note: generally you don't want to deal with files in unit tests; working in-memory would be much faster. Also, if this is one of those frameworks that throws an exception, or otherwise aborts the test if an assertion fails, then the "Erase" portion of the test won't get executed if AssertImagesEqual failed. Let's assume that's not a problem for the moment.

Let's imagine that you then write a another test like so:



TEST(TestUnblurImageFilter)
{
// Arrange
string outfileName = NewTempFileName("TestImageFilter");
Image* sourceImage = new Image("lena.png");

// Act
ImageFilter* filter = new UnblurImageFilter();
filter->ProcessToFile(sourceImage, outfileName);

// Assert
AssertImagesEqual("expected_lena_unblurred.png", outfileName);

// Erase
DeleteTempFile(outfileName);
delete filter;
delete sourceImage;
}


Now you've got duplicated "Arrange" and "Erase" sections. And duplicated logic in tests can be just as bad it would be in production code. Fortunately, most test frameworks already have support for extracting "Arrange" and "Erase" to methods in a "test fixture". The above code could be refactored to something like the following:


class ImageFilterTests : public TestFixture
{
public:
ImageFilterTests()
: sourceImage(NULL), filter(NULL)
{
}

string outfileName;
Image* sourceImage;
ImageFilter* filter;

virtual void SetUp()
{
// Arrange
outfileName = NewTempFileName("TestImageFilter");
sourceImage = new Image("lena.png");
}
virtual void TearDown()
{
// Erase
DeleteTempFile(outfileName);
delete filter;
delete sourceImage;
}
};

TEST_F(ImageFilterTests, TestBlurImageFilter)
{
// Act
filter = new BlurImageFilter();
filter->ProcessToFile(sourceImage, outfileName);

// Assert
AssertImagesEqual("expected_lena_blurred.png", outfileName);
}

TEST_F(ImageFilterTests, TestUnblurImageFilter)
{
// Act
filter = new UnblurImageFilter();
filter->ProcessToFile(sourceImage, outfileName);

// Assert
AssertImagesEqual("expected_lena_unblurred.png", outfileName);
}


Not only has this eliminated the duplicated logic, most unit test frameworks will also guarantee running the TearDown method even if the test fails, so you don't have to write your own try/catch blocks or other contortions for exception-safe "erase".

You'll see that I also added a constructor to insure that the pointer variables have valid NULL values so we don't delete garbage pointers if the Image or Filter objects were not allocated successfully. (You should consider using boost::shared_ptr and/or boost::scoped_ptr if you're dealing with object pointers in C++ code and tests, by the way.)

In those C++ test frameworks where the test-fixture creation and deletion is done just before and after executing the test, the SetUp and TearDown methods can (almost always) be replaced with a constructor and destructor instead. Using that and boost::scoped_ptr to insure exception-safe object deletion would allow us to write the following code:



class ImageFilterTests : public TestFixture
{
public:
ImageFilterTests()
: outfileName(NewTempFileName("TestImageFilter")),
sourceImage(new Image("lena.png"))
{
// Arrange
}

virtual ~ImageFilterTests()
{
// Erase
DeleteTempFile(outfileName);
}

string outfileName;
boost::scoped_ptr sourceImage;
boost::scoped_ptr filter;
};

TEST_F(ImageFilterTests, TestBlurImageFilter)
{
// Act
filter.reset(new BlurImageFilter());
filter->ProcessToFile(sourceImage.get(), outfileName);

// Assert
AssertImagesEqual("expected_lena_blurred.png", outfileName);
}

TEST_F(ImageFilterTests, TestUnblurImageFilter)
{
// Act
filter.reset(new UnblurImageFilter());
filter->ProcessToFile(sourceImage.get(), outfileName);

// Assert
AssertImagesEqual("expected_lena_unblurred.png", outfileName);
}

No comments:

Post a Comment