Seams
When you start to try to pull out individual classes for unit testing, often you have to break a lot of dependencies. Interestingly enough, you often have a lot of work to do, regardless of how "good" the design is. Pulling classes out of existing projects for testing really changes your idea of what "good" is with regard to design. It also leads you to think of software in a completely different way. The idea of a program as a sheet of text just doesn't cut it anymore. How should we look at it? Let's take a look at an example, a function in C++.
bool CAsyncSslRec::Init() { if (m_bSslInitialized) { return true; } m_smutex.Unlock(); m_nSslRefCount++; m_bSslInitialized = true; FreeLibrary(m_hSslDll1); m_hSslDll1=0; FreeLibrary(m_hSslDll2); m_hSslDll2=0; if (!m_bFailureSent) { m_bFailureSent=TRUE; PostReceiveError(SOCKETCALLBACK, SSL_FAILURE); } CreateLibrary(m_hSslDll1,"syncesel1.dll"); CreateLibrary(m_hSslDll2,"syncesel2.dll"); m_hSslDll1->Init(); m_hSslDll2->Init(); return true; }
It sure looks like just a sheet of text, doesn't it? Suppose that we want to run all of that method except for this line:
PostReceiveError(SOCKETCALLBACK, SSL_FAILURE);
How would we do that?
It's easy, right? All we have to do is go into the code and delete that line.
Okay, let's constrain the problem a little more. We want to avoid executing that line of code because PostReceiveError is a global function that communicates with another subsystem, and that subsystem is a pain to work with under test. So the problem becomes, how do we execute the method without calling PostReceiveError under test? How do we do that and still allow the call to PostReceiveError in production?
To me, that is a question with many possible answers, and it leads to the idea of a seam.
Here's the definition of a seam. Let's take a look at it and then some examples.
Seam
A seam is a place where you can alter behavior in your program without editing in that place.
Is there a seam at the call to PostReceiveError? Yes. We can get rid of the behavior there in a couple of ways. Here is one of the most straightforward ones. PostReceiveError is a global function, it isn't part of the CAsynchSslRec class. What happens if we add a method with the exact same signature to the CAsynchSslRec class?
class CAsyncSslRec { ... virtual void PostReceiveError(UINT type, UINT errorcode); ... };
In the implementation file, we can add a body for it like this:
void CAsyncSslRec::PostReceiveError(UINT type, UINT errorcode) { ::PostReceiveError(type, errorcode); }
That change should preserve behavior. We are using this new method to delegate to the global PostReceiveError function using C++'s scoping operator (::). We have a little indirection there, but we end up calling the same global function.
Okay, now what if we subclass the CAsyncSslRec class and override the PostReceiveError method?
class TestingAsyncSslRec : public CAsyncSslRec { virtual void PostReceiveError(UINT type, UINT errorcode) { } };
If we do that and go back to where we are creating our CAsyncSslRec and create a TestingAsyncSslRec instead, we've effectively nulled out the behavior of the call to PostReceiveError in this code:
bool CAsyncSslRec::Init() { if (m_bSslInitialized) { return true; } m_smutex.Unlock(); m_nSslRefCount++; m_bSslInitialized = true; FreeLibrary(m_hSslDll1); m_hSslDll1=0; FreeLibrary(m_hSslDll2); m_hSslDll2=0; if (!m_bFailureSent) { m_bFailureSent=TRUE; PostReceiveError(SOCKETCALLBACK, SSL_FAILURE); } CreateLibrary(m_hSslDll1,"syncesel1.dll"); CreateLibrary(m_hSslDll2,"syncesel2.dll"); m_hSslDll1->Init(); m_hSslDll2->Init(); return true; }
Now we can write tests for that code without the nasty side effect.
This seam is what I call an object seam. We were able to change the method that is called without changing the method that calls it. Object seams are available in object-oriented languages, and they are only one of many different kinds of seams.
Why seams? What is this concept good for?
One of the biggest challenges in getting legacy code under test is breaking dependencies. When we are lucky, the dependencies that we have are small and localized; but in pathological cases, they are numerous and spread out throughout a code base. The seam view of software helps us see the opportunities that are already in the code base. If we can replace behavior at seams, we can selectively exclude dependencies in our tests. We can also run other code where those dependencies were if we want to sense conditions in the code and write tests against those conditions. Often this work can help us get just enough tests in place to support more aggressive work.