The name OpenALpp suggests that is is directly dependent on the OpenAL functionality. This means that there are lower level calls to OpenAL directly in the code. This makes the code hard to test.
One example would be the following code in the SoundContext constructor.
SoundContext::SoundContext()
{
/// call alcOpenDevice (low level function)
m_device = std::unique_ptr<ALCdevice>(alcOpenDevice(nullptr));
/// if alcOpenDevice failed, throw an exception
if (!m_device) {
throw oalpp::AudioSystemException { "Could not open audio device" };
}
/// call alcCreateContext (low level function)
m_context = std::unique_ptr<ALCcontext>(alcCreateContext(m_device.get(), nullptr));
// if alcCreateContext failed, throw an exception
if (!m_context) {
throw oalpp::AudioSystemException { "Could not create audio context" };
}
}
Note: The actual code is a bit more complicated due to custom destructors. This is left out for presentation purposes, but the full code can be found here.
As OpenAL is a C-library there are not points to hook in custom behavior. Thus it is not easy to get the throw statements covered in unit tests, because the alc*
functions can not be mocked.
Of course one could write system level tests that would remove/block the audio device from the system, which would cause an exception, but that is strongly OS dependent and not very elegant. Additionally the could would still depend on the low level OpenAL library. decoupling the constructor code more from the low level functions would be beneficial.
There is a very nice way to get the throw statements covered. With that the code can be tested even in simple unit tests. And the changes do not affect the users of the class at all.
I stated above that OpenAL does not provide points to hook in custom behavior. So let's create those hook in points our selves. The rough outline will be that for every low level function we will have a parameter to the constructor (this is called Constructor Injection). This will allow us to hook in custom behavior. The default (for the optional parameters) will be to just use the low level functions.
Let's first extract the low level functions into some functions that represent the actual behavior. Those functions will be used later as the local default.
namespace {
auto defaultDeviceFactory()
{
return std::unique_ptr<ALCdevice>(alcOpenDevice(nullptr));
}
auto defaultContextFactory(ALCdevice* device)
{
return std::unique_ptr<ALCcontext>(alcCreateContext(device, nullptr));
}
} // namespace
Additionally there are two using declarations to make the constructor more readable. They basically are just std::function
s, which return a std::unique_ptr
to the respective low-level type.
using DeviceFactoryT = std::function<std::unique_ptr<ALCdevice>()>;
using ContextFactoryT = std::function<std::unique_ptr<ALCcontext>(ALCdevice*)>;
The constructor now gets two additional arguments. The default argument is a nullpointer, which indicates that the default function should be used.
The constructor itself now checks for the factory functions. If one of them is a nullptr, the default function is used, otherwise the one provided by the user.
And voila, there are the points to hook in custom code.
SoundContext::SoundContext(SoundContext::DeviceFactoryT deviceFactory = nullptr,
SoundContext::ContextFactoryT contextFactory = nullptr)
{
if (deviceFactory == nullptr) {
deviceFactory = defaultDeviceFactory;
}
m_device = deviceFactory();
if (!m_device) {
throw oalpp::AudioSystemException { "Could not open audio device" };
}
if (contextFactory == nullptr) {
contextFactory = defaultContextFactory;
}
m_context = contextFactory(m_device.get());
if (!m_context) {
throw oalpp::AudioSystemException { "Could not create audio context" };
}
}
The test is now plain simple to write: Provide a function that returns a nullpointer and expect the constructor to throw in that case.
TEST_CASE("Create SoundContext which cannot allocate ALDevice throws", "[SoundContext]")
{
SoundContext::DeviceFactoryT factory = []() {
return std::unique_ptr<ALCdevice>(nullptr);
};
REQUIRE_THROWS(SoundContext { factory });
}
With this Dependency Injection it is now possible to provide custom "replacement" behavior for low level functions. This allows for detailed testing and does not affect the API for the user. They can still default-construct the SoundContext
.
Finally this enables the complete set of DI benefits. If you would like to have custom logging, which includes the low level pointers, you could just provide a custom logging decorator. Buffering, security and access control are other possible benefits.
You can find the full PR here. Finally I can strongly recommend the book "Dependeny Injection" amazon link which explains the contents of this article (and much more) in an awesome way. Definitely a great read!