[หนังสือแปล] Refactoring JavaScript: บทที่ 4 ภาค 2

Tanakorn Numrubporn
p4ftech
Published in
9 min readAug 8, 2017

หนังสือเล่มได้รับอนุญาติให้แปลได้เป็นที่เรียบร้อยแล้วจาก Evan Burchard หากใครอยากอ่านต้นฉบับสามารถไปอุดหนุนผู้เขียนได้ที่ Amazon

Untested Code และ Characterization Test

สมมติให้มีสถานการณ์ดังต่อไปนี้: เพื่อนร่วมงานของเราได้เขียน Code ในการแจกไพ่แบบสุ่ม เขาใช้เวลาแยกตัวออกไปเขียน Code คนเดียวเป็นเวลาสองเดือนเต็ม ปัญหาคือทีมงานก็ไม่แน่ใจว่านายคนนี้จะทำงานเสร็จตรงตามเวลามั๊ย และคุณก็ติดต่อเขาไม่ได้ แถมเขาคนนี้ก็ไม่ได้เขียน Test เลย

ตอนนี้คุณมีทางเลือก 3 ทาง

  1. เขียน Code ใหม่ตั้งแต่แรก แต่ผมไม่แนะนำวิธีนี้ เพราะมันจะเสี่ยงมาก ยิ่งโปรเจกท์มีขนาดใหญ่ก็ยิ่งเลวร้าย
  2. คุณสามารถเปลี่ยน Code ได้ตามความจำเป็น โดยไม่ต้องใช้ Test แต่ก็อีกนั่นแหละ ผมไม่แนะนำวิธีนี้
  3. เขียน Test เพิ่มเข้าไป ซึ่งเป็นวิธีที่ดีที่สุด

ต่อไปนี้คือ Code ของเพื่อนร่วมงานเรา (ให้คุณผู้อ่านนำไปเขียน แล้ว Save ในชื่อ random-hand.js):

var s = ['H', 'D', 'S', 'C'];var v = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K'];var c = [];var rS = function(){  return s[Math.floor(Math.random()*(s.length))];};var rV = function(){  return v[Math.floor(Math.random()*(v.length))];};var rC = function(){  return rV() + '-' + rS();};var doIt = function(){  c.push(rC());  c.push(rC());  c.push(rC());  c.push(rC());  c.push(rC());};doIt();console.log(c);

เป็น Code ที่ดูลึกลับดีนะครับ อ่านเข้าใจยาก แถมเป็น Code ที่ทำงานสำคัญซะด้วย สำคัญขนาดที่ว่า ระบบจะไม่สามารถทำงานได้หากไม่มี Code ตัวนี้ ถือเป็นสถานการณ์ที่ยากลำบากไม่น้อย วิธีที่ผมจะเลือกใช้ในตอนนี้คือการทำ Test Harness (การ Exercise Code ผ่าน Test) ซึ่งดูแล้วไม่น่าจะยากเท่าไหร่

เราจะใช้ assert ในการทำ Characterization Test ในครั้งนี้ โดยให้แทรก Test Code ต่อท้ายจาก Code ข้างต้น แล้วให้รันด้วยคำสั่ง

mocha random-hand.js

โดยให้เขียน Test Code ตามนี้

const assert = require('assert');
describe('doIt()', function() {
it('returns nothing', function() {
var result = doIt();
assert.equal(result, null);
});
});

เขียนแค่นี้ก็พอ โดยระหว่างเขียนเราคาดว่า ฟังก์ชั่น doIt() จะไม่ return อะไรกลับออกมาเลย ซึ่งหากเราคาดผิด เดี๋ยว Code มันจะประท้วงเราเองว่า “เฮ้ย กระผมมีหน้าที่ return ข้อมูลนะครับ อย่ามาดูถูกฟังก์ชั่นอย่างผมว่าไม่มีปัญญา return อะไรให้คุณ บอกเลยผมไม่ชอบ” วิธีนี้แหละที่เรียกว่า Characterization Test

มีบ้างเหมือนกันที่ Test แบบนี้แล้วผ่าน (อย่างในกรณีนี้เป็นต้น) โดยเราใช้ assert.equal ในการทดสอบ ซึ่งฟังก์ชั่นนี้จะทำงานแบบเดียวกับ result == null และสำหรับกรณีนี้ฟังก์ชั่น doIt() จะ return undefined และ null == undefined

หากคุณแก้ Test Code ดังนี้

assert(result === null);

คุณจะเจอกับ error ดังนี้

AssertionError: false == true

แต่ไม่ว่าจะเขียน Test แบบไหน ก็ดูเหมือนไม่ค่อยมีประโยชน์เท่าไหร่ หากอยากให้ Test มีประโยชน์กว่านี้ ก็ลองใส่ค่าอื่นนอกจาก null เพื่อทดสอบผลลัพธ์ของฟังก์ชั่น เช่น

assert.equal(result, 3);
AssertError: undefined == 3

ปัญหาคือ เราอยากจะทำให้ผลการ Test ที่แสดงออกมาทาง Terminal ดูเข้าใจง่าย และช่วยเราได้มากกว่าที่เป็นอยู่ ดังนั้น เราจะเพิ่ม library ตัวนี้ ที่ทำหน้าที่ Assertion แบบเดียวกับ assert ของ node เพียงแต่แสดงผลลัพธ์ดีกว่า ซึ่ง library ตัวนี้มีชื่อว่า chai

ให้ทำการติดตั้ง chai ดังนี้

node install chai

จากนั้นให้เปลี่ยน Test Code ให้เป็นดังนี้

const chai   = require('chai');
const assert = chai.assert;
describe('doIt()', function() {
it('returns something', function() {
assert.equal(doIt(), true);
});
});
describe('rC()', function() {
it('returns something', function() {
assert.equal(rC(), true);
});
});
describe('rV()', function() {
it('return something', function() {
assert.equal(rV(), true);
});
});
describe('rS()', function() {
it('return something', function() {
assert.equal(rS(), true);
});
});

ซึ่งเมื่อรัน Test แล้ว จะแสดง Error ดังนี้

4 failing1) doIt() returns something:   AssertionError: expected undefined to equal true    at Context.<anonymous> (random-hand.js:27:12)2) rC() returns something:   AssertionError: expected '4-C' to equal true    at Context.<anonymous> (random-hand.js:33:12)   3) rV() return something:   AssertionError: expected '3' to equal true    at Context.<anonymous> (random-hand.js:39:12)4) rS() return something:   AssertionError: expected 'S' to equal true    at Context.<anonymous> (random-hand.js:45:12)   

คราวนี้รู้ชัดเลยว่าฟังก์ชั่นที่เรา Test แต่ละตัว return อะไรออกมา แต่จะสังเกตุเห็นว่าฟังก์ชั่น doIt() จะ return undefined ออกมา ซึ่งมักจะแปลว่า มันเป็นฟังก์ชั่นที่สร้าง Side Effect ให้กับ Code จะมีบ้างเหมือนกันฟังก์ชั่นที่ไม่ return อะไรออกมา และไม่สร้าง Side Effect ใดๆ เลย เราจะเรียกมันว่าเป็น Dead Code อันเป็น Code ที่ต้องถูกกำจัดทิ้ง

Side Effects

Side Effect หมายถึงการเปลี่ยนค่าของ Variable หรือการเปลี่ยนค่าต่างๆ ภายในฐานข้อมูล บางคนมองว่าการเขียน Code แบบ Side Effect นั้น เป็นแนวทางที่ไม่คูล ไม่ชิคเอาเสียเลย สู้ Code แบบ Immutability ไม่ได้ ดูหล่อกว่าเยอะ

หนังสือเล่มนี้ มุ่งเป้าไปที่การลด Side Effect Code ให้เหลือน้อยที่สุด หรือกำจัดมันไปให้หมดเลยยิ่งดี ซึ่งก็ขึ้นอยู่กับ JavaScript Tool ต่างๆ ที่คุณเลือกใช้งานด้วย (ตามเนื้อหาในบทที่ 2) ภายในหนังสือเล่มนี้ เราจะพูดถึงเรื่องนี้ตลอด แต่จะเจาะลึกในบทที่ 11

สำหรับฟังก์ชั่นที่ return non-null value คุณสามารถทดลองใส่ input อะไรก็ได้ เพื่อ assert ว่ามันจะ return อะไรออกมา ซึ่งเป็น Characterization Test ขั้นที่สอง และหากมันไม่สร้าง Side Effect ใดๆ เราก็ไม่จำเป็นต้องเข้าไปดูที่ภายใน Code ดูแค่ Input และ Output ก็พอ

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

สาเหตุที่มันเป็นเช่นนั้น เพราะว่าฟังก์ชั่นที่เราโฟกัสนี้มันทำงานแบบสุ่มผลลัพธ์จึงคาดเดาไม่ได้ ดังนั้นการจะทดสอบฟังก์ชั่นดังกล่าว ต้องใช้กระบวนท่าพิศดารเล็กน้อย ซึ่ง regex (Regular Expression) น่าจะเป็นตัวเลือกที่ดีทีเดียว เพราะมันสามารถรองรับ output ได้หลากหลายรูปแบบ

ให้เราลบ Characterization Test เดิมออกไป แล้วเขียน Test ใหม่ ดังนี้

describe('rC()', function() {
it('returns match for card', function() {
assert(rC().match(/\w{1,2}-[HDSC]/));
});
});
describe('rV()', function() {
it('return match for card value', function() {
assert(rV().match(/\w{1,2}/));
});
});
describe('rS()', function() {
it('return match for suit', function() {
assert(rS().match(/[HDSC]/));
});
});

ฟังก์ชั่นสามตัวนี้ Test ไม่ค่อยยากเท่าไหร่ เพราะมันแค่ส่ง output ออกมาแบบสุ่มเท่านั้น ว่าแต่ฟังก์ชั่น doIt ล่ะ ทดสอบยังไงดี

doIt เป็นฟังก์ชั่นที่ return undefined ออกมา วิธีทดสอบก็มีได้หลายทาง ก่อนอื่นเราต้องมั่นใจก่อนว่า variable ที่ชื่อ c ไม่ได้ถูกเรียกใช้งานจากที่อื่น เพราะไม่งั้นสถานการณ์จะยิ่งซับซ้อนกว่านี้ แต่โชคดีตรงที่ c ถูกใช้งานภายในฟังก์ชั่น doIt เท่านั้น ดังนั้น เราจะการโยก variable declaration ของ c มาอยู่ภายในฟังก์ชั่น doIt แล้วจากนั้นก็ return c ออกมาดังนี้

var doIt = function(){
var c = [];
c.push(rC());
c.push(rC());
c.push(rC());
c.push(rC());
c.push(rC());
return c;
};
console.log(c);

ตอนนี้เราเพิ่งพัง Code ตรงส่วน console.log(c) ไป เพราะตอนนี้เราไม่สามารถ access ค่าของ c ได้จากภายนอกฟังก์ชั่น doIt อีกต่อไปแล้ว วิธีแก้ก็ง่ายมาก ดังนี้

console.log(doIt());

จากนั้นก็ให้เขียน Characterization Test ขึ้นมาเพื่อทดสอบฟังก์ชั่น doIt ดังนี้

describe('doIt()', function() {
it('does something', function() {
assert.equal(doIt(), true);
});
});

เมื่อรัน Test เราจะได้ Error ดังนี้

AssertionError: expected [ '9-C', '4-S', '3-H', '6-S', 'K-H' ] to equal true

มองดูดีๆ ฟังก์ชั่นนี้จะ return ผลลัพธ์จากการรันฟังก์ชั่น rC 5 ครั้ง ซึ่งเราจะใช้ Regex เพื่อตรวจสอบผลลัพธ์ทั้งห้าตัวก็ได้ แต่เราได้ใช้วิธีนั้นในการ Test ฟังก์ชั่น rC ไปแล้ว ดังนั้น อย่าไป Test ซ้ำซ้อนแบบนั้นเลย ที่เราต้องทำก็คือ ตัดสินใจให้ได้ว่า เป้าหมายของ doIt มันคืออะไร และต้องเป็นเป้าหมายที่ไม่ซ้ำซ้อนกับ rC ซึ่งเป็นฟังก์ชั่นย่อยภายในตัวมันด้วย

เป้าหมายของมันก็คือ การ return array ของไพ่ 5 ใบ ซึ่งแต่ละใบจะมีรูปแบบข้อมูลที่ถูกกำหนดจากฟังก์ชั่น rC (ซึ่งเราได้ Test ไปแล้ว) ดังนั้น High-Level Test ของฟังก์ชั่นนี้ก็คือ การนับจำนวนของไพ่ที่ถูก return ออกมา ดังนี้

describe('doIt()', function() {
it('does something', function() {
assert(doIt().length === 5);
});
});

สำหรับ Codebase ที่มีขนาดเล็ก การใช้ Test ในรูปแบบนี้เพื่อสำรวจการทำงานของระบบสามารถทำได้ไม่มีปัญหา แต่หากเมื่อไหร่ที่ Codebase มีขนาดใหญ่ การตั้งเป้าให้มี Coverage ถึงระดับ 100% ด้วยวิธีการ Test แบบนี้ เป็นเรื่องที่เป็นไปได้ยากมากในทางปฏิบัติ

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

อีกวิธีหนึ่งก็คือ ให้คุณวางมาตรฐานไว้สำหรับ Code ใหม่ว่าจะต้องมีแนวทางการเขียนอย่างไร และต้องมี Coverage เท่าไหร่ ในช่วงแรกนั้น Code ในกลุ่มนี้อาจจะดูมีจำนวนเพียงเสี้ยว เมื่อนำไปเทียบกับ Legacy Code ที่มีอยู่เดิม แต่ด้วยความอดทน Code คุณภาพแบบนี้จะเพิ่มขึ้นเรื่อยๆ จนเพิ่มส่งผลทางบวกต่อทั้งระบบ ดูไปแล้วก็เหมือนกับการชำระหนี้ (Technical Debt) โดยจะต้องชำระด้วยการเพิ่ม Coverage และ Refactoring อย่างต่อเนื่อง

จำไว้เลยว่า กระบวนการ “จ่ายหนี้” แบบนี้ คนเพียงคนเดียวไม่สามารถทำได้ ต้องอาศัยความร่วมแรงร่วมใจของทุกคนภายในทีมจึงจะสามารถบรรลุผลได้

นอกเหนือไปจากการปรับปรุงคุณภาพของ Code แล้ว สิ่งที่ Developer ทุกคนต้องฝึกให้เป็นนิสัยก็คือ การมอง Code ที่มีการใช้งานกันอยู่ที่ยังไม่มีการ Test ด้วยสายตาที่สงสัยไว้ก่อน เพราะเราต้องระลึกเสมอว่า เราต้องหลีกเลี่ยงการเปลี่ยนแปลง Code ที่ไม่มี Test คุม แต่เราก็ต้องตระหนักเช่นเดียวกันว่า นี่ไม่ใช่ความผิดของ Developer เจ้าของ Legacy Code เหล่านี้ เพราะในช่วงเวลานั้น ทุกคนต่างก็มีความจำเป็น และเหตุผลอันควรของตัวเองทั้งสิ้น

โดยสรุปก็คือ หากคุณต้องเข้ามารับผิดชอบ Legacy Code ขนาดใหญ่ ให้เริ่มทำการ Test ที่ Code ขนาดเล็กๆ ก่อน โดยใช้ Characterization Test เข้ามาช่วย จากนั้นให้วางกฎเพื่อให้ Code ใหม่ ต้องมี Coverage ในระดับที่เหมาะสม โดยใช้วิธีการ Test สำหรับ Feature ใหม่ (อาจจะใช้ TDD หรือไม่ก็ได้) แล้วทำ Regression Test เพื่อจัดการกับ bug ที่เกิดขึ้นใหม่ และที่สำคัญที่สุดก็คือ อย่าหาคนผิด เพราะความผิดไม่ได้อยู่ที่คน แต่อยู่ที่กระบวนการต่างหาก

Debugging and Regression Test

Characterization Test ช่วยให้เราสามารถตรวจสอบ Code ว่ามันทำทำงานได้จริงหรือไม่ ถึงแม้ว่าฟังก์ชั่นบางตัวจะให้ผลลัพธ์แบบ Random ก็ตาม แต่เราก็สามารถสร้าง Test คุมมันได้สำเร็จแล้ว จนความมั่นใจเพิ่มพูน คราวนี้จะทำการ Refactoring, เพิ่ม Feature หรือแก้ Bug ก็ไม่ใช่ปัญหาใหญ่

เรามาเริ่มกันที่ตัวอย่างใหม่กันเลยดีกว่า เนื้อเรื่องมีอยู่ว่า เราได้ไปเจอกับ Bug เจ้ากรรมใน Production โดยเจ้า Bug ดังกล่าวแสดงผล Error ออกมาประมาณว่า ผู้เล่นได้ไพ่ซ้ำกันหลายใบ

หากคุณกำลังจะเอาระบบนี้ไปใช้กับธุรกิจคาสิโนออนไลน์ของคุณ รับรองเจ๊งยับ เพราะแปลว่าผู้เล่นมีโอกาสได้แต้ม four of a kind สูงมาก (ที่สำคัญ five of a kind ก็ดันมีด้วย ทั้งๆ ที่ไพ่หนึ่งแต้มจะมีได้ 4 ใบเท่านั้น) ใครเข้ามาใช้ระบบ คงไม่กล้ากลับมาเล่นอีก เพราะไม่มีความน่าเชื่อถือเอาซะเลย

เราดู Code กันเลยดีกว่าว่า มันมีจุดผิดพลาดตรงไหน โดยให้สร้างไฟล์ที่ชื่อว่า random-hand-with-regression.js

var suits = ['H', 'D', 'S', 'C'];
var values = ['1', '2', '3', '4', '5', '6',
'7', '8', '9', '10', 'J', 'Q', 'K'];
var randomSuit = function() {
return suits[Math.floor(Math.random() * (suits.length))];
};
var randomValue = function() {
return values[Math.floor(Math.random() * (values.length))];
};
var randomCard = function() {
return randomValue() + '-' + randomSuit();
};
var randomHand = function() {
var cards = [];
cards.push(randomCard());
cards.push(randomCard());
cards.push(randomCard());
cards.push(randomCard());
cards.push(randomCard());
return cards;
};
console.log(randomHand());

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

const chai = require('chai');
const assert = chai.assert;
describe('randomHand()', function() {
it('returns 5 randomCards', function() {
assert(randomHand().length === 5);
});
});
describe('randomCard()', function() {
it('returns nothing', function() {
assert(randomCard().match(/\w{1,2}-[HDSC]/));
});
});
describe('randomValue()', function() {
it('return nothing', function() {
assert(randomValue().match(/\w{1,2}/));
});
});
describe('randomSuit()', function() {
it('return nothing', function() {
assert(randomSuit().match(/[HDSC]/));
});
});

งานต่อไปของเราก็คือ การ Reproduce Bug ให้เกิดขึ้นซ้ำ วิธีที่ง่ายที่สุดก็คือ รันระบบด้วย node แล้วดูว่า console.log() จะแสดงผลไพ่ซ้ำกันออกมาเมื่อไหร่ หากไม่ออก ให้รันซ้ำๆๆๆๆ ไปเรื่อยๆ จนกว่าจะได้ผลลัพธ์ที่ต้องการ คราวนี้ลองนับดูครับว่า ต้องรันกี่รอบกว่าจะ Reproduce Bug ได้

วิธีแบบนี้คือ Manual Test (ซึ่งได้กล่าวไว้ในบทที่ 3) ที่กว่าจะเจอ Error ได้ซักที คงต้องรันซ้ำไปซ้ำมาจนเซ็ง ขั้นตอนต่อไปจึงควรเป็นการเขียน Test เพื่อ Exercise Code จนกว่า Error จะโผล่หัวออกมา พูดง่ายๆ ก็คือ เราต้องการเขียน Test ที่ต้อง Fail ให้ได้ก่อนจะเขียน Code ใดๆ ต่อไป ซึ่งบางคนอาจจะเขียน Test Code ตามสัญชาตญาณดังนี้

describe('randomHand()', function() {
. . .
for(var i=0; i<1000; i++){
it('should not have the first two cards be the same', function() {
var result = randomHand();
assert.notEqual(result[0], result[1]);
});
};
});

ซึ่งก็น่าจะทำให้มัน Fail จริงๆ นั่นแหละ แต่ Test Code แบบนี้มีข้อเสียสองอย่าง ข้อแรก Test นี้เป็น Slow Test เพราะมันต้องรันหลายรอบ ข้อสอง มันก็ไม่แน่ว่าการรัน 100 รอบจะทำให้เราเจอ Error ถึงเราอาจจะเพิ่มจำนวนรอบจนรับประกันผลการ Fail ได้ก็ตามที แต่นั่นก็จะยิ่งทำให้ Test ยิ่งช้าลงไปอีก

แต่ถึงกระนั้นก็ตาม Test กากๆ แบบนี้ก็ยังสามารถช่วยให้เราสามารถปรับเปลี่ยนเนื้อในของฟังก์ชั่น randomHand() โดยเราสามารถเพิ่มจำนวนรอบให้มากเท่าไหร่ก็ได้ ตราบเท่าที่มันสามารถรับประกันได้ว่า มันจะ Test จน Fail ใน (เกือบ) ทุกกรณี

ในเมื่อตอนนี้เราได้สร้าง Failing Test คุมไว้แล้ว ก็สามารถเปลี่ยน implementation ของฟังก์ชั่นได้อย่างปลอดภัย แต่บอกไว้ก่อนนะครับว่า งานนี้ไม่ใช่การ Refactoring เพราะถึงแม้จะเป็นการเปลี่ยน Code ที่มี Test คุมก็ตาม แต่ในกรณีนี้ เราตั้งใจจะเปลี่ยน พฤติกรรม ของฟังก์ชั่น จาก Failing Test (Red) ไปยัง Passing Test (Green) ดังนั้น เราจึงไม่เรียกว่าเป็นการ Refactoring

ตอนนี้เราจะ return ทั้งค่าของแต้ม และดอกบนไพ่ออกมาพร้อมๆ กัน (แทนที่จะ random ค่าของแต้ม และดอกแยกต่างหากจากกัน) โดยอาจจะสร้าง Array ที่เก็บไพ่ทั้ง 52 ใบขึ้นมา โดยใช้ Array สองตัวที่เราได้สร้างไว้ก่อนหน้านี้มาเป็นวัตถุดิบ โดยเราจะเริ่มต้นจากการ Test ว่าเราต้องได้ไพ่ทั้ง 52 ใบออกมา

โดยก่อนอื่นให้รันคำสั่งต่อไปนี้

mocha -w random-hand-with-regression.js

จากนั้น ให้เริ่มเขียน Test ดังนี้

describe('buildCardArray()', function() {
it('returns a full deck', function() {
assert.equal(buildCardArray().length, 52);
});
});

error บอกเราว่า ฟังก์ชั่น buildCardArray() ยังไม่มีในระบบเลย

ดังนั้นให้สร้างโครงฟังก์ชั่นขึ้นมา ดังนี้

var buildCardArray = function() {
return [];
};

ตอนนี้ Error ที่ได้มาก็ดูมีความหมายมากขึ้น เป็น Fail Test (ไม่ใช่แค่ Error) จากนั้นก็ได้เวลาสร้าง Array โดยใช้การวนลูปแบบธรรมดา ดังนี้

var buildCardArray = function() {
var tempArray = [];
for(var i=0; i < values.length; i++){
for(var j=0; j < suits.length; j++) {
tempArray.push(values[i]+'-'+suits[j]);
}
}
return tempArray;
};

Test ผ่านหมดทุกตัวแล้ว แต่ดูเหมือนว่า พฤติกรรมของ Code ยังไม่ถูก Test เลย เพราะเราแค่ Test ว่า ฟังก์ชั่น buildCardArray() return Array ที่มีจำนวนสมาชิกเท่ากับ 52 ตัว แต่เราต้องการทราบว่า ฟังก์ชั่นตัวนี้มัน return อะไรออกมาบ้าง วิธีการก็ง่ายๆ ครับ แค่เขียน Test เพื่อเปรียบเทียบผลลัพธ์ของฟังก์ชั่นขึ้นมา แต่เนื่องจากเราไม่รู้ว่าผลลัพธ์ของมันคืออะไร ดังนั้น เราก็ต้องใช้ Characteristic Test เพื่อตรวจดูผลลัพธ์ของฟังก์ชั่นแบบง่ายๆ ดังนี้

describe('buildCardArray()', function() {
. . .
it('check characteristic', function() {
console.log(buildCardArray());
assert.equal(buildCardArray(), true);
});
});

คราวนี้เราได้เห็นผลลัพธ์ของฟังก์ชั่นนี้แล้ว

    [
'1-H','1-D','1-S','1-C','2-H','2-D','2-S','2-C',
'3-H','3-D','3-S','3-C','4-H','4-D','4-S','4-C',
'5-H','5-D','5-S','5-C','6-H','6-D','6-S','6-C',
'7-H','7-D','7-S','7-C','8-H','8-D','8-S','8-C',
'9-H','9-D','9-S','9-C','10-H','10-D','10-S','10-C',
'J-H','J-D','J-S','J-C','Q-H','Q-D','Q-S','Q-C',
'K-H','K-D','K-S','K-C'
];

คราวนี้เราก็เอาผลลัพธ์ดังกล่าวมาใช้เพื่อการ Assertion กับฟังก์ชั่น buildCardArray() ได้ทันที ถือเป็นการเพิ่ม Coverage และเพิ่มความมั่นใจไปพร้อมๆ กันว่า เราจะไม่ทำ Code พัง หากมีการเปลี่ยนแปลงอะไรในภายหลังอย่างแน่นอน

โดย Test Code จะมีเนื้อหาดังนี้

describe('buildCardArray()', function() {
. . .
it('gives a card array', function() {
var expected = [
'1-H','1-D','1-S','1-C','2-H','2-D','2-S','2-C',
'3-H','3-D','3-S','3-C','4-H','4-D','4-S','4-C',
'5-H','5-D','5-S','5-C','6-H','6-D','6-S','6-C',
'7-H','7-D','7-S','7-C','8-H','8-D','8-S','8-C',
'9-H','9-D','9-S','9-C','10-H','10-D','10-S','10-C',
'J-H','J-D','J-S','J-C','Q-H','Q-D','Q-S','Q-C',
'K-H','K-D','K-S','K-C'
];
assert.deepEqual(buildCardArray(), expected);
});
});

ผ่านหมด แปลว่าตอนนี้เรามีฟังก์ชั่นที่สามารถ return ไพ่ทั้ง 52 ใบแล้ว ดังนั้น เราสามารถนำฟังก์ชั่นดังกล่าวมาช่วยให้ฟังก์ชั่น randomHand() เจ้าปัญหาของเราไม่ต้อง return ไพ่ซ้ำอีกต่อไป

แต่ตอนนี้หากเรากับมารัน Test ที่ชื่อ “should not have the first two cards be the same” ต่อ เราก็ต้องเจอกับ Error เหมือนเดิม ซึ่งมันก็ควรจะเป็นเช่นนั้น เพราะเรายังไม่ได้แก้ Code ภายในฟังก์ชั่น randomHand() เลย ดังนั้น เรามาแก้กันเถอะ

var randomHand = function() {
var cards = [];
var deckSize = 52;
cards.push(buildCardArray()[Math.floor(Math.random() * deckSize)]);
cards.push(buildCardArray()[Math.floor(Math.random() * deckSize)]);
cards.push(buildCardArray()[Math.floor(Math.random() * deckSize)]);
cards.push(buildCardArray()[Math.floor(Math.random() * deckSize)]);
cards.push(buildCardArray()[Math.floor(Math.random() * deckSize)]);
return cards;
};

สังเกตุว่า Test ก็ยังให้ผล Fail เหมือนเดิม (หากไม่ fail ก็ให้เพิ่มจำนวนรอบของการรัน Test จนกว่าจะเจอ) แต่บอกเลยว่า นี่คือช่วงเวลาที่เรารอคอย เพราะตอนนี้ฟังก์ชั่น randomHand() มี Test คอยคุมแล้ว พร้อมให้เราเข้าไปแก้ไขได้อย่างมั่นใจ ลองคิดดูสิครับว่า หากไม่มี Test อยู่เบื้องหลัง เราอาจจะคิดว่าได้แก้ Bug ไปแล้ว แต่ที่ไหนได้ พอเอาขึ้น Production อาจจะเจอ Bug เดิมกลับมาก็เป็นได้

ตอนนี้ได้เวลาทำให้ Test ผ่านแล้ว

var randomHand = function() {
var cards = [];
var cardArray = buildCardArray();
cards.push(cardArray.splice(Math.floor(
Math.random() * cardArray.length), 1)[0]);
cards.push(cardArray.splice(Math.floor(
Math.random() * cardArray.length), 1)[0]);
cards.push(cardArray.splice(Math.floor(
Math.random() * cardArray.length), 1)[0]);
cards.push(cardArray.splice(Math.floor(
Math.random() * cardArray.length), 1)[0]);
cards.push(cardArray.splice(Math.floor(
Math.random() * cardArray.length), 1)[0]);
return cards;
};

ครั้งนี้เราใช้ฟังก์ชั่น splice() เพื่อดึงสมาชิกที่ random index จำนวน (parameter ตัวแรก ของ splice) จำนวน 1 ตัว (parameter ตัวที่สอง) แล้ว push เข้าไปใน array variable ที่ชื่อว่า cards

splice() เป็นฟังก์ชั่นแบบ side effect ที่มีผลทำสมาชิกภายใน้ array ต้นทางถูกลบออกไป ซึ่งดูไปแล้วก็เหมาะกับสถานการณ์ของเขาในตอนนี้พอดี (แต่หากมองจากมุมของ functional programming ในบทที่ 11 แล้ว วิธีนี้ก็ไม่งดงามเท่าไหร่)

บางคนอาจจะเริ่มถามว่า แบบนี้เรียกว่าเป็นการ Refactoring หรือยัง? ตอบได้เลยว่า “ยัง” เพราะจนถึงตอนนี้ เรากำลังทำการเปลี่ยนพฤติกรรมของ Code อยู่ (เปลี่ยนจาก “red” ไปเป็น “green”) และตอนนี้ Test ก็ผ่านหมดแล้ว ถึงแม้จะต้องใช้ Test แบบวนลูปเป็นร้อย เป็นพันรอบก็ตามที

ปัญหาที่เหลือก็คือ Test Code ที่ใช้สำหรับฟังก์ชั่น randomHand() เป็น Test ที่คาดเดาผลลัพธ์ไม่ได้ และเป็น Slow Test

วิธีแก้ปัญหาที่ผมเลือกใช้ในการแก้ปัญหาครั้งนี้ก็คือ เราจะทำการ Test เข้าไปที่ระดับ implementation ของฟังก์ชั่น เพื่อเลี่ยงผลลัพธ์แบบ random โดยเริ่มจากสร้างฟังก์ชั่นใหม่ขึ้นมาชื่อว่า spliceCard()

เริ่มต้นโดยการเขียน Test ก่อน ดังนี้

describe('spliceCard()', function() {
it('returns two things', function() {
var result = spliceCard(buildCardArray()).length
assert.equal(result, 2);
});
it('returns the selected card', function() {
var result = spliceCard(buildCardArray())[0].match(/\w{1,2}-[HDSC]/);
assert(result);
});
it('returns an array with one card gone', function() {
var result = spliceCard(buildCardArray())[1].length;
var expected = buildCardArray().length - 1;
assert.equal(result, expected);
});
});

จากนั้นก็ตามธรรมเนียมครับ ให้เราสร้างโครงฟังก์ชั่นขึ้นมา

var spliceCard = function(cardArray){
return [];
};

จากนั้นเราจะ implement เนื้อในฟังก์ชั่นดังนี้

var spliceCard = function(cardArray){
var takeAway = cardArray.splice(
Math.floor(Math.random() * cardArray.length), 1
)[0];
return [takeAway, cardArray];
};

ผ่านหมดทุกตัว ที่สำคัญ Slow Test ตัวเก่าก็ยังคงผ่านเหมือนเดิม ตอนนี้ได้เวลาเข้าไปจัดการกับฟังก์ชั่น randomHand โดย Code ต่อไปนี้คือการปรับครั้งแรก

var randomHand = function() {
var cards = [];
var cardArray = buildCardArray();
var result = spliceCard(cardArray);
cards[0] = result[0];
cardArray = result[1];
result = spliceCard(cardArray);
cards[1] = result[0];
cardArray = result[1];
result = spliceCard(cardArray);
cards[2] = result[0];
cardArray = result[1];
result = spliceCard(cardArray);
cards[3] = result[0];
cardArray = result[1];
result = spliceCard(cardArray);
cards[4] = result[0];
cardArray = result[1];
return cards;
};

ตอนนี้เราเรียกได้เต็มปากหรือยังว่าเป็นการ Refactoring? ใช่ครับ เราได้ Refactoring ไปแล้ว โดยเราได้ทำการ extract function โดยไม่มีการเปลี่ยนพฤติกรรมของ Code (เพราะ Test ยังเหมือนเดิม) และ Test ของเรายังผ่านเหมือนเดิม ไม่ว่าจะรันกี่รอบก็ตาม

งานสุดท้ายที่เหลืออยู่ก็คือ กำจัดพวก Dead Code หรือ Code ที่ไม่ได้ใช้งานออกไป ซึ่งประกอบไปด้วย randomSuit randomValue randomCard พร้อมทั้ง Test ที่คุมทั้งสามฟังก์ชั่น ให้ออกไปจากระบบ

คำถามคือ งานของเราจบหรือยัง? นั่นก็แล้วแต่ใจของคุณผู้อ่านแล้วล่ะครับ หากเราจะเพิ่มฟีเจอร์ใหม่เข้าไปใน Codebase การเข้าสู่วงจร Red/Green/Refactor ใหม่ก็เป็นเรื่องที่เหมาะสม แต่หากเราโอเคกับคุณภาพของ Code ในตอนนี้แล้ว ก็ไม่จำเป็นต้องทำอะไรเพิ่มอีก

สำหรับ Code ชุดนี้ ผมยังรู้สึกว่ามันสามารถ Refactoring ได้อีกซักหน่อย เป็นการปรับฟังก์ชั่น randomHand ครั้งสุดท้าย โดยใช้ฟีเจอร์ของ ES6 ที่ชื่อว่า Destructuring ซึ่งช่วยให้เราสามารถ assign variable หลายๆ ตัวได้ในเวลาเดียวกัน

โดย Code หลังจากการ Refactoring จะมีหน้าตาดังนี้

var randomHand = function() {
var cards = [];
var cardArray = buildCardArray();
[cards[0], cardArray] = spliceCard(cardArray);
[cards[1], cardArray] = spliceCard(cardArray);
[cards[2], cardArray] = spliceCard(cardArray);
[cards[3], cardArray] = spliceCard(cardArray);
[cards[4], cardArray] = spliceCard(cardArray);
return cards;
};

Code สวยขึ้น และ Test ก็ยังผ่านเหมือนเดิม

สรุปส่งท้าย

บางครั้ง คุณอาจจะรู้สึกอยากจะเปลี่ยน Interface ของฟังก์ชั่น (โครงสร้างของ Input และ Output) นั่นแปลว่า คุณกำลังจะเขียน Code ใหม่ ที่จำเป็นต้องมี Test ชุดใหม่เข้ามาคุม แต่ Refactoring ไม่จำเป็นต้องมี Test ชุดใหม่ใดๆ ทั้งสิ้น เพราะ Interface ของ Code เก่าผ่าน Test ไปเรียบร้อยแล้ว (แต่สำหรับบางคนอาจจะอยากเพิ่ม Test เข้าไป เพื่อความมั่นใจ)

โดยสรุป เราจะใช้ Red/Green/Refactor เพื่อการ Regression Test, ทำ Regression Test เพื่อหาก Bug, เราเขียน Test เพื่อเพิ่มความมั่นใจ, เขียน Characterization Test เพื่อตรวจสอบ Untested Code

เราสามารถ Refactoring ได้มากตามความเหมาะสม โดยมีเงื่อนไขเดียว นั่นคือ ก่อนการ Refactoring ทุกครั้ง ต้องมี Test Coverage ที่เพียงพอ จนเราสามารถเปลี่ยน Code ได้อย่างมั่นใจ

กลับไปที่ สารบัญ

--

--