Angular の Unit Testing — Part 2 มาลองม็อคค่ากัน

ถ้าใครยังไม่ได้อ่าน Part 1 คลิ๊กบทความด้านล่างเข้าไปอ่านได้ก่อนเลยครับ
Recap จากตอนที่แล้ว เราพูดถึงในเรื่องของ
- ข้อดีของการเขียนเทส
- อธิบาย describe, beforeEach และ it เบื้องต้น
- เขียนและรันเทสเคสแบบง่ายๆ
คราวนี้ถึงคราวที่เราจะมาเริ่มสำหรับพาร์ทนี้กันแล้วครับ โดยตอนนี้จะเริ่มเข้ามาโฟกัสที่การม็อคค่าสำหรับการทำเทส (ม็อคค่าในที่นี้ไม่ใช่กาแฟและก็ไม่ใช่ test framework นะครับ ฮ่าๆๆ แต่เป็นคำที่ทีมผมใช้ในการสื่อสารกันสำหรับการทำ Mockup ต่างๆ ผมเลยติดคำนี้มา)
อันดับแรกเลยเปิดไฟล์ component หลักของเราครับ for-test.component.ts และให้ลบฟังก์ชั่น increaseValue ที่เคยเพิ่มไปเมื่อพาร์ทที่แล้ว ออกก่อนเลย
increaseValue(val: number, inc: number): number { return val + inc;} //ลบฟังก์ชั่นนี้ออก
แล้วเพิ่มสองฟังก์ชั่นนี้เข้าไป setSomething กับ getRandomValue
setSomething(val: number): string { let returnValue: string; if (val - this.getRandomValue() < 1) { returnValue = 'Success'; } else { returnValue = 'Fail'; } return returnValue;}getRandomValue(): number { const a = 10; const c = 20; return (a + c) * 2;}
อัพเดตไฟล์ for-test.component.ts เสร็จแล้วจะได้หน้าตาแบบข้างล่างนี้

เราจะมี 2 ฟังก์ชั่น
- setSomething ที่รับพารามิเตอร์ type number และมีการรีเทิร์นค่าจากฟังก์ชั่นเป็น string โดยการทำงานของฟังก์ชั่นนี้คือ จะทำการส่งค่าตัวเลขเข้าไปทำอะไรสักอย่างแล้วคอยรับผลว่า Success หรือ Fail โดยในฟังก์ชั่นนี้จะมีการเรียกใช้ฟังก์ชั่นอื่นมาด้วย ก็คือฟังก์ชั่น getRandomValue นั่นเอง
- getRandomValue เป็นฟังก์ชั่นที่รีเทิร์นค่าตัวเลข(สักค่านึง) ออกมาจากฟังก์ชั่น
จะเห็นได้ว่าการทำงานของทั้งสองฟังก์ชั่นนี้ถูกร้อยกันอยู่ด้วยการเรียกใช้ ฟังก์ชั่น getRandomValue ในฟังก์ชั่น setSomething และการมีเงื่อนไขในการตัดสินใจอะไรบางอย่าง ซึ่งในมุมของผมหากเราต้องการที่จะเขียน unit test เพื่อเทสฟังก์ชั่นนี้ เราจะต้องแบ่งการเทสนี้เป็น 2 เทสเคส นั่นก็คือ เคสที่รันแล้วได้ค่ารีเทิร์นเป็น ‘Success’ และ ‘Fail’
ใครที่ยังเก็บเทสเคส should increse value from 10 to 25 ที่เขียนไว้เมื่อพาร์ทที่แล้วอยู่ก็อย่าลืมไปลบออกด้วยนะครับ
it('should increse value from 10 to 25', () => { expect(component.increaseValue(10, 15)).toBe(25);}); //ลบเทสเคสนี้ออกนะ
แล้วเพิ่มเทสเคสใหม่เข้าไป 2 เทสเคส ตามค่าที่เราอยากได้จากฟังก์ชั่นนี้
it('should return Success from setSomething function', () => { expect(component.setSomething(10)).toBe('Success');});it('should return Fail from setSomething function', () => { expect(component.setSomething(20)).toBe('Fail');});
เขียนเสร็จแล้วจะออกมาประมาณนี้

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

ผลที่ได้ออกมาจะเป็นแบบนี้

Executed 6 of 6 (1 FAILED) (0 secs / 0.174 secs)
ForTestComponent should return Fail from setSomething function FAILED Expected 'Success' to be 'Fail'. at UserContext.<anonymous> src/app/components/for-test/for-test.component.spec.ts:31:40)จะเห็นได้ว่า เรามีเทสเคสทั้งหมด 6 เทสเคส(มีในไฟล์ app.component.spec.ts 3 เทสเคสและ ไฟล์ for-test.component.spec.ts 3 เทสเคส) และมันมี FAILED อยู่ 1 คือเทสเคสที่ชื่อว่า ‘should return Fail from setSomething function’ โดยเทสเคสนี้เรามีความคาดหวังว่ามันจะรีเทิร์นค่า Fail ออกจากฟังก์ชั่น setSomething แต่มันดันออกมาเป็น Success เนาะ
ซึ่งแน่นอนอยู่แล้วล่ะว่าเทสเคสที่เราเพิ่มไปทั้ง 2 อันนั้นเราใส่พารามิเตอร์ที่เหมือนกันไปทั้ง 2 เคส component.setSomething(10) ซึ่งหลายคนที่อ่านมาถึงตรงนี้ก็คงจะเอะใจแล้ว ว่าทำไมไม่แก้ค่า 20 ในเทสเคสที่มัน FAILED ให้เป็นค่าอื่นเพื่อให้มันเข้า else ในฟังก์ชั่น setSomething เพื่อให้มันเซ็ทค่า returnValue = ‘Fail’ ล่ะ
คำตอบคือเราจะไม่ทำอย่างนั้นครับ เพราะถ้าเราทำแบบนั้น มันจะเป็นการหลอกเทสเคสให้มันรันผ่านๆไป โดยไม่สนใจความถูกต้องของฟังก์ชั่นจริงๆซะมากกว่า และเนื่องจากสิ่งที่เราต้องการคือความถูกต้องของการทำงานในฟังก์ชั่น setSomething โดยไม่สนใจค่าจากฟังก์ชั่นอื่นที่เข้ามาเกี่ยวข้องเนาะ และอันนี้คือเรารู้เราเห็นอยู่แล้วว่าค่าที่มันรีเทิร์นออกมาจากฟังก์ชั่น getRandomValue มันคือค่า 60 เป็นค่าคงที่ แล้วถ้าในความเป็นจริงเราไม่รู้ล่ะว่าสิ่งที่มันจะรีเทิร์นออกมาจากฟังก์ชั่นที่เรียกใช้คืออะไร หรือค่าที่มันรีเทิร์นออกมานั้นเปลี่ยนแปลงไปเรื่อยๆ เช่นครั้งแรกรีเทิร์นค่าออกมาเป็น 60 ครั้งที่สองเป็น 40 ครั้งที่สามเป็น 120 ถ้าเป็นแบบนี้เราไม่ต้องไปนั่งแก้เทสเคสเพื่อให้มันปรับค่าตามการรันของทุกครั้งเหรอ !!!
จริงๆแล้วถ้าเราทำ TDD ปัญหาเรื่องการคิดตรงนี้น่าจะไม่เกิดนะ
ซึ่งผมอยากจะแนะนำให้เข้าไปอ่าน Principles of unit testing ในบทความนี้ครับ แล้วจะเข้าใจเหตุผลในการเขียนเทสที่ดีมากขึ้น
เพื่อแก้ปัญหาตรงนี้เราจะม็อคค่าให้กับฟังก์ชั่น getRandomValue เพื่อให้มีการรีเทิร์นค่าที่อยู่ในการควบคุมของเราด้วย Spy
SpyOn ที่จะทำนี้เป็นฟีเจอร์ของ Jasmine Framework ที่เรากำลังเขียนเทสอยู่นั่นเอง โดย Spy จะทำหน้าที่ม็อคค่ารีเทิร์นจากคลาส หรือฟังก์ชั่นที่เราเขียนไว้อยู่แล้ว นั่นก็คือเราจะสามารถใช้ม็อคค่าให้กับคลาส ForTestComponent ได้ และสามารถม็อคค่าให้กับฟังก์ชั่น setSomething และ getRandomValue หรือจะเป็นฟังก์ชั่นใดๆในคลาสนี้ก็ได้
เราจะเขียน Spy เพื่อม็อคค่าออกมาได้ประมาณนี้

spyOn(component, 'getRandomValue').and.returnValue(10);ผมจะอธิบายตรงโค้ดส่วน Spy ที่เราเพิ่มเข้าไปในแต่ละเทสเคสนะครับ คือฟังก์ชั่น spyOn ของ Jasmine จะรับพารามิเตอร์ 2 ตัว ตัวแรกจะเป็น component ที่เราจำลองมาจาก Tesbed หรืออาจจะเป็น component จริงๆเลยก็ได้ (ซึ่งจะพูดถึงในบทความตอนถัดๆไปครับ) และตัวที่สองจะเป็นชื่อฟังก์ชั่นที่รับค่าเป็นชื่อฟังก์ชั่น ที่ดูเหมือนจะรับค่าเป็น type string แต่จริงๆมันไม่ใช่ string แต่มันเป็น keyof นะครับ และตามด้วย .and ความหมายก็ตรงๆตัวเลย และตามด้วยฟังก์ชั่น returnValue ตรงนี้เราจะใส่พารามิเตอร์ที่เราจะม็อคค่ารีเทิร์นให้กับฟังก์ชั่นนี้ ซึ่งในนี้เราใส่เป็น 10 เข้าไป
ซึ่งจากโค้ดตรงนี้พูดง่ายๆเลยคือ ถ้ารันเทสแล้วเจอฟังก์ชั่นชื่อ getRandomValue ให้ทำการ by pass ค่าที่รีเทิร์นกลับมาจากใน getRandomValue ทันทีเลย
คราวนี้เรามาลองรันเทสด้วยคำสั่ง ng test กันเลย

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

