ตามหลักการแล้ว เราไม่ควรต้องทำอะไรพิเศษกับคลาสเพื่อเริ่มทำงานกับมัน ในระบบที่สมบูรณ์แบบ เราควรจะสามารถสร้าง Object ของคลาสใดก็ได้ใน Test Harness แล้วเริ่มทำงานได้ทันที สร้าง Object, เขียน Test, แล้วก็ไปต่อกับอย่างอื่น ถ้ามันง่ายขนาดนั้น ก็คงไม่ต้องเขียนถึงเรื่องนี้กันแล้ว แต่โชคไม่ดีที่มันมักจะยากเสมอ Dependencies ระหว่างคลาสทำให้การนำกลุ่ม Object บางกลุ่มมาไว้ภายใต้ Test เป็นเรื่องยากลำบาก เราอาจจะอยากสร้าง Object ของคลาสหนึ่งเพื่อสอบถามข้อมูลจากมัน แต่กว่าจะสร้างมันได้ เราก็ต้องสร้าง Object ของอีกคลาสหนึ่งก่อน และคลาสนั้นก็ต้องการ Object ของคลาสอื่นต่อกันไปเรื่อยๆ สุดท้ายคุณก็จะพบว่าต้องนำแทบทั้งระบบเข้าไปไว้ใน Harness ในบางภาษา นี่อาจจะไม่ใช่ปัญหาใหญ่ แต่ในบางภาษา โดยเฉพาะ C++ แค่ Link Time เพียงอย่างเดียวก็ทำให้การ Turnaround อย่างรวดเร็วแทบจะเป็นไปไม่ได้เลย ถ้าคุณไม่ Break Dependencies

ในระบบที่ถูกพัฒนาขึ้นโดยไม่ได้เขียน Unit Tests ไปพร้อมกัน เรามักจะต้อง Break Dependencies เพื่อนำคลาสเข้าไปไว้ใน Test Harness แต่นั่นไม่ใช่เหตุผลเดียวที่ต้อง Break Dependencies บางครั้งคลาสที่เราอยาก Test ก็มีผลกระทบต่อคลาสอื่น และ Test ของเราจำเป็นต้องรู้ถึงผลกระทบเหล่านั้น บางครั้งเราสามารถ Sense ผลกระทบเหล่านั้นผ่าน Interface ของคลาสอื่นได้ แต่บางครั้งเราก็ไม่สามารถทำได้ ทางเลือกเดียวที่เรามีคือการแอบอ้างเป็นคลาสอื่น เพื่อให้เราสามารถ Sense ผลกระทบได้โดยตรง

โดยทั่วไปแล้ว เวลาที่เราอยากให้มี Tests ขึ้นมา มีเหตุผลสองอย่างที่ต้อง Break Dependencies นั่นคือ sensing และ separation .

1. Sensing —เรา Break Dependencies เพื่อ sense เมื่อเราไม่สามารถเข้าถึงค่าที่โค้ดของเราคำนวณได้

2. Separation —เรา Break Dependencies เพื่อ separate เมื่อเราไม่สามารถนำโค้ดแม้แต่ชิ้นเดียวเข้าไปไว้ใน Test Harness เพื่อให้มันทำงานได้

นี่คือตัวอย่าง เรามีคลาสชื่อ NetworkBridge ในแอปพลิเคชันจัดการเครือข่าย:

public class NetworkBridge
{
    public NetworkBridge(EndPoint [] endpoints) {
        ...
    }

    public void formRouting(String sourceID, String destID) {
        ...
    }
    ...
}

NetworkBridge รับ Array ของ EndPoints เข้ามา และจัดการ Configuration ของพวกมันโดยใช้ Hardware ในพื้นที่ ผู้ใช้ NetworkBridge สามารถใช้ Method ของมันเพื่อ Route Traffic จาก Endpoint หนึ่งไปยังอีก endpoint หนึ่ง NetworkBridge ทำงานนี้โดยการเปลี่ยน Settings บนคลาส EndPoint แต่ละ Instance ของคลาส EndPoint จะเปิด Socket และสื่อสารผ่านเครือข่ายไปยัง Device ใด Device หนึ่ง

นั่นเป็นแค่คำอธิบายสั้นๆ ว่า NetworkBridge ทำอะไร เราอาจจะลงรายละเอียดมากกว่านี้ได้ แต่จากมุมมองของการ Testing ก็เห็นปัญหาที่ชัดเจนอยู่แล้ว ถ้าเราอยากเขียน Tests สำหรับ NetworkBridge เราจะทำยังไง? คลาสนี้มีโอกาสสูงมากที่จะเรียกใช้ Hardware จริงๆ ตอนที่มันถูกสร้างขึ้น เราจำเป็นต้องมี Hardware เหล่านั้นพร้อมใช้เพื่อสร้าง Instance ของคลาสนี้หรือ? แย่กว่านั้นคือ เราจะรู้ได้ยังไงว่า Bridge กำลังทำอะไรกับ Hardware หรือ Endpoints เหล่านั้นอยู่บ้าง? จากมุมมองของเรา คลาสนี้คือกล่องทึบ

มันอาจจะไม่ได้แย่ขนาดนั้น บางทีเราอาจจะเขียนโค้ดเพื่อ Sniff Packets ผ่านเครือข่าย บางทีเราอาจจะหา Hardware ให้ NetworkBridge คุยด้วย เพื่อว่าอย่างน้อยมันก็ไม่ค้างตอนเราพยายามสร้าง Instance ของมัน บางทีเราอาจจะตั้งค่าระบบเพื่อให้มี Local Cluster ของ Endpoints และใช้พวกมันภายใต้ Test วิธีเหล่านั้นอาจใช้ได้ แต่มันเป็นงานที่หนักหนาสาหัสมาก Logic ที่เราอยากเปลี่ยนใน NetworkBridge อาจจะไม่ต้องการอะไรพวกนั้นเลย ปัญหาคือเราแค่เข้าไปจัดการกับมันไม่ได้ เราไม่สามารถ Run Object ของคลาสนั้นและลองใช้มันโดยตรงเพื่อดูว่ามันทำงานยังไง

ตัวอย่างนี้แสดงให้เห็นทั้งปัญหา Sensing และ Separation เราไม่สามารถ Sense ผลกระทบของการเรียก Method บนคลาสนี้ และเราไม่สามารถ Run มันแยกออกมาจากส่วนอื่นของแอปพลิเคชันได้

ปัญหาไหนหนักกว่ากัน? Sensing หรือ Separation? ไม่มีคำตอบที่ชัดเจน โดยทั่วไปแล้วเราต้องการทั้งสองอย่าง และทั้งสองอย่างล้วนเป็นเหตุผลที่เราต้อง Break Dependencies แต่มีสิ่งหนึ่งที่ชัดเจน: มีหลายวิธีในการแยกซอฟต์แวร์ออกจากกัน ที่จริงแล้วมี Catalog ของเทคนิคพวกนั้นอยู่ท้ายเล่มในหัวข้อนั้นเลย แต่สำหรับ Sensing นั้นมีเทคนิคหลักๆ อยู่เพียงเทคนิคเดียว

Faking Collaborators (การปลอม Collaborators)

หนึ่งในปัญหาใหญ่ที่เราเผชิญในการทำงานกับ Legacy Code คือ Dependency ถ้าเราอยากให้โค้ดบางส่วนทำงานโดยลำพังและดูว่ามันทำอะไร เรามักจะต้อง Break Dependencies กับโค้ดอื่นๆ แต่มันไม่เคยง่ายขนาดนั้น บ่อยครั้งที่โค้ดอื่นๆ นั้นกลับเป็นที่เดียวที่เราสามารถ Sense ผลกระทบของการกระทำของเราได้อย่างง่ายดาย ถ้าเราสามารถเอาคนอื่นมาแทนที่โค้ดนั้นและ Test ผ่านมันได้ เราก็จะสามารถเขียน Tests ได้ ในแนวคิดเชิงวัตถุ โค้ดอื่นๆ พวกนี้มักถูกเรียกว่า fake objects .

Fake Objects (วัตถุปลอม)

fake object คือ Object ที่แอบอ้างเป็น Collaborator บางตัวของคลาสของเราในขณะที่กำลังถูก Test นี่คือตัวอย่าง ในระบบ Point-of-Sale เรามีคลาสชื่อ Sale (ดู Figure 3.1 ) มันมี Method ชื่อ scan() ที่รับ Bar Code ของสินค้าที่ลูกค้าอยากซื้อ เมื่อไหร่ก็ตามที่ scan() ถูกเรียก Object Sale จะต้องแสดงชื่อของสินค้าที่สแกน พร้อมกับราคาบนจอของ Cash Register

Figure 3.1 คลาส Sale

image

เราจะ Test เรื่องนี้ยังไงเพื่อดูว่าข้อความที่ถูกต้องแสดงบนจอหรือไม่? ถ้าการเรียก API ของจอ Cash Register ฝังลึกอยู่ในคลาส Sale มันก็จะยากหน่อย การ Sense ผลกระทบบนจออาจจะไม่ใช่เรื่องง่าย แต่ถ้าเราสามารถหาตำแหน่งในโค้ดที่จอถูก Update ได้ เราก็สามารถเปลี่ยนไปใช้ Design ที่แสดงใน Figure 3.2 ได้

Figure 3.2 Sale สื่อสารกับคลาส Display

image

ในขั้นตอนนี้เราได้แนะนำคลาสใหม่ชื่อ ArtR56Display คลาสนั้นมีโค้ดทั้งหมดที่จำเป็นในการสื่อสารกับ Device แสดงผลเฉพาะที่เราใช้อยู่ สิ่งที่เราต้องทำก็แค่ส่ง Line ของข้อความที่เราอยากแสดงให้มัน เราสามารถย้ายโค้ดแสดงผลทั้งหมดใน Sale ไปไว้ที่ ArtR56Display และได้ระบบที่ทำงานเหมือนเดิมทุกประการ แล้วมันให้อะไรกับเราหรือ? เมื่อเราทำแบบนั้นแล้ว เราก็สามารถเปลี่ยนไปใช้ Design ที่แสดงใน Figure 3.3 ได้

Figure 3.3 Sale กับ Hierarchy ของ Display

image

คลาส Sale สามารถถือ ArtR56Display หรืออย่างอื่นอย่าง FakeDisplay ก็ได้ ข้อดีของการมี Fake Display คือเราสามารถเขียน Tests ผ่านมันเพื่อค้นหาว่า Sale ทำอะไรบ้าง

มันทำงานยังไง? Sale รับ Display เข้ามา และ Display ก็คือ Object ของคลาสใดก็ได้ที่ implement Interface Display

public interface Display
{
    void showLine(String line);
}

ทั้ง ArtR56Display และ FakeDisplay ต่างก็ implement Display .

Object Sale สามารถรับ Display ผ่าน Constructor และเก็บมันไว้ภายใน:

public class Sale
{
    private Display display;

    public Sale(Display display) {
        this.display = display;
    }

    public void scan(String barcode) {
        ...
        String itemLine = item.name()
                + " " + item.price().asDisplayText();
        display.showLine(itemLine);
        ...
    }
}

ใน Method scan โค้ดจะเรียก Method showLine บนตัวแปร display แต่สิ่งที่เกิดขึ้นจะขึ้นอยู่กับว่าเราส่ง Display แบบไหนให้ Object Sale ตอนที่เราสร้างมัน ถ้าเราให้ ArtR56Display มันจะพยายามแสดงผลบน Hardware จริงของ Cash Register ถ้าเราให้ FakeDisplay มันจะไม่แสดงผลบน Hardware จริง แต่เราจะสามารถเห็นสิ่งที่จะถูกแสดงขึ้นมาได้ นี่คือ Test ที่เราใช้เพื่อดูสิ่งนั้น:

import junit.framework.*;

public class SaleTest extends TestCase
{
    public void testDisplayAnItem() {
        FakeDisplay display = new FakeDisplay();
        Sale sale = new Sale(display);

        sale.scan("1");
        assertEquals("Milk $3.99", display.getLastLine());
    }
}

คลาส FakeDisplay ค่อนข้างแปลกนิดหน่อย มาดูกัน:

public class FakeDisplay implements Display
{
    private String lastLine = "";

    public void showLine(String line) {
        lastLine = line;
    }

    public String getLastLine() {
        return lastLine;
    }
}

Method showLine รับ Line ของข้อความเข้ามาและกำหนดให้กับตัวแปร lastLine ส่วน Method getLastLine จะคืนค่า Line ของข้อความนั้นเมื่อถูกเรียก นี่เป็นพฤติกรรมที่บางเบามาก แต่มันช่วยเราได้เยอะเลย ด้วย Test ที่เราเขียนไว้ เราสามารถค้นหาได้ว่าข้อความที่ถูกต้องจะถูกส่งไปที่ Display หรือไม่เมื่อคลาส Sale ถูกใช้งาน


Fake Objects สนับสนุนการทดสอบจริง

บางครั้งเมื่อคนเห็นการใช้ Fake Objects พวกเขาจะพูดว่า "นั่นไม่ใช่การทดสอบจริง" เพราะท้ายที่สุดแล้ว Test นี้ไม่ได้แสดงให้เราเห็นว่าจริงๆ แล้วอะไรถูกแสดงบนหน้าจอจริง สมมติว่าซอฟต์แวร์ส่วนหนึ่งของจอ Cash Register ทำงานไม่ถูกต้อง Test นี้ก็ไม่มีทางบอกเราได้ ก็จริง แต่ก็ไม่ได้หมายความว่านี่ไม่ใช่การทดสอบจริง ถึงแม้เราจะสามารถออกแบบ Test ที่บอกเราได้อย่างแม่นยำว่า Pixel ไหนถูก Set บนจอ Cash Register จริงๆ นั่นหมายความว่าซอฟต์แวร์จะทำงานกับ Hardware ทุกตัวหรือ? ไม่เลย — แต่ก็ไม่ได้หมายความว่านั่นไม่ใช่การทดสอบเช่นกัน เวลาเราเขียน Tests เราต้อง Divide and Conquer Test นี้บอกเราว่า Object Sale ส่งผลต่อ Display ยังไง แค่นั้นเอง แต่นั่นก็ไม่ใช่เรื่องเล็กน้อย ถ้าเราเจอ Bug การ Run Test นี้ก็อาจช่วยให้เราเห็นว่าปัญหาไม่ได้อยู่ที่ Sale ถ้าเราสามารถใช้ข้อมูลแบบนั้นเพื่อช่วยระบุตำแหน่งของ Error ได้ เราก็จะประหยัดเวลาได้มหาศาล

เมื่อเราเขียน Tests สำหรับแต่ละ Unit เราจะได้ชิ้นส่วนเล็กๆ ที่เข้าใจได้ง่าย ซึ่งทำให้เราสามารถใช้เหตุผลเกี่ยวกับโค้ดของเราได้สะดวกขึ้น


The Two Sides of a Fake Object (สองด้านของ Fake Object)

Fake Objects อาจทำให้สับสนได้เมื่อคุณเห็นมันครั้งแรก สิ่งที่แปลกประหลาดที่สุดอย่างหนึ่งเกี่ยวกับพวกมันคือมันมี "สองด้าน" ในทางใดทางหนึ่ง มาดูคลาส FakeDisplay อีกครั้ง ใน Figure 3.4 .

Figure 3.4 สองด้านของ Fake Object

image

Method showLine จำเป็นต้องมีบน FakeDisplay เพราะ FakeDisplay implement Display มันเป็น Method เดียวบน Display และเป็น Method เดียวที่ Sale จะมองเห็น ส่วนอีก Method หนึ่งคือ getLastLine นั้นมีไว้สำหรับใช้ใน Test นี่คือเหตุผลที่เราประกาศ display เป็น FakeDisplay ไม่ใช่ Display :

import junit.framework.*;

public class SaleTest extends TestCase
{
    public void testDisplayAnItem() {
         FakeDisplay display = new FakeDisplay();
        Sale sale = new Sale(display);

        sale.scan("1");
        assertEquals("Milk $3.99", display.getLastLine() );
    }
}

คลาส Sale จะมองเห็น Fake Display เป็น Display แต่ใน Test เราจำเป็นต้องถือ Object นั้นในรูปแบบ FakeDisplay ถ้าเราไม่ทำแบบนั้น เราจะไม่สามารถเรียก getLastLine() เพื่อค้นหาว่า Sale แสดงอะไรออกมาได้

Fakes Distilled (สรุปเกี่ยวกับ Fakes)

ตัวอย่างที่ผมแสดงในส่วนนี้เรียบง่ายมาก แต่มันแสดงแนวคิดหลักเบื้องหลัง Fakes พวกมันสามารถถูก Implement ได้ในหลายวิธี ในภาษา OO พวกมันมักจะถูก Implement เป็นคลาสง่ายๆ เช่นคลาส FakeDisplay ในตัวอย่างก่อนหน้านี้ ในภาษาที่ไม่ใช่ OO เราสามารถ Implement Fake โดยการนิยามฟังก์ชันอื่นขึ้นมา ซึ่งบันทึกค่าไว้ในโครงสร้างข้อมูล Global บางอย่างที่เราเข้าถึงได้ใน Tests ดูรายละเอียดเพิ่มเติมได้จาก Chapter 19 , My Project is Not Object-Oriented. How Do I Make Safe Changes? (โปรเจกต์ของฉันไม่ได้เป็นเชิงวัตถุ ฉันจะทำการเปลี่ยนแปลงอย่างปลอดภัยได้อย่างไร?) ,

Mock Objects (วัตถุ Mock)

Fakes เขียนง่ายและเป็นเครื่องมือที่มีค่ามากสำหรับการ Sensing ถ้าคุณต้องเขียนมันจำนวนมาก คุณอาจอยากพิจารณา Fake ประเภทที่ล้ำหน้ากว่าเรียกว่า mock object . Mock Objects คือ Fakes ที่ทำ Assertions ภายในตัวมันเอง นี่คือตัวอย่างของ Test ที่ใช้ Mock Object:

import junit.framework.*;

public class SaleTest extends TestCase
{
    public void testDisplayAnItem() {
        MockDisplay display = new MockDisplay();
        display.setExpectation("showLine", "Milk $3.99");
        Sale sale = new Sale(display);

        sale.scan("1");
        display.verify();
    }
}

ใน Test นี้เราสร้าง Mock Display Object ข้อดีของ Mocks คือเราสามารถบอกพวกมันได้ว่าคาดหวังให้มีการเรียก Method ไหนบ้าง จากนั้นเราก็บอกให้มันตรวจสอบว่ามันได้รับการเรียกเหล่านั้นหรือไม่ นั่นคือสิ่งที่เกิดขึ้นใน Test Case นี้ เราบอกให้ Display คาดหวังว่า Method showLine จะถูกเรียกด้วย Argument " Milk $3.99 " หลังจากตั้ง Expectation เสร็จ เราก็แค่ใช้ Object นั้นภายใน Test ในกรณีนี้เราเรียก Method scan() จากนั้นเราเรียก Method verify() ซึ่งจะตรวจสอบว่าความคาดหวังทั้งหมดได้รับการตอบสนองหรือไม่ ถ้าไม่ ก็จะทำให้ Test ล้มเหลว

Mocks เป็นเครื่องมือที่ทรงพลัง และมี Frameworks สำหรับ Mock Object มากมายให้เลือกใช้ อย่างไรก็ตาม Mock Object Frameworks อาจไม่มีในทุกภาษา และ Fake Objects แบบง่ายๆ ก็เพียงพอในสถานการณ์ส่วนใหญ่