Getting GUI code into a test harness

You've got some legacy C++ code to support. The GUI code has almost no unit tests, but it works.

But now you need to change it. Maybe you need to add a new menu item, or change a generic message box into a custom dialog.

Working Effectively with Legacy CodeFor whatever reason, you need to change your GUI code. It works now, but you're afraid of breaking things. What you really need to do is get that code into a test harness, so you can quickly spot any unintended changes in the behavior of your code.

For getting legacy code into a test harness I can't recommend Working Effectively with Legacy Code highly enough. In fact, it also taught me a lot about designing new code to be test-friendly.

Let's look at a simple example. I've got an application that uses a yes/no/cancel dialog to get some user input. I want to change this to a custom dialog with meaningful text in the buttons, and a "Don't ask me again" checkbox. Here's a prototype hacked together with wxPython:

Don't ask, don't tell!

Here's the code that uses the yes/no/cancel dialog:

void CMainFrame::QueryMerge(const CString &filename)
{
    CString message ;
    message.Format( IDS_QUERY_MERGE_MSG, filename ) ;
    CString title;
    title.LoadString( IDS_QUERY_MERGE_TITLE ) ;

    switch( MessageBox( message,
                        title,
                        MB_YESNOCANCEL |
                           MB_ICONEXCLAMATION |
                           MB_SETFOREGROUND ) )
    {
        case IDYES:
            m_memories->merge_memories(filename) ;
            break ;
        case IDNO:
            m_memories->add_memory(filename) ;
            break ;
        case IDCANCEL:
            user_feedback( IDS_ACTION_CANCELED ) ;
            break ;
    }
}

We need to get this into a test harness before we can change to the custom dialog. The problems are the calls to Format, LoadString, and MessageBox. We need to isolate these, so that we can unit test this method (assume we've already got test interfaces for m_memories and user_feedback).

My strategy here is to create an abstract class, with a real child class that does the job of the method above, and a fake one that we can use for testing. I'm using the WTL on Windows, but the basic concepts apply to just about all GUI programming.

The abstract base class:

class UserFeedback
{
    public:
    virtual INT_PTR ask_merge(const CString &filename) = 0 ;
} ;

The real child class:

class RealUserFeedback : public UserFeedback
{
    CWindow m_parent ;
    public:
    RealUserFeedback(HWND parent) :
        m_parent(parent)
    {
    }

    INT_PTR ask_merge(const CString &filename)
    {
        CString message ;
        message.Format( IDS_QUERY_MERGE_MSG, filename ) ;
        CString title;
        title.LoadString( IDS_QUERY_MERGE_TITLE ) ;

        return ( m_parent.MessageBox( message,
                            title,
                            MB_YESNOCANCEL |
                               MB_ICONEXCLAMATION |
                               MB_SETFOREGROUND ) ) ;
    }
} ;

The fake child class:

class FakeUserFeedback : public UserFeedback
{
    public:
    INT_PTR m_ask_merge_val ;

    INT_PTR ask_merge(const CString &filename)
    {
        filename ;
        return m_ask_merge_val ;
    }
} ;

The new method:

void CMainFrame::QueryMerge(const CString &filename,
                       UserFeedback *feedback)
{
    switch( feedback->ask_merge(filename) )
    {
        case IDYES:
            m_memories->merge_memories(filename) ;
            break ;
        case IDNO:
            m_memories->add_memory(filename) ;
            break ;
        case IDCANCEL:
            user_feedback( IDS_ACTION_CANCELED ) ;
            break ;
    }
}

Of course, now all the code that calls QueryMerge will fail to compile, but the compiler will helpfully tell us where those places are, and we can fix them pretty easily. So it's safe in that it won't compile until we get it right.

Here's how a call to this method might look now:

    RealUserFeedback user_fb(*this) ;
    QueryMerge( filename, &user_fb ) ;

Here's a possible unit test for the function (using EasyUnit):

TEST( CMainFrame, QueryMergeCancel)
{
    CMainFrame &mainframe ;
    set_up_fake_members(mainframe) ; // convenience function…
    FakeUserFeedback user_fb ;
    user_fb.m_ask_merge_val = IDCANCEL
    CString filename = "c:foo.xml" ;

    mainframe.QueryMerge( filename, &user_fb ) ;

    ASSERT_EQUALS( mainframe.m_memories->size(), 0 ) ;
}

As I put more methods into the test harness, I can keep expanding my UserFeedback class, until just about all my interaction with the user is encapsulated.

Of course, there's a lot of other refactoring I can do. For example, the method name isn't very good — the method queries the user about merging, but it also performs actions based on the user's response. But the point is, once we get the code into a test harness, we can refactor with a lot more confidence.

No comments yet. Be the first.

Leave a reply