[PHP] ฝึกเขียน Test กันเถอะ LV Beginner

phpunit test traning

เนื่องจากผมได้ไปเรียนการทำ TDD ( Test Driven Development ) กับพี่รูฟ Agile66 ก็ได้เรียนรู้การฝึกเขียน Test มาบ้างนิดหน่อยแต่ไม่นานก็ลืมผมก็พยายามจะผลักดันตัวเองให้ฝึกเขียนโดยบทความนี้จะสอนการคิด การทำต่างๆ แล้วเรามาช่วยกันสังเกตุวิธีคิดและวิธีการลงมือทำกันเพื่อให้ได้ ไอเดียในการทำ Test แต่ยังไม่สามารถใช้กับงานจริงๆได้นะครับผมเชื่อว่าบทความนี้จะสอนเกี่ยวกับการทำเบื้องต้นครับ

ทำไมเราต้องทำ TDD ?

เพราะเราไม่มีหลักฐานเมื่อเวลาเราแก้ไข code ทุกครั้งแล้วเรามีหลักประกันอะไรที่จะไม่เกิด bug ? เราไม่สามารถพูดด้วยปากได้ เพราะเอาจริงๆเราทุกคนไม่สามารถจำ logic ทั้งหมดได้ คือ งาน 2 สัปดาห์ก่อนแก้ไขอะไรไปถามจริงๆเถอะคุณจำได้หรอ ? แค่วันนี้แก้ตรงไหนไปบ้างยังจำไม่ได้เลย ถูกต้องใช่ไหมครับ ? นั่นแหละทำให้เกิดกระบวนการนี้เพื่อให้เรามีคำตอบ ตอบคนอื่นว่า มันจะไม่พัง มันจะทำงานเหมือนเดิมเพื่อเติมคือ Feature ใหม่ๆ อะไรทำนองนี้ครับ

แล้วทำยังไง ?

เราต้อง set up สิ่งที่จำเป็นสำหรับการฝึกเขียน Test ของเรานะครับในบทความนี้จะยังไม่ไปเกี่ยวข้องกับพวก Framework ต่างๆนะครับ เพื่อไม่ให้งงและตัวผมเองก็ยังทำไม่ได้ด้วย ฮ่าๆ เอาล่ะเราไปดูกันดีกว่า ว่ามีอะไรบ้างที่เราต้องลงในเครื่อง

  1. install composer ( ใครไม่รู้จักผมว่าอ่านที่นี่ดีสุดแหละ เท่าที่ผมอ่านหลายๆที่นะ //www.thaicreate.com/community/composer-psr-future-php.html 
  2. สร้างไฟล์ composer.son และใส่ code ตามนี้
    {
        "require-dev": {
          	"phpunit/phpunit": "5.0.*"
        }
    }
  3. download phpunit.phar จากเว็บ //phpunit.de/getting-started.html

ถ้าใครทำแล้วติดตรงไหนถามใน comment ได้เลยจะมาตอบให้นะครับ

คอมพร้อม ใจพร้อม เราทำได้ !

โจทย์สำหรับการฝึกเขียนครั้งนี้มีดังนี้ครับ

เราเป็นทีมหนึ่งที่อยู่ในบริษัทใหญ่ ซึ่งมีการพัมนาและดูแล application มายังยาวนาน ทีมเราได้ project หนึ่งมาคือให้ทำ “Word wrap” ลูกค้าของเราไม่ต้องการเห็น scroll bar แบบจากซ้ายไปขวาจึงให้เราทำโปรแกรมมาช่วยจัดการเรื่องการตัดขึ้นบรรทัดใหม่ให้หน่อย

จากโจทย์นี้ เราต้องสร้าง class ที่สามารถจัดการกับรูปแบบของข้อความที่กรอกเข้ามาผ่าน input โดยผลลัพธ์คือ การจำกัดจำนวนตัวอักษร คล้ายๆกับการจำกัดจำนวนบรรทัดเหมือนพวก text editor ต่างๆ   ลูกค้าของเราไม่เข้าใจเรื่องเกี่ยวกับจำกัดจำนวนตัวอักษรต่อบรรทัดและเขารู้แค่ว่าจะเอาไม่มี scroll bar เหมือนกับ application ตัวอื่นๆ

วางแผน

มีสิ่งหนึ่งที่ programmer หลายๆคนนั้นไม่ได้ทำคือ การคิดและวางแผน แม้ว่าตัว TDD จะช่วยเหลือเราเรื่องการ Design การทำให้ code น้อยลง และการประกันว่า function ทำงานได้ดี แต่มันไม่ได้ช่วยเรื่องการคิด Logic ของเรา

ทุกๆครั้งที่ต้องการจะแก้ไขปัญหา คุณควรจะให้เวลาเกี่ยวกับการคิดกับมันเพื่อสร้าง Design แบบง่ายๆ ไม่ต้องอลังการเอาแบบพอใช้งานได้ก่อน ส่วนการคิดนี้แหละจะช่วยเหลือเราในเรื่อง การคิดว่าแต่ละ step ของ application เราจะทำงานแบบไหนอย่างไร ที่มันเป็นไปได้บ้าง

เริ่มคิดจาก เงื่อนไขง่ายๆของ word wrap กัน สมมติว่าเรามี text ที่ไม่ได้ un-wrap มาให้เรา และเราก็รู้ว่ามีกี่ตัวอักษรต่อบรรทัด สิ่งที่เราคิดง่ายๆคือ ถ้าจำนวนอักษรเกินกว่าที่เรากำหนดต่อ 1 บรรทัด เราจะให้ขึ้นบรรทัดใหม่ โดยแทนที่ space ในตัวอักษรสุดท้ายของบรรทัด

โอเคนี่เป็นข้อสรุปที่ระบบเราจะทำงาน แต่มันยังซับซ้อนมากสำหรับการ test ยกตัวอย่าง หากมีคำที่ยาวกว่าจำนวนตัวอักษรที่เรากำหนดล่ะ เช่นเรากำหนดไว้ 10 ตัว แต่ประโยคที่กรอกมา ตรงตัวอักษรที่ 10 ดันเป็นคำยาวกว่าเช่น

this’s a book.

อย่างนี้ตอนนับ 10 ไปตกแถวๆคำว่า book ซึ่งเราไม่สามารถแทนที่ค่า space ได้เลย เราควรจะบังคับให้ขึ้นบรรทัดใหม่ไอเดียตอนนี้ก็เพียงพอแหละ สำหรับการเขียน program ของเรา โดยเริ่ม project ด้วย class ชื่อว่า Wrapper

เริ่ม Project และเขียน Test อันแรกกัน

ใครยังไม่เคย install phpunit test ก็ไป install ก่อนนะที่เว็บ //phpunit.de/getting-started.html ส่วนใครมีแล้วก็ให้สร้าง folder Tests มาแล้วสร้างไฟล์ WrapperTest.php ขึ้นมา เขียน code ตามนี้

require_once dirname(__FILE__) . '/../Wrapper.php';
 
class WrapperTest extends PHPUnit_Framework_TestCase {
 
    function testCanCreateAWrapper() {
        $wrapper = new Wrapper();
    }
 
}

บรรทัดแรกก็เรียกหาไฟล์ Wrapper.php ( ซึ่งตอนนี้เราไม่มี ก็ถูกแล้ว ) เสร็จก็เป็น code สร้าง class WrapperTest ซึ่งมีการสืบทอดมาจาก PHPUnit_Framework_TestCase อันนี้คือตายตัวจำไว้เลยก็ได้นะ เสร็จก็สร้าง method เพื่อสำหรับการ Test ขึ้นต้องใส่คำว่า test นำหน้าด้วย อย่าง method นี้เราจะ test ว่ามีการสร้าง Class wrapper ขึ้นมาหรือยัง

จำไว้ว่า ! เรายังไม่อนุญาตให้คุณเขียนอะไรใน production code ( หมายถึงไฟล์สำหรับทดสอบ เพราะ step คือสร้าง Test ก่อน )  แม้แต่ประกาศสร้าง class หรือไฟล์ก็ห้าม เพื่ออะไร เพื่อให้เรารู้ถึงว่าแต่ละขั้นตอนจริงๆมันเป็นอย่างไร และเป็นการทบทวนสิ่งที่เรากำลังจะสร้างว่า ถูกต้องไหม

ซึ่ง canCreateAWrapper ที่ถูกเรียกเนี้ย บางทีอาจจะดูเหมือนไร้ประโยชน์แต่จริงๆแล้ว เราสามารถมองได้ว่า class ที่เรากำลังจะสร้างเนี้ย ต้องการเป็น class หรือเปล่า ? เราควรจะเรียกว่าอะไร ? ควรจะเป็น static ไหม ?

เมื่อเรารัน test พิมพ์คำสั่งว่า

phpunit Tests/WrapperTest.php —color=auto

อธิบายคือสั่งคำสั่ง phpunit [ไฟล์ Test] [option] ตามนี้

พิมพ์ใน terminal หรือถ้าเป็น window ก็พิมพ์ใน cmd ได้เลยครับ โดยเราต้องเข้าไปใน folder ที่เราทำการ set up พวกไฟล์สำหรับ Test แล้วนะครับ

ที่เราเขียนผลลัพธ์จะได้ประมาณนี้

PHP Fatal error:  require_once(): Failed opening required ‘/path/to/WordWrapPHP/Tests/../Wrapper.php’ (include_path=’.:/usr/share/php5:/usr/share/php’) in /path/to/WordWrapPHP/Tests/WrapperTest.php on line 3

ใช่แล้ว !! เนี้ยแหละ เราควรจะทำอะไรเพื่อแก้ไขสิ่งนี้ที่เกิดขึ้นถูกไหม ให้สร้าง Class เปล่าๆ ขึ้น ชื่อว่า Wrapper.php ใน main folder เขียนโค้ดดังนี้

class Wrapper {}

แค่นี้ เมื่อเรารันเทสอีกครั้ง มันผ่าน !! ยินดีด้วยคุณได้ผ่าน step แรกสำหรับการทำ test แล้วเย้ !

ถึงเวลาทำ Test อันแรกแบบจริงๆ

ถึงเวลาที่เราต้อง “คิด” เกี่ยวกับ First test ของเราจริงๆแหละ ที่ผ่านมาคือหลอกๆ ( ไม่ใช่แหละที่ผ่านมาคือการ set up และเรียนรู้วิธีใช้ ) สิ่งที่เราต้องคิดถึงการทำ test คือ

อะไรที่ดูง่ายโครตๆ ดูโง่ๆ ดูพื้นๆที่สุดที่จะทำให้ โค้ดของ production fail ได้ สิ่งที่นึกออกง่ายๆคือ “ให้เอาคำสั้นๆมาทดสอบว่า และคาดหวังว่าผลลัพธ์คือไม่มีการเปลี่ยนแปลง” ฟังดูทำได้เลยดิเอาล่ะ มาลองทำ Test กัน

require_once dirname(__FILE__) . '/../Wrapper.php';

/**
* 
*/
class WrapperText extends PHPUnit_Framework_TestCase 
{
	public function testDoesNotWrapAShorterThanMaxCharsWord() 
	{
		$wrapper = New Wrapper();
		assertEquals('word', $wrapper->wrap('word', 5));
	}
}

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

จากโค้ดข้างบนดูเหมือนซับซ้อนใช่ไหมครับ เรามาดูกันดีกว่า อะไรคือ ‘MaxChars’ ในชื่อ method ? แล้วเลข 5 ใน wrap method มันอ้างอิงถึงอะไร ผมเชื่อว่าหลายคนที่อ่านเนี้ยคงเดาได้อยู่แล้วว่าคืออะไร แต่อยากให้ลองดูไปเรื่อยๆก่อนครับอย่าใจร้อน … เอ๊ะ ! ก็บอกว่าอย่าใจร้อนไง ใจเย็นๆ

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

require_once dirname(__FILE__) . '/../Wrapper.php';

/**
* 
*/
class WrapperText extends PHPUnit_Framework_TestCase 
{
	function testItShouldWrapAnEmptyString() {
        $wrapper = new Wrapper();
        $this->assertEquals('', $wrapper->wrap(''));
    }
}

อย่างนี้ดูง่ายกว่าตะกี้ว่ามะ แล้วสังเกตุดูชื่อ method นะเราต้องพยายามคิดชื่อให้มันสื่อว่า มันกำลัง Test อะไรถ้านึกไม่ออกก็ถามเพื่อนในทีมดูว่าจะใช้คำอะไรดี แล้วลล่ะลองรัน Test กันดู ผลลัพธ์จะได้

Fatal error: Call to undefined method Wrapper::wrap() in …

ถ้าเราเห็นประโยคข้างบนแสดงก็แปลว่าทำถูกแหละครับ เพราะตอนนี้ class Wrapper ของเรายังไม่มี method ชื่อว่า wrap อยู่เลย ถ้าหากสังเกตุผมจะไม่ได้ใส่พวก method แรกๆที่เราทำกันไปในตัว Test เพราะมันไม่สำคัญ บางอย่างมันเป็น common ก็เอาออกได้ เราคงไม่อยากจะรันทีเดียวเป็นพันๆเคสหรอกนะ อย่างน้อยควรจะใส่ใจกับสิ่งที่ควรจะ test เพื่อลดเวลา ไม่งั้นกด test ทีรอ 30 นาทีก็ไม่ไหวนะ ต้องไม่นานซัก 1- 3 นาทีพอไหว สำหรับเยอะๆจริงๆ อย่ากลัวที่จะลบ Testcase ถ้ามันจำเป็นก็ทำ

ตอนนี้กลับไปที่ production code และทำให้มัน pass ซะ

class Wrapper {
 
    function wrap($text) {
        return;
    }
 
}

ถึงเวลาส่งค่า

จำได้ไหมว่า test ก่อนหน้านั้นเป็นไง ถึงเวลาเราต้องใช้มันแหละครับเอากลับมา

function testItDoesNotWrapAShortEnoughWord() {
    $wrapper = new Wrapper();
    $this->assertEquals('word', $wrapper->wrap('word', 5));
}

ใส่มันลงไปอีก method สำหรับ Test ซึ่งผลลัพธ์เราจะได้

Failed asserting that null matches expected ‘word’.

และสำหรับโค้ดที่จะทำให้มันผ่านคือ

function wrap($text) {
    return $text;
}

เป็นไง ? ง่ายใช่ไหมครับ 

ในขณะที่การรัน Test ของเราผ่านเป็นสีเขียวหมดแล้ว ต่อไปเราก็ต้องทำการ refactor code ของเรากันหน่อย ให้จำไว้ว่าเราจะทำการ refactor ต่อเมื่อเป็นสีเขียวแล้วเท่านั้น

ขั้นแรกให้เราทำการ remove การเรียก initialization ซ้ำๆในโค้ดของ test ก่อน เราสามารถใช้มันได้เพียงการเรียกครั้งเดียวให้สร้าง setUp() ขึ้นมาและใช้มันกับ Testcase ทั้งสองอันครับตามนี้

class WrapperTest extends PHPUnit_Framework_TestCase {
 
    private $wrapper;
 
    function setUp() {
        $this->wrapper = new Wrapper();
    }
 
    function testItShouldWrapAnEmptyString() {
        $this->assertEquals('', $this->wrapper->wrap(''));
    }
 
    function testItDoesNotWrapAShortEnoughWord() {
        $this->assertEquals('word', $this->wrapper->wrap('word', 5));
    }
 
}

สังเกตุนะครับตอนแรกเราใช้

$wrapper = new Wrapper();

กับทุกอันเลย

ต่อไปมีบางสิ่งบางอย่างที่ดูกำกวมใน Test ที่สอง ‘word’ คืออะไร ? ‘5’ คืออะไร ? เรามาทำให้มันเข้าใจง่ายกว่านี้กัน ต้องทำให้แบบว่าคนที่มาอ่านนั้นไม่ต้องเดาว่า 5 คืออะไร ‘word’ คืออะไร

อย่าลืมว่า Test ของเราเป็นเสมือน document ของ code ของเรา Programmer คนอื่นมาอ่าน Test ของเราต้องเหมือนเขากำลังอ่าน Document เพราะฉะนั้นเราจะเปลี่ยนชื่อ method ใหม่

function testItDoesNotWrapAShortEnoughWord() {
    $textToBeParsed = 'word';
    $maxLineLength = 5;
    $this->assertEquals($textToBeParsed, $this->wrapper->wrap($textToBeParsed, $maxLineLength));
}

คราวนี้เราลองอ่านและทำความเข้าใจอีกคร้งว่าเป็นไงบ้าง ดีขึ้นไหม ? ใครอ่านก็เข้าใจว่าแต่ละตัวคืออะไรค่าแต่ละค่าอะไรอย่ากลัวถ้าจะทำให้ตัวแปรชื่อยาวมันดีกว่าให้เพื่อนของเรามานั่งเดาว่าค่านี้คืออะไร

ต่อไปเป็น Testcase สำหรับคำที่มันยาวๆบ้าง

function testItWrapsAWordLongerThanLineLength() {
    $textToBeParsed = 'alongword';
    $maxLineLength = 5;
    $this->assertEquals("along\nword", $this->wrapper->wrap($textToBeParsed, $maxLineLength));
}

เราลองรัน phpunit ดูครับว่าได้ผลอย่างไร

There was 1 failure:

1) WrapperText::testItWrapsAWordLongerThanLineLength
Failed asserting that two strings are equal.
--- Expected
+++ Actual
@@ @@
-'along
-word'
+'alongword'

/Applications/MAMP/htdocs/tutplus_tdd/Tests/WrapperTest.php:28

เครื่องหมายแสดงว่าเป็นผลที่เราคิดว่าจะให้ออกมาเป็นแบบนี้หรือผลลัพธ์ที่เราคาดหวัง

และหลังเครื่องหมาย + เป็นผลจริงๆที่เราทำจะเห็นว่าตอนนี้มันไม่ wrap คำผลออกมายังคงเป็นคำเดิมอยู่

เพราะฉะนั้นเราจะแก้ไข Production code ให้มันทำงานผ่าน Test นี้กันตามนี้

function wrap($text, $lineLength) {
    if (strlen($text) > $lineLength)
        return substr ($text, 0, $lineLength) . "\n" . substr ($text, $lineLength);
    return $text;
}

เมื่อเราแก้ไขแล้วลอง run phpunit ดูจะเห็นว่า

here was 1 error:

1) WrapperText::testItShouldWrapAnEmptyString
Missing argument 2 for Wrapper::wrap(), called in /Applications/MAMP/htdocs/tutplus_tdd/Tests/WrapperTest.php on line 18 and defined

/Applications/MAMP/htdocs/tutplus_tdd/Wrapper.php:5
/Applications/MAMP/htdocs/tutplus_tdd/Tests/WrapperTest.php:18

FAILURES!
Tests: 3, Assertions: 2, Errors: 1.

Test ล่าสุดผ่าน แต่ ! Test อันแรกไม่ผ่าน เพราะว่า function ที่เราทำการแก้ไขต้องรับค่า paratemer สองค่า แต่ Testcase อันแรกเราไม่ได้ส่งค่าที่สองไปด้วย

เหตุการณ์นี้คุ้นๆไหมครับ เราทำงานมาตอนแรกไม่มี error พอเจอ bug เราก็แก้ไข program ให้ผ่านตรงนี้ เสร็จแล้วเป็นไง ก่อนหน้านั้น Bug โผล่มาเพราะการแก้ไขของเราล่าสุดยังไงล่ะเริ่มเห็นประโยชน์แล้วหรือยังล่ะ

ณ ตอนนี้เรามี 2 ทางในการแก้ไขปัญหานี้

  1. แก้ไข code ให้มี parameter แบบตัวเลือก
  2. แก้ไข Testcase แรกของเรา ให้มันเรียกพร้อมกับค่า parameter

ถ้าหากเราเลือกข้อแรก ทำให้ parameter แบบตัวเลือกคือใส่ก็ได้ไม่ใส่ก็ได้ มันจะมีปัญหากับ code ของเรา ณ ตอนนี้และตัว option ที่เรานั้นจะถูกสร้างค่า Default เสมอ แล้วค่า Default มันควรจะเป็นอะไร ? 0 ดูเหมือนจะสมเหตุสมผลแต่มันจะแค่ทำให้ผ่าน case by case ไปเหมือนทำแค่ให้ผ่านการ Test นี้เฉยๆ แล้วถ้าหากมีการส่งค่าตัวเลขแบบเยอะๆล่ะ ?

ใน if statement นั้นจะไม่มีทางเป็น true อย่างแน่นอน เช่น ถ้าเป็น 10 , 10,000 หรือ 1,000,000 ล่ะ ผมตัดสินใจเปลี่ยนค่าใน Testcase แทนเป็นดังนี้สำหรับ Test แรก

function testItShouldWrapAnEmptyString() {
    $this->assertEquals('', $this->wrapper->wrap('', 0));
}

เมื่อ run phpunit เราจะผ่านครับต่อไปเราจะทำการให้ Test ว่าถ้าหากมีประโยคยาวกว่านี้มันจะตัดหลายๆบรรทัดไหมตามนี้เลยครับ

function testItWrapsAWordSeveralTimesIfItsTooLong() {
    $textToBeParsed = 'averyverylongword';
    $maxLineLength = 5;
    $this->assertEquals("avery\nveryl\nongwo\nrd", $this->wrapper->wrap($textToBeParsed, $maxLineLength));
}

เมื่อรัน phpunit แล้วจะได้เหมือนกับด้านล่าง

Failed asserting that two strings are equal.
--- Expected
+++ Actual
@@ @@
 'avery
-veryl
-ongwo
-rd'
+verylongword'

จากตัวอย่างคุณคงคิดถึง while loop ล่ะสิ แต่อยากให้คุณนึกดีๆว่า while loop นั้นมันเป็น code ที่ง่ายที่สุดหรือยัง ? ที่จะทำให้เราผ่าน Test case นี้ คำตอบคือ ไม่ใช่ Recursive เป็นคำตอบที่ง่ายกว่าการ loop และมัน Test ง่ายกว่า แก้ไข code production ตามนี้

function wrap($text, $lineLength) {
    if (strlen($text) > $lineLength)
        return substr ($text, 0, $lineLength) . "\n" . $this->wrap(substr($text, $lineLength), $lineLength);
    return $text;
}

คุณสังเกตุไหมว่าอะไรเปลี่ยนแปลง ? มันดูง่ายขึ้นมากๆ เราทำการเอา string มาต่อกัน

แค่คำสองคำ

Test โครตง่ายอันต่อไป คือหากเจอคำสองคำล่ะ ? เมื่อมี space อยู่หลังสุด Test เราจะเป็นแบบนี้ครับ

function testItWrapsTwoWordsWhenSpaceAtTheEndOfLine() {
    $textToBeParsed = 'word word';
    $maxLineLength = 5;
    $this->assertEquals("word\nword", $this->wrapper->wrap($textToBeParsed, $maxLineLength));
}

คุณอาจจะคิดถึง function str_replace เพื่อเอา space ออกไปอย่าทำอย่างนั้นเด็ดขาด หลายๆคนจะเลือกใช้ if แบบนี้

function wrap($text, $lineLength) {
    if (strpos($text,' ') == $lineLength)
        return substr ($text, 0, strpos($text, ' ')) . "\n" . $this->wrap(substr($text, strpos($text, ' ') + 1), $lineLength);
    if (strlen($text) > $lineLength)
        return substr ($text, 0, $lineLength) . "\n" . $this->wrap(substr($text, $lineLength), $lineLength);
    return $text;
}

เมื่อเรารัน Test จะพบกัน loop ที่ไม่มีสิ้นสุดครับ

PHP Fatal error:  Allowed memory size of 134217728 bytes exhausted

ถึงเวลาที่เราต้องให้เวลาในการคิดอีกครั้ง ปัญหามาจาก Testcase แรกที่เราทำการใส่ค่าเป็น 0 เมื่อค่าเป็น 0 เอามา compare กับค่า strpos() ที่ return false เมื่อเอา 0 == false จะเกิดอะไรขึ้น ? ใช่ครับ ใน if statement เป็นจริงเมื่อเป็นจริงแล้วเกิดอะไรขึ้น ?

return substr ($text, 0, strpos($text, ‘ ‘)) . “\n” . $this->wrap(substr($text, strpos($text, ‘ ‘) + 1), $lineLength);

มันจะรันบรรทัดนี้แล้วก็เรียกตัวเองซ้ำๆไปเรื่อยๆ นี่แหละครับที่มาของ Loop ที่ไม่สิ้นสุด

การแก้ปัญหาครั้งนี้ เราต้องปรับ code ของเราสำหรับ if condition แรก โดยการที่เราจะค้นหา ค่าวาง ‘ ‘ โดยเราจะใช้ function substr หาตัวสุดท้ายว่ามันเป็นค่า เว้นวรรค ( spacebar ) หรือเปล่า

function wrap($text, $lineLength) {
    if (substr($text, $lineLength - 1, 1) == ' ')
        return substr ($text, 0, strpos($text, ' ')) . "\n" . $this->wrap(substr($text, strpos($text, ' ') + 1), $lineLength);
    if (strlen($text) > $lineLength)
        return substr ($text, 0, $lineLength) . "\n" . $this->wrap(substr($text, $lineLength), $lineLength);
    return $text;
}

เมื่อเราลองรัน phpunit แล้วผลจะเป็นสีเขียวคือผ่านหมด คราวนี้จะมีคำถามต่อมาว่า อ้าวแล้วถ้าตัวสุดท้ายมันไม่ใช่ spacebar ล่ะ ? เรามาลองเขียน Test สำหรับกรณีนั้นให้เกิดขึ้นดีกว่าครับ

function testItWrapsTwoWordsWhenLineEndIsAfterFirstWord() {
    $textToBeParsed = 'word word';
    $maxLineLength = 7;
    $this->assertEquals("word\nword", $this->wrapper->wrap($textToBeParsed, $maxLineLength));
}

ใน Testcase นี้เราจะบอกว่าให้ตัดตัวอักษรที่ 7 ลองรัน phpunit ดูครับว่าได้ผลยังไงผลคือ Fail เสร็จแล้วเราจึงแก้ไข production code เป็น

function wrap($text, $lineLength) {
    if (strlen($text) > $lineLength) {
        if (strpos(substr($text, 0, $lineLength), ' ') != 0)
            return substr ($text, 0, strpos($text, ' ')) . "\n" . $this->wrap(substr($text, strpos($text, ' ') + 1), $lineLength);
        return substr ($text, 0, $lineLength) . "\n" . $this->wrap(substr($text, $lineLength), $lineLength);
    }
    return $text;
}

มันใช้งานได้ ! เราย้ายเงื่อนไขแรกไปอยู่ในเงื่อนไขที่สองเพื่อหลีกเลี่ยงการเกิดลูปไม่สิ้นสุด และเราก็ค้นหาค่า spacebar ตอนนี้ถึงเวลาที่เราต้อง refector code กันหน่อยแหละเพราะ code ของเราเกิดการซ้อนกันดูน่าเกลียด เราจะ refactor เป็นแบบด้านล่าง

function wrap($text, $lineLength) {
    if (strlen($text) <= $lineLength)
        return $text;
    if (strpos(substr($text, 0, $lineLength), ' ') != 0)
        return substr ($text, 0, strpos($text, ' ')) . "\n" . $this->wrap(substr($text, strpos($text, ' ') + 1), $lineLength);
    return substr ($text, 0, $lineLength) . "\n" . $this->wrap(substr($text, $lineLength), $lineLength);
}

ดู code แล้วอาจจะงง อธิบายคือ ตัว Test ส่งค่าเข้ามาคือ ‘word word’ และค่าที่ตัดคือ 7 ความยาวตัวอักษรของ ‘word word’ คือเพราะฉะนั้นมันจะเข้า เงื่อนไขที่สองคือ

if (strpos(substr($text, 0, $lineLength), ' ') != 0)

เพราะอะไร ? เริ่มทำงานของเงื่อนไขคือ

substr(‘word word’, 0, 7)

อย่างนี้จะตัดเหลือ

word wo

เสร็จเอาไปเช็คกับ strpos ว่ามีค่า เว้นเวรรค ไหม ก็มีตำแหน่งที่ 5 ค่าที่เปรียบเทียบคือ

if( 5 != 0 ) ซึ่งเป็นจริง

บรรทัดนี้จึงทำงาน

return substr ($text, 0, strpos($text, ‘ ‘)) . “\n” . $this->wrap(substr($text, strpos($text, ‘ ‘) + 1), $lineLength);

จะได้ค่าว่า

substr( ‘word word’, 0, 5) . “\n” . $this->wrap( substr(‘word word’, 5 + 1), 7);

แล้วก็เป็น

‘word’ . “\n” . $this->wrap(‘word’ , 7);

ประมาณนี้

แล้วถ้าเป็นคำหลายๆคำล่ะ

Test ต่อไปของเราจะเป็นผลลัพธ์ว่ามีคำสามคำถูก wrap ด้วย 3 บรรทัด แต่เรารู้แน่นอนว่ามันผ่านแน่ๆ เราควรจะเขียน Test ที่เรารู้อยู่แล้วว่ามันผ่านไหม ? ส่วนใหญ่คือ ไม่ต้อง แต่ ถ้าคุณมีความสงสัยเมื่อไหร่ หรือไม่มั่นคง หรือคิดว่ามีความเป็นไปได้ที่จะทำให้ Test fail ก็สร้าง Testcase ซะ ! ไม่มีอะไรไม่ดีถ้ามันเป็นผลลัพธ์ของการเขียน Test อย่าลืมว่า Test ของคุณคือ เอกสารของคุณ

คราวนี้เราจะใช้ 3 คำแต่ตัด 2 บรรทัด Test ของเราหน้าตาจะเป็นแบบนี้

function testItWraps3WordsOn2Lines() {
    $textToBeParsed = 'word word word';
    $maxLineLength = 12;
    $this->assertEquals("word word\nword", $this->wrapper->wrap($textToBeParsed, $maxLineLength));
}

โดยเราจะ Fail เพราะว่าผลลัพธ์กลับไม่ใช่อย่างที่เราคิดคือมันตัดคำที่สองไปขึ้นบรรทัดใหม่

Failed asserting that two strings are equal.
--- Expected
+++ Actual
@@ @@
-'word word
-word'
+'word
+word word'

เราจึงต้องปรับปรุง function ของเราเป็นแบบนี้

function wrap($text, $lineLength) {
    if (strlen($text) <= $lineLength)
        return $text;
    if (strpos(substr($text, 0, $lineLength), ' ') != 0)
        return substr ($text, 0, strrpos($text, ' ')) . "\n" . $this->wrap(substr($text, strrpos($text, ' ') + 1), $lineLength);
    return substr ($text, 0, $lineLength) . "\n" . $this->wrap(substr($text, $lineLength), $lineLength);
}

ให้หา เว้นวรรค จากตำแหน่งทางขวามือเข้ามาแทนที่จะนับจาซ้ายมือ

มี Test แบบอื่นอีกไหมสำหรับโปรแกรมนี้ หรือ สิ่งที่อาจจะเป็นไปได้อีก

Edge case คือ เหตุการณ์ที่อาจจะเกิดขึ้นได้แบบต่ำสุดหรือสูงสุดของ operation นั้นๆ เช่น สมมติว่าอย่างในกรณี เราทำ function รับค่าตัวเลข ถ้าเขาใส่ 1,000,000 ได้ไหม ? อะไรทำนองเนี้ย

ในตัวอย่างที่เราทำนี้มันน่าจะมีบางกรณีที่อาจจะเกิดขึ้นได้อีก แต่เราก็ใกล้จะทำมันสำเร็จแล้ว เย้! ก็ให้เรานั่งคิดกรณีที่จะเกิดขึ้นเกี่ยวกับตัว function ของเรานะครับว่ามันอาจจะเกิดรูปแบบไหนได้อีก อ่างั้นเรามาลองแบบนี้กัน

function testItWraps2WordsOn3Lines() {
    $textToBeParsed = 'word word';
    $maxLineLength = 3;
    $this->assertEquals("wor\nd\nwor\nd", $this->wrapper->wrap($textToBeParsed, $maxLineLength));
}

ลองรัน Test ปรากฎว่าก็ผ่าน ก็เพราะมันตัดคำแบบที่เราอยากได้ต่อไปลองอันนี้

function testItWraps2WordsAtBoundry() {
    $textToBeParsed = 'word word';
    $maxLineLength = 4;
    $this->assertEquals("word\nword", $this->wrapper->wrap($textToBeParsed, $maxLineLength));
}

ผลก็น่าจะเป็นอย่างที่เราตั้งใจคือ ‘word’ แล้วก็ตัดเป็นอีกบรรทัด แล้วก็มีคำว่า word อย่างนี้

word
word

แต่ทำไมลองรันดูแล้ว Fail

Failed asserting that two strings are equal.
--- Expected
+++ Actual
@@ @@
 'word
-word'
+ wor
+d'

นั่นแหละครับคือการคิดถึง Edge case หรือกรณีที่อาจจะเกิดขึ้นได้เมื่อ Fail เราก็ต้องมาแก้ไข Code ของเรากันครับให้แก้ไขแบบนี้

function wrap($text, $lineLength) {
    $text = trim($text);
    if (strlen($text) <= $lineLength)
        return $text;
    if (strpos(substr($text, 0, $lineLength), ' ') != 0)
        return substr ($text, 0, strrpos($text, ' ')) . "\n" . $this->wrap(substr($text, strrpos($text, ' ') + 1), $lineLength);
    return substr ($text, 0, $lineLength) . "\n" . $this->wrap(substr($text, $lineLength), $lineLength);
}

อธิบายแบบเล็กน้อยคือ เมื่อเราตัด 4 ตัวอักษรแรกคือ word มาได้แล้วมันจะเหลืออะไร ? มันจะเหลือแบบนี้ครับ

‘ word’ ( มีเว้นวรรคข้างหน้าสุด )

สังเกตุว่ามันจะเจอตัว เว้นวรรค ก่อนที่จะเจอคำเมื่อเจอมันก็เลยนับเอาตัว เว้นวรรค เป็น 1 ตัวอักษรด้วยเลยกลายเป็นว่าผลที่ออกมาคือ

‘ wor’

อย่างนี้คือ  4 ตัวอักษรแล้วมันก็ตัดขึ้นบรรทัดใหม่เหลือแค่ d อย่างนี้แหละครับ

เราทำเสร็จแล้ว

ณ จุดนี้เราไม่สามารถคิดกรณีที่เกี่ยวกับ function ได้แล้วก็แปลว่าเราจบแล้ว ตอนนี้เราได้ใช้ TDD มาช่วยเหลือในการสร้างสิ่งที่ดูง่ายและใช้ประโยชน์เห็นไหมล่ะครับ 6 บรรทัดเท่านั้น

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

แต่ถ้าหากว่าคุณไม่สามารถคิด Case ที่จะเกิดขึ้นได้อีก มันแปลว่า อัลกอริทึมของคุณ Perfect ใช่ไหม ? ไม่ใช่ … TDD มันไม่ได้รับประกันว่า Bug น้อยลง ( แต่ในความคิดผมคิดว่ามันช่วยให้น้อยลงนะ ) แต่มันช่วยให้คุณเขียน Code ดีขึ้นซึ่งจะสามารถจะเข้าใจและแก้ไขได้ง่ายขึ้น

สุดท้ายก่อนจากกัน

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

ถ้าใครทำแล้วติดปัญหาอะไร ให้ comment ไว้ได้เลยครับ แล้วเรามาร่วมกันช่วยแก้ไขที่ติดกันครับ :)

One response to “[PHP] ฝึกเขียน Test กันเถอะ LV Beginner”

ฝากข้อคิดเห็น

This site uses Akismet to reduce spam. Learn how your comment data is processed.

%d bloggers like this: