Revisiting Test Context Classes in C++
In an earlier post, I described how I like to use disposable objects when writing functional tests in C#, to reliably cleanup resources and to promote code reuse across tests. I’ve been writing more tests in C++ recently, so I thought I would revisit the concept of resource-context classes in C++.
In C#, the scope of the resource is controlled by a using
statement. At the end of the using
statement, the Dispose
method is called and the resource managed by the context is cleaned up. If the context is not used within a using
statement, the tester is responsible for calling the Dispose
method explicitly, usually in a try
/finally
statement or in the TestCleanup
method.
In C++, with deterministic destruction and RAII, life is a little simpler. The constructor of the context class creates the resource and, symmetrically, the destructor cleans up the resource. The scope of the resource is simply determined by the scope of the context variable; when the variable goes out of scope, the resource is cleaned up.
Consider the example I used in my previous post where there is a class for interacting with a database and we want to write tests for the GetRecord
method. Some tests will require a database record to act on. The following resource-context class can be used to manage creating and deleting this record.
class DatabaseRecordContext { public: DatabaseRecordContext(std::string name, std::shared_ptr<Database> database) : _name(std::move(name)) , _database(std::move(database)) { _database->CreateRecord(_name); } ~DatabaseRecordContext() { try { _database->DeleteRecord(_name); } catch (const DatabaseException &e) { auto error = boost::format("Error deleting database record %1% : %2%\n") % _name % e.what(); Logger::WriteMessage(error.str().c_str()); } } private: std::shared_ptr<Database> _database; std::string _name; };
This context class can be used to write tests like the following.
TEST_METHOD(GetRecordReturnsRecord) { auto name = std::string("GetRecordReturnsRecord"); auto database = std::make_shared<Database>("testdb.domain.int"); auto context = DatabaseRecordContext(name, database); auto record = database->GetRecord(name); Assert::AreEqual(name, record->GetName()); }
If I need to constrain the scope of the resource to just a portion of the test, I do that as follows.
TEST_METHOD(GetRecordReturnsNullForDeletedRecord) { auto name = std::string("GetRecordReturnsNullForDeletedRecord"); auto database = std::make_shared<Database>("testdb.domain.int"); { auto context = DatabaseRecordContext(name, database); auto record = database->GetRecord(name); Assert::AreEqual(name, record->GetName()); } auto record = database->GetRecord(name); Assert::IsTrue(nullptr == record); }
I described in my previous post that I like to use dependency injection for things like the database session since the test itself almost always requires this session, and avoiding session management in the context makes the context simpler. An important consideration in C++ is the scope of the database session. This is why I use a std::shared_ptr
to hold the session. Even if the database session goes out of scope in the test itself, the context class will still hold a pointer to a valid session.
Practical Considerations
I think all of the practical considerations that I outlined in my second post on resource-context classes in C# also apply to resource-context classes in C++: using multiple contexts, interacting with the context itself, deciding whether or not to handle exceptions, etc. The only difference perhaps is using the context within the test initialization method. Unlike having to call Dispose
on the C# context in the test cleanup method, there is no need to do anything in the test cleanup method since the resource will just go out of scope at the end of the test and will be cleaned up when the destructor is called.
TEST_CLASS(GetRecordTests) { public: TEST_METHOD_INITIALIZE(TestInitialize) { _name = std::string("TestRecord"); _database = std::make_shared<Database>("testdb.domain.int"); _context = std::make_unique<DatabaseRecordContext>(_name, _database); } TEST_METHOD(GetRecordReturnsRecord) { auto record = _database->GetRecord(_name); Assert::AreEqual(_name, record->GetName()); } private: std::string _name; std::shared_ptr _database; std::unique_ptr _context; };