Angular の Unit Testing — Part 3 ม็อค Service ด้วย SpyObject กัน
ใครยังไม่ได้อ่าน Part 2 คลิ๊กบทความด้านล่างเข้าไปอ่านได้ก่อนนะครับ
Recap จากตอนที่แล้ว เราพูดถึงในเรื่องของ
- ข้อดีของการเขียนเทส
- อธิบาย describe, beforeEach และ it เบื้องต้น
- เขียนและรันเทสเคสแบบง่ายๆ
- การม็อคค่า(by pass) ด้วย SpyOn
พาร์ทนี้เราจะมาต่อกันในเรื่องของการม็อคค่าที่เราจะไม่ได้ม็อคกันแค่ฟังก์ชั่นเท่านั้นคราวนี้เราจะม็อคกันในระดับ component ซึ่งพระเอกของตอนนี้มีชื่อว่า SpyObject
เจ้า SpyObject เนี่ยมันเหมือนจะคล้ายกับตัว SpyOn แต่ก็ไม่เชิง ก่อนหน้านี้ที่เราลองม็อคด้วย spyOn(component, ‘getRandomValue’).and.returnValue(10) เราจะสังเกตุเห็นว่าการที่เราจะม็อคให้มันรีเทิร์นค่าออกมาตามที่เราต้องการได้เนี่ย คือเราต้องมีฟังก์ชั่น getRandomValue และมีตัวแปร component จริงๆที่เกิดจากการจำลองมาจาก TestBed หรือมาจากการสร้างไว้อยู่แล้ว หรืออีกอย่างนึงก็คือมันเหมาะที่จะเอาไว้ม็อคค่าแค่ใน component หลักของเราซะมากกว่า แต่ SpyObject เนี่ยจะเหมาะสำหรับการใช้ม็อคพวก Service, Object หรือ Component ต่างๆที่เราเรียกใช้มาจากข้างนอก Component หลักของเรา
ขอบอกอีกครั้งนะครับว่าบทความของผมไม่ได้ทำ TDD เพื่อให้คนที่ไม่เคยเขียนเทสได้เห็นภาพของการเขียนเทสก่อน
เอาล่ะ หลักการสร้างและใช้งาน SpyObject มันเป็นยังไง เรามาดูกันต่อเลยดีกว่าครับขั้นตอนแรกเลย ให้เราสร้าง Service ขึ้นมาใหม่ ชื่อว่า test ให้อยู่ภายใต้โฟลเดอร์ services โดยใช้คำสั่ง
ng g s services/test
เมื่อสร้างเสร็จแล้ว เราจะได้โฟลเดอร์ services กับไฟล์ test.service.spec.ts ที่เป็นไฟล์สำหรับเขียนเทส และ test.service.ts ที่เป็นไฟล์ service หลักมา
ให้เราเปิดไฟล์ test.service.ts และเพิ่มโค้ดเข้าไป 3 ส่วนครับ ส่วนแรกเป็นการ Import ให้เรา import HttpClient เข้ามาเพื่อจะใช้ http service ข้างใน service นี้
import { HttpClient } from '@angular/common/http';
หลังจาก Import แล้วให้ inject ของเข้าผ่าน constructor โดยเราประกาศเป็นตัวแปรชื่อ http ชนิดตัวแปร HttpClient เป็นแบบ private กัน
constructor(private http: HttpClient) { }
จากนั้นสร้างฟังก์ชั่น getGithubUserDetail ขึ้นมา โดยรีเทิร์นค่าออกจากฟังก์ชั่นเป็น Promise ซึ่งในฟังก์ชั่นนี้จะเรียกใช้ตัว HttpClient ที่เราได้ inject เข้ามาทาง constructor โดยเราจะเรียกใช้ฟังก์ชั่น HTTP request method เป็นแบบ GET เรียกไปที่ https://api.github.com/users/kitphon
getGithubUserDetail(): Promise<any> {
return this.http.get('https://api.github.com/users/kitphon')
.toPromise();
}
แต่เดี๋ยวก่อน แค่นี้ยังไม่พอ เพื่อที่เราจะสามารถใช้งาน HttpClient ใน service ของเราได้ เราต้องไป import HttpClientModule เข้าไปที่ app.module.ts ด้วย
ให้เราเปิดไฟล์ app.module.ts และเพิ่มโค้ดเข้าไป 2 ส่วน คือ
import { HttpClientModule } from ‘@angular/common/http’;
และเพิ่ม HttpClientModule เข้าไปตรงส่วน imports ใน @NgModule ด้วย
และในไฟล์เทส test.service.spec.ts ให้เข้าไปเพิ่ม HttpClientModule imports ในส่วนของ TestBed configureTestingModule ด้วย ไม่งั้นเวลาเรารันเทสแล้วมันจะ FAILED ว่า NullInjectorError: No provider for HttpClient! เพราะว่า test.service.ts มีการเรียกใช้ HttpClient อยู่
ขออธิบายเพิ่มครับ ในตรงส่วนบนนี้จะเห็นว่าไฟล์เทส test.service.spec.ts ถ้าเราไม่ได้ import HttpClientModule เข้าไปใน TestBed เวลารันเทสมันจะ FAILED ทันที ถ้าให้ยกตัวอย่างให้เห็นภาพง่ายๆเลยก็คือ มันเหมือนกับตัวไฟล์หลักของเรา test.service.ts ที่มีการเรียกใช้ HttpClient อยู่ และเราจะเห็นว่าก่อนหน้าที่เรา import และ inject HttpClient เข้าไปใน constructor ของ service นี้เราต้องไป import HttpClientModule ในไฟล์ app.module.ts ซะก่อน
เจ้าตัว TestBed ในไฟล์เทสนี่ก็เหมือนกัน คือสิ่งที่เราต้องเพิ่มเข้า app.module.ts นั้นเราต้องมาเพิ่มใน TestBed ของไฟล์เทสนั้นๆด้วย
จากนั้นให้เรากลับมาที่ไฟล์ for-test.component.ts และให้เราลบฟังก์ชั่นที่เคยเขียนไว้เมื่อพาร์ทที่แล้วออกไป แล้วเขียนฟังก์ชั่นใหม่เพิ่มเข้าไปแทนครับ
async isPublicRepoGreaterThan(val: number): Promise<boolean> {
let returnValue: boolean;
await this.testService.getGithubUserDetail().then(
result => {
if (result.public_repos > val) {
returnValue = true;
} else {
returnValue = false;
}
}
);
return returnValue;
}
หลังจากแก้ไขแล้ว จะได้ไฟล์หน้าตาแบบนี้ครับ
ขอไม่อธิบายเรื่อง Promise, Async, Await ละกัน ไม่งั้นเดี๋ยวจะออกนอกประเด็นเยอะ
ฟังก์ชั่น isPublicRepoGreaterThan จะทำการเช็คว่าค่า public_repos ที่รีเทิร์นมาจาก getGithubUserDetail นั้นมีค่ามากกว่าค่าที่เราใส่ไปในพารามิเตอร์หรือเปล่า
จากนั้นไปที่ไฟล์เทสของเรากัน for-test.component.spec.ts
it(‘should return Success from setSomething function’, ()it(‘should return Fail from setSomething function’, ()
และให้ลบเทสเคสเก่า 2 อันบนนี้ออกก่อน และเพิ่มเทสเคสใหม่เข้าไป
it('should return True from isPublicRepoGreaterThan function', (async() => {
await component.isPublicRepoGreaterThan(1).then(
(result) => {
expect(result).toBe(true);
}
);
}));it('should return False from isPublicRepoGreaterThan function', (done) => {
component.isPublicRepoGreaterThan(10).then(
(result) => {
expect(result).toBe(false);
done();
}
);
});
จะเห็นว่าเทสเคสที่เพิ่มเข้าไปใหม่นี้ มีทั้งการใช้ async, awiat และก็ done อยู่ในเทสเคส แถมยังมีการใช้ thenในนี้อีกด้วย ตรงนี้ขอยกไปอธิบายในพาร์ทหน้านะครับ เพราะจะทำไว้เป็นบทความเกี่ยวกับเรื่องการเทสที่เป็น Asynchronous โดยเฉพาะเลย
คราวนี้เรามาลองรันเทสดู จะเห็นว่ามีการ FAILED ว่า NullInjectorError: No provider for HttpClient! เกิดขึ้น จริงๆแล้วมันเกิดจากเราไม่ได้ import HttpClientModule เข้าไปใน TesdBed นั่นล่ะ
แต่คราวนี้เราจะไม่แก้ปัญหาด้วยการ import HttpClientModule เข้าไปแล้ว เพราะถ้า import เข้ามาจะกลายเป็นว่า component.isPublicRepoGreaterThan(1) จะทำการ ไปเรียก TestService และเรียกใช้ฟังก์ชั่น getGithubUserDetail เพื่อยิง HTTP request ไปที่ https://api.github.com/users/kitphon จริงๆ และเราไม่สามารถการันตีได้เลยว่า วันใดวันหนึ่งถ้า github เกิดเปลี่ยนไม่ให้คนภายนอก access api เพื่อเข้าไปเอาข้อมูลจะเกิดอะไรขึ้น ใช่ครับเทสเคสเราจะ FAILED ทันทีเลย ซึ่งจริงๆแล้วการเทสฟังก์ชั่นที่ดีนั้น ผมจะอิงตาม F.I.R.S.T Principles โดยเทสเคสของเราแต่ละเคสจะจบภายในตัวเอง และผลลัพธ์การทำงานจะไม่ขึ้นกับฟังก์ชั่นอื่นใครสนใจแนะนำให้ไปไปอ่านต่อในบทความข้างล่างเลยครับ
เราจึงตัดสินใจที่จะไม่ให้เทสเคสนี้ ไปเรียก api จริงๆ เพราะฉะนั้นเพื่อที่จะให้เทสเคสนี้ทำงานได้ปกติ เราจึงต้องทำการสร้างต้องทำ Fake Service ขึ้นมาเพื่อที่จะให้เทสเคสของเราไปเรียกใช้ Fake Service แทนที่จะไปเรียก Service ของจริงแทน ซึ่งเราจะทำ Fake Service นี้ขึ้นมาด้วย Spy Object
อันดับแรกให้เรา import TestService มาจาก services/test.service และให้สร้างตัวแปรขึ้นมาชื่อ mockTestService ซึ่งเป็นตัวแปรที่มาจากการใช้ฟังก์ชั่น createSpyObj ของ jasmine สร้าง Spy Object ขึ้นมา
jasmine.createSpyObj(‘TestService’, [ ‘getGithubUserDetail’ ]);
จากโค้ดข้างบนนี้ คือเราสร้าง Spy Object สำหรับจำลอง TestService ขึ้นมาโดยระบุว่า Object นี้มีฟังก์ชั่นชื่อ getGithubUserDetail อยู่ ถ้าหากใน TestService ของเรามีหลายฟังก์ชั่นก็สามารถใส่เพิ่มได้แบบนี้
jasmine.createSpyObj(‘TestService’, [ ‘function1’, ‘function2’, ‘function3’ ]);
หลังจากที่เราสร้าง Spy Object เสร็จแล้วให้เราไปเพิ่มเจ้าตัว mockTestService ให้กับ TestBed configureTestingModule ด้วย โดยจะเพิ่มตรงส่วน providers เข้าไป
providers: [
{ provide : TestService, useValue: mockTestService }
]
เราจะเห็นว่าตัว providers นี้เป็น array ที่สามารถใส่ Object ไปได้หลายๆตัว โดยตรงส่วน provide : TestService ตรง TestService นั้นคือ Service จริงๆที่ใน component หลักเราเรียกใช้ ส่วน useValue: mockTestService ตรง mockTestService นั้นให้เราใส่ตัว Spy Object หรือพวก Fake Object/Class เข้าไปแทน ง่ายๆก็เหมือนกับว่าเอา mockTestService ไปใช้แทน TestService ตอนรันเทสนั่นเอง
ส่วนในกรณีที่เราต้องการ Mock หลายๆ Service เราสามารถใส่เพิ่มเข้าไปได้แบบนี้
providers: [
{ provide : TestService, useValue: mockTestService },
{ provide : Service2, useValue: mockService2 },
{ provide : Service3, useValue: mockService3 },
]
แต่ทำไมต้องใส่เข้าไปที่ providers ล่ะ ? ให้เห็นภาพง่ายๆก็คือ พวก component หรือ service ต่างๆ ที่เราได้ inject เข้าไปทาง contruct ใน component หลักของเรา ให้เรา import ใส่ไปที่ providers ของ TesBed ในไฟล์เทสนั้นๆเสมอ
อย่างเช่นไฟล์ตรงนี้ ที่เรามีการใส่ตัวแปร private testService: TestService เข้าไปที่ constructor ของเรา เป็นต้น
แต่เดี๋ยวก่อน การ Mock Service ด้วย Spy Object มันยังไม่จบเท่านี้ ตอนนี้เราเพียงแค่เอา mockTestService ไปแทนที่ TestService ได้เท่านั้นเอง แต่เรายังไม่ได้ม็อคค่าที่จะรีเทิร์นออกมาจาก getGithubUserDetail ของ mockTestService เลย
แล้วเราจะม็อคค่าเจ้า getGithubUserDetail ที่อยู่ใน mockTestService ยังไงล่ะ ? อันดับแรกเลยเราต้องดูกันก่อนเลยว่าฟังก์ชั่น getGithubUserDetail ของจริงนั้นเวลามันรีเทิร์นมันจะมีค่าออกมาเป็นอะไร ซึ่งตอนที่เราจะม็อคค่าให้กับตัว getGithubUserDetail ที่อยู่ใน mockTestService นั้นเราจะต้องม็อคให้มันมีค่าตามความจริงที่สุด เพื่อเป็นการจำลอง Service ให้เหมือนที่สุด
ค่าที่รีเทิร์นมาจากฟังก์ชั่น getGithubUserDetail ของจริง จะเป็น Json Data หน้าตาแบบข้างล่างนี้
{
"login": "kitphon",
"id": 26889866,
"node_id": "MDQ6VXNlcjI2ODg5ODY2",
"avatar_url": "https://avatars1.githubusercontent.com/u/26889866?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/kitphon",
"html_url": "https://github.com/kitphon",
"followers_url": "https://api.github.com/users/kitphon/followers",
"following_url": "https://api.github.com/users/kitphon/following{/other_user}",
"gists_url": "https://api.github.com/users/kitphon/gists{/gist_id}",
"starred_url": "https://api.github.com/users/kitphon/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/kitphon/subscriptions",
"organizations_url": "https://api.github.com/users/kitphon/orgs",
"repos_url": "https://api.github.com/users/kitphon/repos",
"events_url": "https://api.github.com/users/kitphon/events{/privacy}",
"received_events_url": "https://api.github.com/users/kitphon/received_events",
"type": "User",
"site_admin": false,
"name": null,
"company": null,
"blog": "",
"location": null,
"email": null,
"hireable": null,
"bio": null,
"public_repos": 7,
"public_gists": 0,
"followers": 0,
"following": 0,
"created_at": "2017-04-04T03:25:46Z",
"updated_at": "2018-03-30T03:29:37Z"
}
ซึ่งในเทส เราอาจจะต้องม็อคค่าให้เจ้า getGithubUserDetail ที่อยู่ใน mockTestService รีเทิร์นค่าออกมาหน้าตาแบบข้างบนนี้
แต่จริงๆแล้ว เราไม่จำเป็นต้องม็อคให้ Object มีฟิลด์หน้าตาเหมือนตามนี้ทั้งหมดก็ได้ แค่ม็อคมาเท่าที่จะใช้ก็พอ(แต่มันก็ไม่ใช่วิธีที่ดีนักหรอกครับ 555)
แต่อย่างที่เห็น ว่าในฟังก์ชั่น isPublicRepoGreaterThan ของเรานั้นมีการเรียกใช้ตัวแปรนี้ public_repos แค่ตัวเดียวเอง เพราะฉะนั้นเราไม่จำเป็นต้องม็อคทุกฟิลด์ก็ได้ แต่ถ้าหากในวันนี้เราม็อคไว้ครบทุกตัวแล้ว เราก็จะสามารถนำตัวแปรที่ม็อคไปใช้กับเทสเคสอื่นๆได้อีก (มันก็มีข้อดีข้อเสียต่างกันไปนะ) แต่ในตอนนี้ผมขอม็อคแค่เท่าที่ใช้พอละกัน
โอเคผมสร้าง Object มา 1 ตัวชื่อ mockObject เพื่อเอาไว้ใช้สำหรับม็อคค่ารีเทิร์นจากฟังก์ชั่น getGithubUserDetail
และเพิ่มโค้ดเข้าไปอีก 3 ส่วน ไว้ตรงหลัง TestBed configureTestingModule
const TestServiceSpyObj = TestBed.get(TestService);
มาอธิบายโค้ดตรงนี้กันครับ TestBed.get() ตรงนี้จะเป็นส่วนที่เราใช้เจ้า TestBed ไปเรียก TestService ที่มีอยู่ในนั้นออกมาเพื่อที่เราจะทำการ(แทรกแซง) อะไรบางอย่างกับมันก่อนที่มันจะถูกเอาไปใช้ทำงานในเทสเคสเรา
หลังจากที่เราได้ TestServiceSpyObj สำหรับที่จะใช้แทรกแซงแล้ว เราสามารถทำการ By pass ค่ารีเทิร์นให้กับฟังก์ชั่นที่อยู่ใน Service นี้ได้
TestServiceSpyObj.getGithubUserDetail.and.returnValue(ค่าที่จะรีเทิร์น);
แต่เจ้ากรรมเอ๋ย อย่าลืมนะว่าฟังก์ชั่น getGithubUserDetail มันรีเทิร์นค่าออกมาเป็น Type Promise ซึ่งความยุ่งยากของมันอีกอย่างคือ เราไม่สามารถเอา mockObject ของเราไปใส่ใน returnValue ได้ตรงๆ เราต้องทำให้มันเป็น Promise ซะก่อน
โดยเราจะจับเจ้า mockObject มาทำให้เป็น Promise ด้วยการสร้าง Promise ขึ้นมาและใส่ Syntax จะหน้าแบบข้างล่างนี้ ให้เราเอาค่าที่จะม็อคไปใส่ที่ resolve() ได้เลย
const mockPromise = new Promise((resolve, reject) => { resolve(mockObject); });
เสร็จแล้วเราก็เอาตัวแปร mockPromise ไปใช้ในการรีเทิร์นค่าได้เลย แบบนี้
TestServiceSpyObj.getGithubUserDetail.and.returnValue(mockPromise);
หลังจากที่เราทำการม็อคทุกอย่างเสร็จแล้ว เราจะมีไฟล์เทสหน้าตาประมาณนี้
เมื่อลองรันเทส จะเห็นว่ารันผ่านทุกเทสเคสแล้ว
จะเห็นว่าเทสเคสที่เพิ่มเข้าไปครั้งนี้ มีทั้งการใช้ async, awiat และก็ done อยู่ในเทสเคส แถมยังมีการใช้ thenในนี้อีกด้วย
โอเคครับ เอาเป็นว่าสำหรับพาร์ทนี้ขอจบไปแค่ตรงนี้ครับ ในครั้งหน้าเนี่ย ผมจะทำเป็นบทความเกี่ยวกับเรื่องการเทสที่เป็น Asynchronous ครับ