หนึ่งในสิ่งที่เกือบทุกคนสังเกตเห็นเมื่อลองเขียน Test สำหรับโค้ดที่มีอยู่แล้วก็คือ โค้ดส่วนใหญ่ไม่เหมาะกับการ Testing เลย มันไม่ใช่แค่โปรแกรมหรือภาษาใดภาษาหนึ่ง โดยทั่วไปแล้ว Programming Language ดูเหมือนจะไม่ค่อย Support การ Testing สักเท่าไหร่ ดูเหมือนว่าวิธีเดียวที่จะได้โปรแกรมที่ Test ได้ง่ายคือการเขียน Tests ไปพร้อมกับตอนพัฒนา หรือใช้เวลาสักหน่อยเพื่อ "ออกแบบให้ Testable" วิธีแรกนั้นมีความหวังมากกว่า แต่ถ้าดูจากโค้ดส่วนใหญ่ในวงการแล้ว วิธีหลังก็ยังไม่ประสบความสำเร็จเท่าที่ควร

สิ่งหนึ่งที่ผมสังเกตคือ เวลาที่พยายามทำให้โค้ดอยู่ภายใต้ Test ผมเริ่มมองโค้ดในมุมที่แตกต่างออกไป ผมอาจจะคิดว่านี่เป็นแค่นิสัยเฉพาะตัวของผมเอง แต่ผมพบว่ามุมมองที่แตกต่างนี้ช่วยได้มากเวลาที่ต้องทำงานกับ Programming Language ใหม่ๆ ที่ไม่คุ้นเคย เพราะผมไม่สามารถครอบคลุมทุก Programming Language ในหนังสือเล่มนี้ได้ ผมเลยตัดสินใจอธิบายมุมมองนี้ไว้ตรงนี้ โดยหวังว่ามันจะเป็นประโยชน์กับคุณพอๆ กับที่มันเป็นประโยชน์กับผม

A Huge Sheet of Text (ข้อความผืนใหญ่)

ตอนที่ผมเริ่มเขียนโปรแกรมครั้งแรก ผมโชคดีที่เริ่มช้าพอที่จะมีเครื่องเป็นของตัวเองและมี Compiler ไว้ใช้งาน เพื่อนหลายคนของผมเริ่มเขียนโปรแกรมในยุค Punch Card ตอนที่ผมตัดสินใจเรียนเขียนโปรแกรมในมหาวิทยาลัย ผมเริ่มทำงานบน Terminal ใน Lab เราสามารถ Compile โค้ดจากระยะไกลบนเครื่อง DEC VAX ได้ มีระบบคิดเงินเล็กๆ อยู่ด้วย การ Compile แต่ละครั้งจะหักเงินจากบัญชีของเรา และเราได้รับเวลาการใช้เครื่องในจำนวนจำกัดในแต่ละเทอม

ในตอนนั้น สำหรับผม โปรแกรมก็แค่ Listings หนึ่ง ทุกๆ สองสามชั่วโมง ผมจะเดินจาก Lab ไปยังห้องพิมพ์ เอา Printout ของโปรแกรมออกมานั่งดูพินิจพิเคราะห์ว่าอะไรถูกหรือผิด ตอนนั้นผมยังไม่รู้มากพอที่จะสนใจเรื่อง Modularity มากนัก เราต้องเขียนโค้ดแบบ Modular เพื่อแสดงให้เห็นว่าทำได้ แต่ตอนนั้นผมสนใจแค่ว่าโค้ดจะให้คำตอบที่ถูกต้องหรือไม่ พอเริ่มเขียนโค้ดแนว Object-Oriented Modularity ก็เป็นแค่เรื่องวิชาการ ผมคงไม่ได้สลับ Class หนึ่งไปใช้อีก Class หนึ่งในงานที่โรงเรียน ตอนที่ออกมาทำงานจริง ผมเริ่มใส่ใจเรื่องพวกนี้มากขึ้น แต่ตอนเรียน สำหรับผม โปรแกรมก็แค่ Listings หนึ่ง เป็นชุด Function ยาวๆ ที่ต้องเขียนและทำความเข้าใจทีละตัว

มุมมองที่มองโปรแกรมเป็น Listing นี้ดูจะสมเหตุสมผล อย่างน้อยก็ถ้าเราดูจากพฤติกรรมของผู้คนที่มีต่อโปรแกรมที่พวกเขาเขียน ถ้าเราไม่รู้อะไรเลยเกี่ยวกับการเขียนโปรแกรมและเห็นห้องที่เต็มไปด้วยโปรแกรมเมอร์กำลังทำงาน เราอาจจะคิดว่าพวกเขาเป็นนักวิชาการกำลังตรวจสอบและแก้ไขเอกสารสำคัญชิ้นใหญ่ โปรแกรมดูเหมือนผืนข้อความขนาดใหญ่ การเปลี่ยนข้อความเพียงเล็กน้อยสามารถเปลี่ยนความหมายของเอกสารทั้งหมดได้ ผู้คนจึงเปลี่ยนแปลงอย่างระมัดระวังเพื่อหลีกเลี่ยงข้อผิดพลาด

ในเชิงผิวเผินแล้วก็จริง แต่แล้วเรื่อง Modularity ล่ะ? เรามักถูกบอกว่าควรเขียนโปรแกรมที่ประกอบด้วยชิ้นส่วนเล็กๆ ที่สามารถนำกลับมาใช้ใหม่ได้ แต่บ่อยแค่ไหนที่ชิ้นส่วนเล็กๆ เหล่านั้นถูกนำกลับมาใช้อย่างอิสระ? ไม่บ่อยนัก การนำกลับมาใช้ใหม่ (Reuse) นั้นยาก แม้แต่ตอนที่ชิ้นส่วนซอฟต์แวร์ดูเหมือนIndependent กัน แต่จริงๆ แล้วพวกมันมักพึ่งพากันในแบบที่ซับซ้อน

Seams (ซีม)

เมื่อคุณเริ่มพยายามแยก Class แต่ละตัวออกมาเพื่อทำ Unit Testing คุณมักจะต้องตัด Dependencies จำนวนมาก ที่น่าสนใจคือคุณมักจะมีงานต้องทำมากมาย ไม่ว่าดีไซน์จะ "ดี" แค่ไหนก็ตาม การดึง Class ออกจาก Project ที่มีอยู่เพื่อ Testing ทำให้ความคิดของคุณเกี่ยวกับคำว่า "ดี" ในแง่ของดีไซน์เปลี่ยนไปอย่างสิ้นเชิง มันยังทำให้คุณมองซอฟต์แวร์ในมุมที่แตกต่างไปโดยสิ้นเชิง แนวคิดที่ว่าโปรแกรมก็แค่ผืนข้อความนั้นใช้ไม่ได้อีกต่อไป แล้วเราควรมองมันอย่างไร? มาดูตัวอย่างกัน นี่คือ Function ใน 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;
}

ดูแล้วเหมือนผืนข้อความจริงๆ ใช่ไหม? สมมติว่าเราต้องการรัน Method นั้นทั้งหมดยกเว้นบรรทัดนี้:

PostReceiveError(SOCKETCALLBACK, SSL_FAILURE);

ทำยังไงดี?

เราจะทำอย่างนั้นได้ยังไง?

ง่ายนิดเดียว ใช่ไหม? แค่เข้าไปในโค้ดแล้วลบบรรทัดนั้นทิ้ง

โอเค มาทำให้โจทย์มีข้อจำกัดมากขึ้นอีกหน่อย เราต้องการหลีกเลี่ยงการรันโค้ดบรรทัดนั้นเพราะ PostReceiveError เป็น Global Function ที่ติดต่อกับซับซิสเต็มอื่น และซับซิสเต็มนั้นทำงานยากเวลาอยู่ใน Test ดังนั้นปัญหาคือ เราจะรัน Method โดยไม่เรียก PostReceiveError ใน Test ได้ยังไง? แล้วเราจะทำแบบนั้นได้ยังไงในขณะที่ยังให้เรียก PostReceiveError ใน Production ได้อยู่?

สำหรับผม นี่คือคำถามที่มีคำตอบได้หลายทาง และมันนำไปสู่แนวคิดของ Seam

นี่คือนิยามของ Seam มาดูกันก่อน แล้วค่อยดูตัวอย่าง


Seam

Seam คือจุดที่คุณสามารถเปลี่ยนพฤติกรรมในโปรแกรมของคุณได้โดยไม่ต้องแก้ไข ณ จุดนั้น


มี Seam ตรงการเรียก PostReceiveError หรือไม่? มีครับ เราสามารถกำจัดพฤติกรรมตรงนั้นได้หลายวิธี นี่คือวิธีที่ตรงไปตรงมาที่สุดวิธีหนึ่ง PostReceiveError เป็น Global Function ไม่ได้เป็นส่วนหนึ่งของ Class CAsynchSslRec จะเกิดอะไรขึ้นถ้าเราเพิ่ม Method ที่มี Signature เหมือนกันทุกประการให้กับ Class CAsynchSslRec ?

class CAsyncSslRec
{
    ...
    virtual void PostReceiveError(UINT type, UINT errorcode);
    ...
};

ในไฟล์ Implementation เราสามารถเพิ่ม Body ให้มันแบบนี้:

void CAsyncSslRec::PostReceiveError(UINT type, UINT errorcode)
{
    ::PostReceiveError(type, errorcode);
}

การเปลี่ยนแปลงนั้นควรคงพฤติกรรมเดิมไว้ เรากำลังใช้ Method ใหม่นี้เพื่อ Delegate ไปยัง Global Function PostReceiveError โดยใช้ Scoping Operator (::) ของ C++ แม้จะมี Indirectness อยู่บ้าง แต่สุดท้ายเราก็เรียก Global Function ตัวเดิมนั่นแหละ

โอเค แล้วถ้าเราสร้าง Subclass ของ CAsyncSslRec และ Override Method PostReceiveError ล่ะ?

class TestingAsyncSslRec : public CAsyncSslRec
{
    virtual void PostReceiveError(UINT type, UINT errorcode)
    {
    }
};

ถ้าเราทำแบบนั้น แล้วกลับไปยังจุดที่เราสร้าง CAsyncSslRec แล้วสร้าง TestingAsyncSslRec แทน เราก็จะทำให้พฤติกรรมของการเรียก PostReceiveError ในโค้ดนี้เป็นโมฆะ:

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;
}

ตอนนี้เราสามารถเขียน Test สำหรับโค้ดนั้นได้โดยไม่มี Side Effect ที่ไม่พึงประสงค์

Seam แบบนี้ผมเรียกมันว่า Object Seam เราสามารถเปลี่ยน Method ที่ถูกเรียกได้โดยไม่ต้องแก้ไข Method ที่เรียกมัน Object Seam มีให้ใช้ในภาษาเชิง Object-Oriented และมันเป็นเพียงหนึ่งใน Seam หลายๆ แบบที่มีอยู่

ทำไมต้อง Seam? แนวคิดนี้มีประโยชน์ยังไง?

หนึ่งในความท้าทายที่ใหญ่ที่สุดในการนำ Legacy Code มาอยู่ภายใต้ Test คือการตัด Dependencies เมื่อเราโชคดี Dependencies ที่เรามีก็จะเล็กและจำกัดอยู่เฉพาะจุด แต่ในกรณีที่แย่หน่อย มันก็จะมากมายและกระจายไปทั่ว Code Base มุมมองแบบ Seam ของซอฟต์แวร์ช่วยให้เราเห็นโอกาสที่มีอยู่แล้วใน Code Base ถ้าเราสามารถเปลี่ยนพฤติกรรมที่ Seam ได้ เราก็สามารถแยก Dependencies ออกมาเฉพาะใน Tests ของเราได้ นอกจากนี้เรายังสามารถรันโค้ดอื่นๆ แทนที่ Dependencies เหล่านั้นได้ ถ้าเราต้องการตรวจสอบเงื่อนไขในโค้ดและเขียน Tests เพื่อทดสอบเงื่อนไขเหล่านั้น บ่อยครั้งที่งานแบบนี้ช่วยให้เรามี Tests เพียงพอที่จะสนับสนุนงานที่ต้องลงมือหนักขึ้นต่อไป

Seam Types (ประเภทของซีม)

ประเภทของ Seam ที่มีให้นั้นแตกต่างกันไปตาม Programming Language วิธีที่ดีที่สุดในการสำรวจพวกมันคือการดูทุกขั้นตอนที่เกี่ยวข้องกับการเปลี่ยนข้อความของโปรแกรมให้เป็นโค้ดที่รันได้บนเครื่อง แต่ละขั้นตอนที่ระบุได้จะเผยให้เห็น Seam ที่แตกต่างกันออกไป

Preprocessing Seams (ซีมจากการประมวลผลล่วงหน้า)

ในสภาพแวดล้อมการเขียนโปรแกรมส่วนใหญ่ ข้อความของโปรแกรมจะถูกอ่านโดย Compiler จากนั้น Compiler จะปล่อย Object Code หรือ Bytecode Instructions ออกมา แล้วแต่ภาษาจะมีขั้นตอนการประมวลผลหลังจากนั้นอีก แต่ก่อนหน้านั้นล่ะ?

มีภาษาไม่กี่ภาษาที่มี Build Stage ก่อนการ Compile C และ C++ เป็นภาษาที่พบได้บ่อยที่สุด

ใน C และ C++ มี Macro Preprocessor ที่ทำงานก่อน Compiler ตลอดหลายปีที่ผ่านมา Macro Preprocessor ถูกสาปแช่งและถูกตำหนิไม่หยุดหย่อน ด้วยมัน เราสามารถเปลี่ยนข้อความที่ดู innocuous แบบนี้:

TEST(getBalance,Account)
{
    Account account;
    LONGS_EQUAL(0, account.getBalance());
}

และทำให้มันปรากฏแบบนี้ต่อ Compiler

 class AccountgetBalanceTest : public Test
     { public: AccountgetBalanceTest () : Test ("getBalance" "Test") {}
           void run (TestResult& result_); }
   AccountgetBalanceInstance;
     void AccountgetBalanceTest::run (TestResult& result_)
{
     Account account;
{ result_.countCheck();
  long actualTemp = (account.getBalance());
  long expectedTemp = (0);
  if ((expectedTemp) != (actualTemp))
{ result_.addFailure (Failure (name_, "c:\\seamexample.cpp", 24,
StringFrom(expectedTemp),
StringFrom(actualTemp))); return; } }

}

เรายังสามารถซ้อนโค้ดในคำสั่ง Conditional Compilation แบบนี้เพื่อรองรับการ Debugging และ Platform ต่างๆ (aarrrgh!):

...
m_pRtg->Adj(2.0);

#ifdef DEBUG
#ifndef WINDOWS
    { FILE *fp = fopen(TGLOGNAME,"w");
    if (fp) { fprintf(fp,"%s", m_pRtg->pszState); fclose(fp); }}
#endif

m_pTSRTable->p_nFlush |= GF_FLOT;
#endif

...

การใช้ Preprocessing มากเกินไปใน Production Code ไม่ใช่ความคิดที่ดีเพราะมันมักจะลดความชัดเจนของโค้ด คำสั่ง Conditional Compilation ( #ifdef , #ifndef , #if และอื่นๆ) บังคับให้คุณต้องดูแลโปรแกรมหลายๆ ตัวพร้อมกันใน Source Code เดียวกัน Macros (ที่กำหนดด้วย #define ) สามารถใช้ทำสิ่งที่ดีๆ ได้หลายอย่าง แต่มันก็แค่การแทนที่ข้อความแบบง่ายๆ การสร้าง Macros ที่ซ่อน Bug ที่เข้าใจยากนั้นทำได้ง่ายมาก

ถึงจะมีข้อควรระวังพวกนั้น แต่จริงๆ แล้วผมดีใจที่ C และ C++ มี Preprocessor เพราะมันทำให้เรามี Seam มากขึ้น นี่คือตัวอย่าง ในโปรแกรม C เรามี Dependencies ต่อ Library Routine ที่ชื่อ db_update ฟังก์ชัน db_update คุยกับ Database โดยตรง ถ้าเราไม่สามารถสลับไปใช้ Implementation อื่นของ Routine นี้ได้ เราก็ไม่สามารถตรวจสอบพฤติกรรมของฟังก์ชันได้

#include <DFHLItem.h>
#include <DHLSRecord.h>
extern int db_update(int, struct DFHLItem *);

void account_update(
    int account_no, struct DHLSRecord *record, int activated)
{
    if (activated) {
        if (record->dateStamped && record->quantity > MAX_ITEMS) {
            db_update(account_no, record->item);
        } else {
            db_update(account_no, record->backup_item);
        }
    }
    db_update(MASTER_ACCOUNT, record->item);
}

เราสามารถใช้ Preprocessing Seam เพื่อแทนที่การเรียก db_update โดยการเพิ่ม Header File ที่ชื่อ localdefs.h

#include <DFHLItem.h>
#include <DHLSRecord.h>

extern int db_update(int, struct DFHLItem *);

#include "localdefs.h"

void account_update(
    int account_no, struct DHLSRecord *record, int activated)
{
    if (activated) {
        if (record->dateStamped && record->quantity > MAX_ITEMS) {
            db_update(account_no, record->item);
        } else {
            db_update(account_no, record->backup_item);
        }
    }
    db_update(MASTER_ACCOUNT, record->item);
}

จากนั้นข้างในนั้น เราก็สามารถให้ Definition สำหรับ db_update และตัวแปรบางอย่างที่จะเป็นประโยชน์กับเรา:

#ifdef TESTING
...
struct DFHLItem *last_item = NULL;
int last_account_no = -1;

#define db_update(account_no,item)\
    {last_item = (item); last_account_no = (account_no);}
...
#endif

ด้วยการแทนที่ db_update นี้ เราสามารถเขียน Tests เพื่อตรวจสอบว่า db_update ถูกเรียกด้วย Parameter ที่ถูกต้อง เราทำได้เพราะ Directive #include ของ C Preprocessor ทำให้เรามี Seam ที่ใช้แทนที่ข้อความก่อนที่จะถูก Compile

Preprocessing Seam นั้นทรงพลังทีเดียว ผมไม่คิดว่าอยากให้มี Preprocessor ใน Java หรือภาษาสมัยใหม่อื่นๆ หรอก แต่มันก็ดีที่มีเครื่องมือนี้ใน C และ C++ เพื่อชดเชยอุปสรรคในการ Testing อื่นๆ ที่ภาษาพวกนี้มี

ผมไม่ได้พูดถึงมาก่อน แต่มีอีกสิ่งหนึ่งที่สำคัญต้องเข้าใจเกี่ยวกับ Seam: ทุกๆ Seam จะมี Enabling Point มาดูนิยามของ Seam อีกครั้ง:


Seam

Seam คือจุดที่คุณสามารถเปลี่ยนพฤติกรรมในโปรแกรมของคุณได้โดยไม่ต้องแก้ไข ณ จุดนั้น


เมื่อคุณมี Seam คุณจะมีจุดที่พฤติกรรมสามารถเปลี่ยนได้ เราไม่สามารถไปยังจุดนั้นและเปลี่ยนโค้ดแค่เพื่อ Testing ได้ Source Code ควรจะเหมือนกันทั้งใน Production และ Test ในตัวอย่างก่อนหน้านี้ เราต้องการเปลี่ยนพฤติกรรมที่ข้อความของการเรียก db_update เพื่อใช้ประโยชน์จาก Seam นั้น คุณต้องเปลี่ยนแปลงที่อื่น ในกรณีนี้ Enabling Point คือ Preprocessor Define ที่ชื่อ TESTING เมื่อ TESTING ถูกกำหนดไว้ ไฟล์ localdefs.h จะนิยาม Macros ที่จะแทนที่การเรียก db_update ใน Source File


Enabling Point

ทุกๆ Seam จะมี Enabling Point ซึ่งเป็นจุดที่คุณสามารถตัดสินใจว่าจะใช้พฤติกรรมใดพฤติกรรมหนึ่ง


Link Seams (ซีมจากการเชื่อมโยง)

ในระบบภาษาส่วนใหญ่ การ Compilation ไม่ใช่ขั้นตอนสุดท้ายของ Build Process Compiler จะสร้าง Intermediate Representation ของโค้ด และ Representation นั้นจะมีการเรียกไปยังโค้ดในไฟล์อื่นๆ Linker จะรวม Representations เหล่านี้เข้าด้วยกัน มันจะแก้ไขการเรียกแต่ละครั้งเพื่อให้คุณมีโปรแกรมที่สมบูรณ์พร้อมทำงานตอน Runtime

ในภาษาเช่น C และ C++ จะมี Linker แยกต่างหากที่ทำงานตามที่ผมอธิบาย ใน Java และภาษาที่คล้ายกัน Compiler จะทำกระบวนการ Linking เบื้องหลัง เมื่อ Source File มีคำสั่ง import Compiler จะตรวจสอบว่า Class ที่ถูก Import นั้นถูก Compile แล้วหรือยัง ถ้ายัง Compiler ก็จะ Compile ให้ถ้าจำเป็น แล้วตรวจสอบว่าการเรียกทั้งหมดจะทำงานได้อย่างถูกต้องตอน Runtime หรือไม่

ไม่ว่าภาษาของคุณจะใช้ Scheme แบบไหนในการแก้ไข Reference โดยปกติคุณสามารถใช้ประโยชน์จากมันเพื่อสลับชิ้นส่วนของโปรแกรมได้ มาดูกรณีของ Java กัน นี่คือ Class เล็กๆ ที่ชื่อ FitFilter :

package fitnesse;

import fit.Parse;
import fit.Fixture;

import java.io.*;
import java.util.Date;

import java.io.*;
import java.util.*;

public class FitFilter {

    public String input;
    public Parse tables;
    public Fixture fixture = new Fixture();
    public PrintWriter output;

    public static void main (String argv[]) {
        new FitFilter().run(argv);
    }

    public void run (String argv[]) {
        args(argv);
        process();
        exit();
    }

    public void process() {
        try {
            tables = new Parse(input);
            fixture.doTables(tables);
        } catch (Exception e) {
            exception(e);
        }
        tables.print(output);
    }
    ...
}

ในไฟล์นี้เรามีการ Import fit.Parse และ fit.Fixture แล้ว Compiler และ JVM หา Class เหล่านี้ได้ยังไง? ใน Java คุณสามารถใช้ Environment Variable ที่ชื่อ Classpath เพื่อกำหนดว่า Java System จะหา Class เหล่านั้นจากที่ไหน คุณสามารถสร้าง Class ที่มีชื่อเดียวกัน วางไว้ใน Directory อื่น และเปลี่ยน Classpath เพื่อให้ Link ไปยัง fit.Parse และ fit.Fixture คนละตัวได้ แม้ว่าการใช้ Trick แบบนี้ใน Production Code จะทำให้สับสน แต่ตอน Testing มันก็เป็นวิธีที่สะดวกในการตัด Dependencies


สมมติว่าเราต้องการ Supply Parse Class คนละตัวสำหรับ Testing Seam อยู่ตรงไหน?

Seam คือการเรียก new Parse ใน Method process .

แล้ว Enabling Point อยู่ตรงไหน?

Enabling Point คือ classpath .


การ Linking แบบ Dynamic แบบนี้สามารถทำได้ในหลายภาษา ส่วนใหญ่แล้วจะมีวิธีใช้ประโยชน์จาก Link Seam แต่ไม่ใช่ทุกการ Linking ที่เป็น Dynamic ในภาษารุ่นเก่าหลายภาษา การ Linking ส่วนใหญ่เป็น Static ซึ่งเกิดขึ้นครั้งเดียวหลังการ Compilation

ระบบ Build ของ C และ C++ หลายระบบใช้ Static Linking เพื่อสร้าง Executable วิธีที่ง่ายที่สุดในการใช้ Link Seam คือการสร้าง Library แยกต่างหากสำหรับ Class หรือ Function ที่คุณต้องการแทนที่ เมื่อทำแบบนั้น คุณสามารถเปลี่ยน Build Scripts ให้ Link ไปยัง Library เหล่านั้นแทน Production Library ตอน Testing นี่อาจเป็นงานที่ต้องลงมือหน่อย แต่ก็คุ้มถ้า Code Base ของคุณเต็มไปด้วยการเรียกไปยัง Third-Party Library ตัวอย่างเช่น ลองนึกถึง CAD Application ที่มีการเรียกไปยัง Graphics Library จำนวนมาก นี่คือตัวอย่างโค้ดทั่วไป:

void CrossPlaneFigure::rerender()
{
    // draw the label
    drawText(m_nX, m_nY, m_pchLabel, getClipLen());
    drawLine(m_nX, m_nY, m_nX + getClipLen(), m_nY);
    drawLine(m_nX, m_nY, m_nX, m_nY + getDropLen());
    if (!m_bShadowBox) {
        drawLine(m_nX + getClipLen(), m_nY,
                    m_nX + getClipLen(), m_nY + getDropLen());
        drawLine(m_nX, m_nY + getDropLen(),
                    m_nX + getClipLen(), m_nY + getDropLen());
    }

    // draw the figure
    for (int n = 0; n < edges.size(); n++) {
        ...
    }

    ...
}

โค้ดนี้มีการเรียก Graphics Library โดยตรงมากมาย น่าเสียดายที่วิธีเดียวที่จะตรวจสอบว่าโค้ดนี้ทำงานตามที่ต้องการหรือไม่คือการดูที่หน้าจอคอมพิวเตอร์ตอนที่ Figure ถูกวาดใหม่ ในโค้ดที่ซับซ้อนนี่ค่อนข้างผิดพลาดง่าย และยังน่าเบื่ออีกด้วย ทางเลือกอื่นคือการใช้ Link Seam ถ้า Function การวาดทั้งหมดเป็นส่วนหนึ่งของ Library ใด Library หนึ่ง คุณสามารถสร้าง Stub Version ที่ Link กับส่วนที่เหลือของ Application ได้ ถ้าคุณแค่ต้องการแยก Dependency ออกมา มันก็แค่ Function ว่างๆ:

void drawText(int x, int y, char *text, int textLength)
{
}

void drawLine(int firstX, int firstY, int secondX, int secondY)
{
}

ถ้า Function คืนค่า คุณต้องคืนค่าบางอย่าง โดยปกติแล้วการคืนค่าที่บ่งบอกถึงความสำเร็จหรือค่าเริ่มต้นของ Type นั้นๆ ก็เป็นทางเลือกที่ดี:

int getStatus()
{
    return FLAG_OKAY;
}

กรณีของ Graphics Library ค่อนข้างผิดปกติเล็กน้อย เหตุผลหนึ่งที่มันเป็นตัวเลือกที่ดีสำหรับเทคนิคนี้ก็คือมันเป็น Interface แบบ "Tell" เกือบแท้ๆ คุณเรียก Function เพื่อบอกให้มันทำอะไรบางอย่าง และคุณไม่ได้ขอข้อมูลกลับมามากมาย การขอข้อมูลกลับมาทำได้ยากเพราะค่า Default มักไม่ใช่สิ่งที่ถูกต้องในการคืนค่าเมื่อคุณกำลังพยายามทดสอบการทำงานของโค้ด

การแยก Dependency มักเป็นเหตุผลในการใช้ Link Seam คุณสามารถทำ Sensing ได้ด้วย มันแค่ต้องทำงานเพิ่มอีกหน่อย ในกรณีของ Graphics Library ที่เราปลอมขึ้นมา เราสามารถเพิ่ม Data Structure เพื่อบันทึกการเรียก:

std::queue<GraphicsAction> actions;

void drawLine(int firstX, int firstY, int secondX, int secondY)
{
    actions.push_back(GraphicsAction(LINE_DRAW,
            firstX, firstY, secondX, secondY);
}

ด้วย Data Structure เหล่านี้ เราสามารถตรวจสอบผลลัพธ์ของ Function ใน Test ได้:

TEST(simpleRender,Figure)
{
    std::string text = "simple";
    Figure figure(text, 0, 0);

    figure.rerender();
    LONGS_EQUAL(5, actions.size());
    GraphicsAction action;
    action = actions.pop_front();
    LONGS_EQUAL(LABEL_DRAW, action.type);

    action = actions.pop_front();
    LONGS_EQUAL(0, action.firstX);
    LONGS_EQUAL(0, action.firstY);
    LONGS_EQUAL(text.size(), action.secondX);
}

Scheme ที่เราใช้ในการตรวจสอบผลลัพธ์อาจซับซ้อนขึ้นได้เรื่อยๆ แต่ทางที่ดีคือเริ่มจาก Scheme ที่ง่ายที่สุดก่อน แล้วค่อยให้มันซับซ้อนเท่าที่จำเป็นเพื่อแก้ปัญหาการตรวจสอบในปัจจุบัน

Enabling Point สำหรับ Link Seam อยู่นอกข้อความของโปรแกรมเสมอ บางครั้งมันอยู่ใน Build Script หรือ Deployment Script ทำให้การใช้ Link Seam ค่อนข้างสังเกตได้ยาก


Usage Tip (เคล็ดลับการใช้งาน)

ถ้าคุณใช้ Link Seam ให้แน่ใจว่าความแตกต่างระหว่างสภาพแวดล้อม Testing และ Production นั้นชัดเจน


Object Seams (ซีมจากอ็อบเจ็กต์)

Object Seam จัดเป็น Seam ที่มีประโยชน์มากที่สุดในภาษา Object-Oriented สิ่งพื้นฐานที่ต้องเข้าใจคือ เมื่อเราดูการเรียกในโปรแกรม Object-Oriented มันไม่ได้ระบุว่า Method ไหนจะถูกเรียกจริงๆ มาดูตัวอย่าง Java กัน:

cell.Recalculate();

เมื่อเราดูโค้ดนี้ มันดูเหมือนว่าต้องมี Method ชื่อ Recalculate ที่จะถูกเรียกเมื่อเราส่ง Call นั้น ถ้าโปรแกรมต้องรันได้ ก็ต้องมี Method ที่ชื่อนั้น แต่ความจริงคือมันอาจมีมากกว่าหนึ่ง:

Figure 4.1 ลำดับชั้นของ Cell

image

Method ไหนจะถูกเรียกในโค้ดบรรทัดนี้?

cell.Recalculate();

ถ้าไม่รู้ว่า Object cell ชี้ไปที่อะไร เราก็ไม่รู้ มันอาจเป็น Method Recalculate ของ ValueCell หรือ Recalculate ของ FormulaCell หรือแม้แต่ Method Recalculate ของ Class อื่นที่ไม่ได้รับสืบทอดจาก Cell (ถ้าเป็นแบบนั้น cell ก็เป็นชื่อที่โหดร้ายมากสำหรับ Variable นั้น!) ถ้าเราสามารถเปลี่ยน Recalculate ที่ถูกเรียกในโค้ดบรรทัดนั้นโดยไม่ต้องเปลี่ยนโค้ดรอบๆ การเรียกนั้นคือ Seam

ในภาษา Object-Oriented ไม่ใช่ทุก Method Call ที่เป็น Seam นี่คือตัวอย่างของการเรียกที่ไม่ใช่ Seam:

public class CustomSpreadsheet extends Spreadsheet
{
    public Spreadsheet buildMartSheet() {
        ...
        Cell cell = new FormulaCell(this, "A1", "=A2+A3");
        ...
         cell.Recalculate();
        ...
    }
    ...
}

ในโค้ดนี้ เรากำลังสร้าง Cell แล้วใช้มันใน Method เดียวกัน การเรียก Recalculate เป็น Object Seam หรือไม่? ไม่ใช่ มันไม่มี Enabling Point เราไม่สามารถเปลี่ยน Recalculate ที่ถูกเรียกได้เพราะการเลือกขึ้นอยู่กับ Class ของ Cell ซึ่งถูกกำหนดตอนที่ Object ถูกสร้าง และเราเปลี่ยนมันไม่ได้โดยไม่แก้ไข Method

แล้วถ้าโค้ดเป็นแบบนี้ล่ะ?

public class CustomSpreadsheet extends Spreadsheet
{
    public Spreadsheet buildMartSheet( Cell cell ) {
        ...
         cell.Recalculate();
        ...
    }
    ...
}

การเรียก cell.Recalculate ใน buildMartSheet เป็น Seam แล้วหรือยัง? ใช่แล้ว เราสามารถสร้าง CustomSpreadsheet ใน Test และเรียก buildMartSheet ด้วย Cell แบบไหนก็ได้ที่เราต้องการ ผลลัพธ์คือเราสามารถเปลี่ยนสิ่งที่การเรียก cell.Recalculate ทำได้โดยไม่ต้องแก้ไข Method ที่เรียกมัน

แล้ว Enabling Point อยู่ตรงไหน?

ในตัวอย่างนี้ Enabling Point คือ Argument List ของ buildMartSheet เราสามารถตัดสินใจได้ว่าจะส่ง Object แบบไหนเข้าไป และเปลี่ยนพฤติกรรมของ Recalculate ตามที่เราต้องการเพื่อ Testing

โอเค Object Seam ส่วนใหญ่ก็ตรงไปตรงมา แต่นี่คืออันที่ tricky หน่อย มี Object Seam ตรงการเรียก Recalculate ใน buildMartSheet เวอร์ชันนี้ไหม?

public class CustomSpreadsheet extends Spreadsheet
{
    public Spreadsheet buildMartSheet(Cell cell) {
        ...
         Recalculate(cell);
        ...
    }

     private static void Recalculate(Cell cell) {
        ...
    }

    ...
}

Method Recalculate เป็น Static Method การเรียก Recalculate ใน buildMartSheet เป็น Seam หรือไม่? ใช่ครับ เราไม่ต้องแก้ไข buildMartSheet เพื่อเปลี่ยนพฤติกรรมตรงการเรียกนั้น ถ้าเราลบ Keyword static ออกจาก Recalculate และเปลี่ยนเป็น Protected Method แทน Private เราสามารถสร้าง Subclass และ Override มันตอน Test ได้:

public class CustomSpreadsheet extends Spreadsheet
{
    public Spreadsheet buildMartSheet(Cell cell) {
        ...
        Recalculate(cell);
        ...
    }
     protected void Recalculate(Cell cell) {
        ...
    }

    ...
}

public class TestingCustomSpreadsheet extends CustomSpreadsheet {
    protected void Recalculate(Cell cell) {
        ...
    }
}

ทั้งหมดนี้ดูอ้อมไปหน่อยไหม? ถ้าเราไม่ชอบ Dependency ทำไมไม่เข้าไปเปลี่ยนโค้ดตรงๆ เลย? บางครั้งก็ได้ผล แต่ใน Legacy Code ที่ยุ่งเหยิงเป็นพิเศษ วิธีที่ดีที่สุดมักจะเป็นการพยายามปรับเปลี่ยนโค้ดให้น้อยที่สุดเท่าที่จะทำได้ตอนที่กำลังวาง Tests ถ้าคุณรู้จัก Seam ที่ภาษาของคุณมีให้และวิธีใช้มัน คุณมักจะวาง Tests ได้อย่างปลอดภัยมากกว่าที่ควรจะเป็น

ประเภทของ Seam ที่ผมได้แสดงให้เห็นเป็นประเภทหลักๆ คุณสามารถพบมันได้ใน Programming Language หลายภาษา มาดูตัวอย่างที่เริ่มต้นในบทนี้กันอีกครั้ง และดูว่าเราเห็น Seams อะไรบ้าง:

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;
}

มี Seam อะไรบ้างที่การเรียก PostReceiveError ? มาลองลิสต์กัน

1. PostReceiveError เป็น Global Function เราจึงสามารถใช้ Link Seam ตรงนั้นได้ง่ายๆ เราสามารถสร้าง Library ที่มี Stub Function และ Link ไปยังมันเพื่อกำจัดพฤติกรรมนั้น Enabling Point ก็คือ Makefile หรือการตั้งค่าบางอย่างใน IDE ของเรา เราจะต้องปรับ Build ให้ Link ไปยัง Testing Library ตอน Testing และ Production Library ตอนที่ต้องการสร้างระบบจริง

2. เราสามารถเพิ่ม #include Statement ในโค้ดและใช้ Preprocessor เพื่อกำหนด Macro ชื่อ PostReceiveError ตอน Testing ดังนั้นเรามี Preprocessing Seam ตรงนั้น แล้ว Enabling Point อยู่ตรงไหน? เราสามารถใช้ Preprocessor Define เพื่อเปิดหรือปิด Macro Definition ได้

3. เรายังสามารถประกาศ Virtual Function สำหรับ PostRecieveError แบบที่เราทำตอนต้นบทนี้ด้วย ดังนั้นเรามี Object Seam ตรงนั้นด้วย แล้ว Enabling Point อยู่ตรงไหน? ในกรณีนี้ Enabling Point คือจุดที่เราตัดสินใจสร้าง Object เราสามารถสร้าง CAsyncSslRec หรือ Object ของ Testing Subclass ที่ Override PostRecieveError ก็ได้

มันน่าทึ่งจริงๆ ที่มีหลายวิธีในการแทนที่พฤติกรรมที่การเรียกนี้โดยไม่ต้องแก้ไข Method:

สิ่งสำคัญคือการเลือกประเภทของ seam ที่เหมาะสมเมื่อคุณต้องการนำโค้ดมาอยู่ภายใต้การทดสอบ โดยทั่วไปแล้ว object seams เป็นตัวเลือกที่ดีที่สุดในภาษาเชิงวัตถุ Preprocessing seams และ link seams อาจมีประโยชน์ในบางครั้ง แต่มันไม่ชัดเจนเท่า object seams นอกจากนี้ การทดสอบที่พึ่งพามันอาจดูแลรักษาได้ยาก ผมมักจะเก็บ preprocessing seams และ link seams ไว้ใช้ในกรณีที่ dependencies กระจายอยู่ทั่วไปและไม่มีทางเลือกอื่นที่ดีกว่า

เมื่อคุณคุ้นเคยกับการมองโค้ดในแง่ของ seams แล้ว คุณจะมองเห็นวิธีการทดสอบสิ่งต่างๆ และวิธีการจัดโครงสร้างโค้ดใหม่เพื่อให้การทดสอบง่ายขึ้นได้ชัดเจนยิ่งขึ้น