Stay COM: stubs and testing

Spread the love

Previously, we built a Windows Task Scheduler sample application using the COM API via WIL. As far as the client code was concerned, COM was a detail encapsulated by the C++ facade we created:

void run()
{
    auto cleanup = init_com();
    auto service = TaskService::connect();
    auto folder = service.get_root_folder();
    auto task = service.create_task();
    task.set_author(L"Author Name");
    task.set_logon_type(TASK_LOGON_INTERACTIVE_TOKEN);
    task.set_settings(true, std::chrono::minutes(5));
    task.add_time_trigger(L"Trigger1", make_date_time(2005y / 1 / 1, 12h + 5min), make_date_time(2015y / 5 / 2, 8h));
    task.add_exec_action(get_executable_path());
    folder.save(task, L"Time Trigger Test Task");
}

Not a COM pointer to be seen here! However, the implementation code has all the gory details:

    void Task::set_author(LPCWSTR author)
    {
        wil::com_ptr<IRegistrationInfo> pRegInfo;
        THROW_IF_FAILED_MSG(m_task->get_RegistrationInfo(pRegInfo.put()), "Cannot get identification pointer");
        auto value = wil::make_bstr(author);
        THROW_IF_FAILED_MSG(pRegInfo->put_Author(value.get()), "Cannot put identification info");
    }

These details are unavoidable — they are essential complexity. But the problem is that nothing here is testable and therefore, nothing is tested. The entire application thus far is contained in main.cpp! To have a chance of testability, we need to break out the components into separate translation units and validate them in our unit test project. So, let’s begin.

The easiest candidate for extraction is the date/time code. We can simply move it into our static library, splitting declarations into a header and definitions (well, the singular definition) into an implementation file. Now, it is testable:

TEST(date_time_test, make)
{
    auto dt = make_date_time(2001y / 2 / 3, 4h + 5min + 6s);

    ASSERT_EQ("2001-02-03 04:05:06", std::format("{:%Y-%m-%d %T}", dt));
}

This change was easy: Move DateTime to lib, add test

However, that change has nothing to do with COM. It’s more of an exercise in setting up our test/development cycle, and so far we’re green. The next step is to extract one of the COM facade classes (easy enough: Move Task to lib) and then comprehensively test it (!!!).

This is the type of test we would like to write, eventually:

TEST(task_test, basic_scenario)
{
    Task task(make_stub_task_definition());

    task.set_author(L"A. Person");
    task.set_logon_type(TASK_LOGON_SERVICE_ACCOUNT);
    task.set_settings(false, 59min);
    task.add_time_trigger(
        L"Trigger1",
        make_date_time(2020y / 10 / 20, 15h + 37min + 42s),
        make_date_time(2022y / 11 / 30, 7h + 6min + 59s));
    task.add_exec_action(L"C:\\WINDOWS\\System32\\notepad.exe");

    auto expected = L". . . something . . .";
    assert_state(task, expected);
}

But there are clearly several prerequisites to get there from here. Before we dive deep on the end-to-end, let’s start from a more modest position. Maybe we can test just the first thing, set_author. Since we’re practicing legacy-style test last development here, we can inspect the implementation code to see what we might need. Following the control flow, we would need at least these elements:

  • An ITaskDefinition interface implementation, to use with the Task facade
  • An ITaskDefinition::get_RegistrationInfo method, which returns an implementation of IRegistrationInfo
  • An IRegistrationInfo::put_Author method

Of course, we cannot use just any implementation of ITaskDefinition. We need a stub class of our own making so that we can control its behavior independently from the real Windows-owned implementation. This is important because we have to program error behavior for all the method calls above, such that those THROW_IF_FAILED_MSG paths are actually exercised.

As usual for legacy code, the initial framework is not going to be easy to establish. We will need dozens, if not hundreds, of lines of code to build even the first stub. The consolation prize is that we can use a tiny bit of C++/WinRT to avoid the nightmare of raw COM implementation semantics. Hello, winrt::implements! First, we declare the class in a header file stub_taskdef.h:

#pragma once

#include <windows.h>
#include <taskschd.h>

#include <wil/cppwinrt.h>

namespace wacpp::test
{

class Stub_ITaskDefinition : public winrt::implements<Stub_ITaskDefinition, ITaskDefinition>
{
public:
    // ITaskDefinition

    STDMETHODIMP get_RegistrationInfo(
        IRegistrationInfo** ppRegistrationInfo) noexcept override;

    STDMETHODIMP put_RegistrationInfo(
        IRegistrationInfo* pRegistrationInfo) noexcept override;

    // . . . more methods . . .

    // IDispatch

    STDMETHODIMP GetTypeInfoCount(
        UINT* pctinfo) noexcept override;

    // . . . more methods . . .
};

}

Then we define the class methods in stub_taskdef.cpp:

#include "stub_taskdef.h"

namespace wacpp::test
{

STDMETHODIMP Stub_ITaskDefinition::get_RegistrationInfo(
    [[maybe_unused]] IRegistrationInfo** ppRegistrationInfo) noexcept
{
    return E_NOTIMPL;
}

// . . . more methods . . . 

}

By default, we won’t observe any of the method parameters and always return E_NOTIMPL — truly, the stubbiest of stubs. Note that ITaskDefinition uses interface inheritance with IDispatch, so we need both sets of methods on this stub. We also need the winrt::implements code, so we pull in those WinRT definitions via WIL’s cppwinrt.h header. This is not strictly necessary, but it’s simpler given that we already have WIL and enables one very important scenario for us: classic COM support. That’s right, we do not have to implement IUnknown or any of that boilerplate, and the resulting component should look just like a “real” COM type to any consumer. First stub done: Create stub for ITaskDefinition

With stub number one out of the way, we move on to number two, IRegistrationInfo. The details are very much the same as before, so we make short work of it: Create stub for IRegistrationInfo

Now we need to start writing the first test. If we want to test every path of set_author, we can follow this recipe:

  1. Initialize the stub such that all inner calls will fail.
  2. Call the method under test and observe the failure.
  3. Set up the stub to make the currently failing inner call succeed instead.
  4. Repeat the above until every call succeeds.
  5. Assert the final successful status.

It would make sense initially to tackle initialization. But what to initialize? Well, we need a class which overrides the default stub behavior with our eventual test-specific logic, thus:

#include <gtest/gtest.h>

#include <windows.h>

#include <wil/com.h>

#include "stub_taskdef.h"
#include "task.h"

namespace
{
    class StubTaskDefinition : public wacpp::test::Stub_ITaskDefinition
    {
    };

    wil::com_ptr<ITaskDefinition> make_stub_task_definition()
    {
        auto ptr = winrt::make<StubTaskDefinition>();
        return wil::make_com_ptr(ptr.detach());
    }
}

namespace wacpp::test
{

    TEST(task_test, set_author)
    {
        Task task(make_stub_task_definition());

        ASSERT_THROW(task.set_author(L"Fail 1"), wil::ResultException);
    }

}

Depending on which Windows SDK version you are using, you might already encounter build errors at this step. Most likely these errors originate from the WinRT base.h header which contains basic support code for all WinRT projection types and is included by WIL. There are many possible ways to address this, but one straightforward solution is to install the Windows 11 SDK and update your CMakeLists.txt to use it (via set(CMAKE_SYSTEM_VERSION 11.0)).

There are a few things to note at this step, as seemingly trivial as it is. First, we cannot directly instantiate winrt::implements types. Instead, we use winrt::make which returns a winrt::com_ptr. Second, our implementation code expects wil::com_ptr so we have to do a small pointer swap here to get the final result. Since our test expects failure and the default stub always returns failure (via E_NOTIMPL), our test indeed throws an exception (specifically, a wil::ResultException) as we have asserted.

But there is something unsatisfying about this. Isn’t it possible that, even though we threw an exception, the internal state of the object under test was modified in an incorrect way? Indeed, we need another assertion to show that the object is still consistent — whatever that means. And how would we even look at the state without violating encapsulation? One potential answer lies directly on the ITaskDefinition interface: the get_XmlText method. This is the supported way to export the task definition which we can exploit for observability. Let’s start with a canned implementation which we will extend over the course of our test’s evolution:

    STDMETHODIMP get_XmlText(BSTR* pXml) noexcept override
    try
    {
        std::wstring xml{};
        xml += L"<Task>";
        xml += L"</Task>";

        auto outer_xml = wil::make_bstr(xml.c_str());
        *pXml = outer_xml.release();

        return S_OK;
    }
    CATCH_RETURN()

Internally we deal in the land of standard C++, so wstring is our type. But for COM interop, we need to speak in BSTRs. WIL has simple conversion functions, though, so we’re well covered here. For now, we define our task XML format with one top-level element Task. It’s empty, indicating we have no state, which is true for the time being. Since all the STL container types (including basic_string) have the potential to throw exceptions, we have to use an exception guard to meet the noexcept specification that all COM methods should follow. (Remember, COM uses error codes exclusively instead of exceptions, for ABI safety.)

Now we can write an assert helper:

void assert_xml(wacpp::Task& task, const std::wstring& expected)
{
    wil::unique_bstr str{};
    THROW_IF_FAILED(task.get().get_XmlText(str.put()));
    std::wstring actual = str.get();

    ASSERT_EQ(expected, actual);
}

Finally, we can assert the task XML in the test:

TEST(task_test, set_author)
{
    Task task(make_stub_task_definition());

    ASSERT_THROW(task.set_author(L"Fail 1"), wil::ResultException);

    assert_xml(task, L"<Task></Task>");
}

This is our skeleton so far: Add skeleton for set_author test

All this work, and yet we’ve only done the first part of our testing recipe. We now have to instruct our stub to not fail at the first step. Rather than inspecting the code this time, let’s change the assertion to help us discover the failure, say, by expecting std::logic_error instead of the correct exception type. Now if we run the test, we get the full error context:

    Expected: task.set_author(L"Fail 1") throws an exception of type std::logic_error.
  Actual: it throws class wil::ResultException with description "...\src\tasksched\lib\task.cpp(18)\tasksched-test.exe!00007FF6133F15B8: (caller: 00007FF6133E52EA) Exception(1) tid(1b734) 80004001 Not implemented

    Msg:[Cannot get identification pointer] [wacpp::Task::set_author(m_task->get_RegistrationInfo(pRegInfo.put()))]
".

Aha! The inner failure is at get_RegistrationInfo, so we have to make that call succeed but fail the rest. Immediately, we have another problem; how exactly are we supposed to fail this call the first time, but not the second time? We need some state that we can manipulate so that the stub knows its immediate instructions. In standard C++ classes, the simplest way to do this involves mutator methods (AKA “setters”), but all we have is a COM pointer. One rather heavy-handed option is to implement our own COM interface with these setter methods, but that sounds like quite a chore. Why not pass the state from outside so that we retain control of it? This is a neat trick which is downright dangerous to use for your external API, but works well for these kinds of test stubs. Here is one potential implementation:

struct Stub
{
    struct Data
    {};

    class TaskDefinition : public wacpp::test::Stub_ITaskDefinition
    {
    public:
        TaskDefinition(const Data& data)
            : m_data(data)
        {}

        // . . .

    private:
        const Data& m_data;
    };
};

wil::com_ptr<ITaskDefinition> make_stub_task_definition(const Stub::Data& data)
{
    auto ptr = winrt::make<Stub::TaskDefinition>(data);
    return wil::make_com_ptr(ptr.detach());
}

// . . . 

TEST(task_test, set_author)
{
    Stub::Data data{};
    Task task(make_stub_task_definition(data));

    ASSERT_THROW(task.set_author(L"Fail 1"), wil::ResultException);

    assert_xml(task, L"<Task></Task>");
}

We have moved all the stub code into a wrapper struct (mostly for organization and namespacing benefits), created a new Data struct, and passed the data along from the test. It doesn’t do anything yet, so let’s create our first failure instruction:

struct Data
{
    HRESULT get_RegistrationInfo_result{};
};

// . . .

TEST(task_test, set_author)
{
    Stub::Data data{ .get_RegistrationInfo_result = E_FAIL };
    Task task(make_stub_task_definition(data));

    ASSERT_THROW(task.set_author(L"Fail 1"), wil::ResultException);

    assert_xml(task, L"<Task></Task>");

    data.get_RegistrationInfo_result = S_OK;

    ASSERT_THROW(task.set_author(L"Fail 2"), wil::ResultException);

    assert_xml(task, L"<Task></Task>");
}

We start by telling the stub to use E_FAIL for the result of get_RegistrationInfo, then flip that status to S_OK and try again. Of course, our stub has to obey the instruction, which on success requires us to give out an IRegistrationInfo instance. So let’s carve out that stub:

#include "stub_reginfo.h"
// . . .
namespace
{

struct Stub
{
    // . . .
    class RegistrationInfo : public wacpp::test::Stub_IRegistrationInfo
    {
    public:
        RegistrationInfo(const Data& data)
            : m_data(data)
        {}

    private:
        const Data& m_data;
    };
    // . . .
}

Now we implement the method in question:

    class TaskDefinition : public wacpp::test::Stub_ITaskDefinition
    {
        // . . .
        STDMETHODIMP get_RegistrationInfo(
            IRegistrationInfo** ppRegistrationInfo) noexcept override
        try
        {
            const auto hr = m_data.get_RegistrationInfo_result;
            if (SUCCEEDED(hr))
            {
                auto ptr = winrt::make<Stub::RegistrationInfo>(m_data);
                ptr.copy_to(ppRegistrationInfo);
            }
            
            return hr;
        }
        CATCH_RETURN()
        // . . .
    };

The basic pattern is that we check our failure result, do the work if successful, and then faithfully return the result. (Note that WinRT/C++ has most of the same goodies for its com_ptr as does WIL’s so it’s quite simple to return an out pointer!) The next phase of the test is done: set_author test allows get_RegistrationInfo

What is the next failure? Using our assert trick, we find it here:

Msg:[Cannot put identification info] [wacpp::Task::set_author(pRegInfo->put_Author(value.get()))]

We address this by implementing the same flow as before but for put_Author:

struct Stub
{
    struct Data
    {
        HRESULT get_RegistrationInfo_result{};
        HRESULT put_Author_result{};
    };

    class RegistrationInfo : public wacpp::test::Stub_IRegistrationInfo
    {
    public:
        RegistrationInfo(const Data& data)
            : m_data(data)
            , m_author()
        {}

        STDMETHODIMP put_Author(
            BSTR author) noexcept override
        try
        {
            const auto hr = m_data.put_Author_result;
            if (SUCCEEDED(hr))
            {
                m_author = author;
            }

            return hr;
        }
        CATCH_RETURN()

    private:
        const Data& m_data;
        std::wstring m_author;
    };

    // . . .
}
// . . .
TEST(task_test, set_author)
{
    Stub::Data data{
        .get_RegistrationInfo_result = E_FAIL,
        .put_Author_result = E_FAIL,
    };
    Task task(make_stub_task_definition(data));

    ASSERT_THROW(task.set_author(L"Fail 1"), wil::ResultException);

    assert_xml(task, L"<Task></Task>");

    data.get_RegistrationInfo_result = S_OK;

    ASSERT_THROW(task.set_author(L"Fail 2"), wil::ResultException);

    assert_xml(task, L"<Task></Task>");

    data.put_Author_result = S_OK;

    task.set_author(L"Some Author");

    const auto expected =
        L"<Task>"
        L"<Author>Some Author</Author>"
        L"</Task>";
    assert_xml(task, expected);
}

We now have the full test implemented, but it won’t pass:

    Expected equality of these values:
  expected
    Which is: L"<Task><Author>Some Author</Author></Task>"
  actual
    Which is: L"<Task></Task>"

Of course, we have not implemented the corresponding get_XmlText for our RegistrationInfo stub. Something like this should work:

    class RegistrationInfo // . . .
    {
        // . . .
        STDMETHODIMP get_XmlText(
            BSTR* pXml) noexcept override
        try
        {
            std::wstring xml{};
            if (!m_author.empty())
            {
                xml += std::format(L"<Author>{}</Author>", m_author);
            }

            auto outer_xml = wil::make_bstr(xml.c_str());
            *pXml = outer_xml.release();

            return S_OK;
        }
        CATCH_RETURN()
        // . . .
    };

But now we have to remember the registration info object we handed out from our task definition stub, and call upon that object to compose our own XML result. Assuming we do not care if we overwrite the registration object every time, this implementation will work:

    class TaskDefinition : public wacpp::test::Stub_ITaskDefinition
    {
    public:
        TaskDefinition(const Data& data)
            : m_data(data)
            , m_registration_info()
        {}

        STDMETHODIMP get_XmlText(
            BSTR* pXml) noexcept override
        try
        {
            std::wstring xml{};
            xml += L"<Task>";

            if (m_registration_info)
            {
                wil::unique_bstr str;
                THROW_IF_FAILED(m_registration_info->get_XmlText(str.put()));
                xml += str.get();
            }

            xml += L"</Task>";

            auto outer_xml = wil::make_bstr(xml.c_str());
            *pXml = outer_xml.release();

            return S_OK;
        }
        CATCH_RETURN()

        STDMETHODIMP get_RegistrationInfo(
            IRegistrationInfo** ppRegistrationInfo) noexcept override
        try
        {
            const auto hr = m_data.get_RegistrationInfo_result;
            if (SUCCEEDED(hr))
            {
                m_registration_info = winrt::make<Stub::RegistrationInfo>(m_data);
                m_registration_info.copy_to(ppRegistrationInfo);
            }

            return hr;
        }
        CATCH_RETURN()

    private:
        const Data& m_data;
        winrt::com_ptr<IRegistrationInfo> m_registration_info;
    };

With all these changes, our test is green again: Complete set_author test

If we mechanically follow this procedure, oh, 10 or 15 more times across the set of COM interfaces and methods we use, eventually we’ll have full coverage of our Task class. It’s not glamorous work, but it sure beats untested code!

One thought on “Stay COM: stubs and testing

  1. Pingback: Stay COM: finishing up the tests – WriteAsync .NET

Leave a Reply

Your email address will not be published. Required fields are marked *