Some time ago I’ve read a blog post what if programming languages were vehicles, where plain C was compared to an old-school military jeep WillysMC, and C++ was a brand-new Humvee (High Mobility Multipurpose Wheeled Vehicle) – I find it a very nice parallel. It inspired me to the afterthought:
Sometimes one cannot freely choose the technology in which the product has to be developed. It is not a big challenge to operate successfully using a top-notch technology.
But what if you are put in a hostile environment, and instead of a Humvee you have to operate with a run down jeep?
Real life example
We have to develop extensions of a library used by our client. For various reasons using another library is not an option. The library is over 1 MLOC of spaghetti and very legacy code, written in plain C. There are no automated tests of the library (no unit-tests, no integration tests, no tests at all).
However, we would like to write unit tests like:
TEST_F(DivideShould, round_to_closest_int_on_config_flag_SHOULD_ROUND_on) {
EXPECT_CALL(checkConfigFlagMock.get(), checkConfigFlag(SHOULD_ROUND))
.WillRepeatedly(Return(TRUE));
EXPECT_EQ(3, divide(5, 2));
}
TEST_F(DivideShould, round_down_on_config_flag_SHOULD_ROUND_off) {
EXPECT_CALL(checkConfigFlagMock.get(), checkConfigFlag(SHOULD_ROUND))
.WillRepeatedly(Return(FALSE));
EXPECT_EQ(2, divide(5, 2));
}
Moreover, we would like these tests to be light-weight and fast-building, so that we could use TDD.
Encountered problems
An obvious solution would be: to create a separate standalone small library with our new feature. Unfortunately this is not an option for two reasons:
- There are two-way dependencies (we depend on some parts of the legacy lib, the other parts of the legacy lib depend on us, the legacy lib cannot be split).
- We cannot modify the build toolchain of the legacy library.
Lack of access to the build toolchain is a source of another problem – building the legacy library is extremely slow.
Furthermore, the code needs to work on WinCE 6.0. Thus it can be build only by a VisualStudio. However we would like to develop (and run unit-tests) on linux.
How did we deal with it
- We followed several “good practices” – to isolate our new code as much as possible, and thus to make it testable.
- We implemented a few hacks to cut the dependencies.
- We accepted, that there are parts of code, that cannot be tested (unless abnormally large effort for refactoring the legacy code were taken).
Good practices
- Keep the platform-specific code (for example: sockets, filesystem, etc.) in separate modules.
These modules will not be unit-testable under Linux, but well, we cannot have everything.- Whenever possible – do not expose platform-specific types (e.g. `SOCK`) in headers. It makes all the files including such header becoming platform-specific.
- One module – one “class”.
- Use static functions – they serve as private methods.
- Use conventions when you do not have access control.
- We have a structure that contains some fields we consider private, but the structure has to be published in the header of the module.
- The rule of thumb – the fields starting with underscore should be accessed only by functions from the module, where the structure was defined.
Hack #1: Parallel CMake-based build toolchain for unit-tests
As it was mentioned – we do not own the build toolchain and the solution must work on WinCE 6.0. This complicates working with unit-tests, since building requires compiling the entire 1MLOC solution and running unit-tests requires deployment on an archaic machine.
In order to workaround these problem we simply pretend, that our additional code is a separate, platform-independent library.
- write CMake scripts for building our small library (let’s call it
extensionLib
)- Obviously we omit the platform-dependent modules.
- we write CMake script for additional library (let’s call it
legacy-stub
). It has an API consisting of these functions from the original legacy library, that we has to use.- We write the stub implementations of the functions from
legacy-stub
. These functions will not be called anyway (see the Hack #2), they are only to makeextensionLib
linkable.
- We write the stub implementations of the functions from
- We write CMake scripts for unit-tests. The unit-tests depend on
extensionLib
, which depends onlegacy-stub
.
Sincelegacy-stub
is a stub, and thus it is portable, the unit-tests can be build on our workstations, using gcc.
Hack #2: Poor-man’s dependency-injection pattern
If a new module depends on the legacy-functions, we do not call them directly. Instead we introduce pointer to each legacy-function (being a static field of the module) and refer to these functions only via the pointer.
Moreover we introduce an additional function called _EnableTestMode()
or similar, that substitutes the pointers to legacy functions. This function is called in the constructor of test-case class so that it sets the pointers to the desired methods of mock classes.
Example
- A module
divide.c
should implement a function, that divides two integer number. - Depending on the configuration flag
SHOULD_ROUND
, the result should be either rounded to the closest integer (i.e. 2,5 goes to 3, while 2,499 goes to 2), or rounded always down (as the standard/
operation in C). - The configuration is managed by the legacy library. We has to access it using the legacy function:
BoolType LegacyReadConfigBit(ConfigKeyType configKey);
- We want to have unit-tests of the module
divide.c
In the header divide.h
typedef BoolType (*checkConfigFlagFunc)(ConfigKeyType configKey); /* BoolType and ConfigKeyType are types from the legacy library. * * Keep in mind that in plain C there is no build-in bool type. */ void _EnableTestMode(checkConfigFlagFunc checkConfigFlagMock); int divide(int dividend, int divider);
In the divide.c
static checkConfigFlagFunc checkConfigFlag = LegacyReadConfigBit; /* The legacy function */
void _EnableTestMode(checkConfigFlagFunc checkConfigFlagMock) {
checkConfigFlag = checkConfigFlagMock;
}
/* And in the code of other functions refer only to `checkConfigFlag()`: */
int divide(int dividend, int divider) {
if (checkConfigFlag(SHOULD_ROUND)) {
return sgn(dividend) * sgn(divider) * (abs(dividend) + (abs(divider) >> 1) ) / abs(divider);
} else {
return dividend / divider;
}
}
In the tests divide_test.cpp
#include "gtest/gtest.h"
#include "gmock/gmock.h"
#include "boost/optional.hpp"
#include "boost/utility/typed_in_place_factory.hpp"
extern "C" {
#include "divide.h"
} // extern "C"
using namespace testing;
class CheckConfigFlagMock {
public:
MOCK_METHOD1(checkConfigFlag, BoolType(ConfigKeyType));
};
class DivideShould: public testing::Test {
protected:
DivideShould() {
checkConfigFlagMock = boost::in_place<CheckConfigFlagMock>();
_EnableTestMode(DivideShould::checkConfigFlagMockFunc);
}
~DivideShould() {
checkConfigFlagMock.reset(); // In order to destroy the mock, and thus verify the calls.
}
static boost::optional<CheckConfigFlagMock> checkConfigFlagMock;
private:
static BoolType checkConfigFlagMockFunc(ConfigKeyType configKey) {
return checkConfigFlagMock ?
checkConfigFlagMock.get().checkConfigFlag(configKey)
: LegacyReadConfigBit(configKey);
}
}
boost::optional<CheckConfigFlagMock> DivideShould::checkConfigFlagMock;
TEST_F(DivideShould, round_to_closest_int_on_config_flag_SHOULD_ROUND_on) {
EXPECT_CALL(checkConfigFlagMock.get(), checkConfigFlag(SHOULD_ROUND))
.WillRepeatedly(Return(TRUE));
EXPECT_EQ(3, divide(5, 2));
}
TEST_F(DivideShould, round_down_on_config_flag_SHOULD_ROUND_off) {
EXPECT_CALL(checkConfigFlagMock.get(), checkConfigFlag(SHOULD_ROUND))
.WillRepeatedly(Return(FALSE));
EXPECT_EQ(2, divide(5, 2));
}
You must be logged in to post a comment.