ทำ Automated Test ก็ดีนะ แต่…

“เขียนยังไงล่ะ???”

Wattanai Thangsrirojkul
Thinc.
10 min readFeb 22, 2019

--

สำหรับ Developer หลายๆ คน ไม่ว่าจะเป็น Frontend, Backend, Mobile ก็ตาม ทุกคนคงได้ยินสรรพคุณต่างๆของการเขียน Tests มากันอยู่แล้วว่ามีข้อดีต่างๆ มากมาย หลายๆคนก็อยากที่จะเริ่มเขียน Tests กันมากๆ แต่ติดตรงที่ว่า เขียนยังไงล่ะ?

ผมเป็น Dev คนนึงที่อยากจะลองเขียน Test มาระยะเวลาหนึ่งเลย แต่กว่าจะได้เริ่มเขียนเป็นนั้นก็ใช้เวลาที่ค่อนข้างเยอะเลยทีเดียว 5555 สาเหตุหนึ่งคือ resource ต่างๆที่เกี่ยวกับการเขียน Test ตามออนไลน์นั้นมักจะไม่ตอบข้อสงสัยในการเขียน Test ในเรื่องต่างๆ เช่น Test โค้ดที่เชื่อมกับ Database ยังไง หรือพวกโค้ดที่มีการเรียก Service อื่นๆ

และโดยส่วนใหญ่จะ Resource ในออนไลน์มักจะมาในรูปแบบนี้คือ

“มาทดสอบการบวกเลขกันดีกว่า”

describe('calculator', () => {
test('one plus one should be two', () =>
const calculator = new Calculator();
const result = calculator.add(3, 5);
expect(result).toEqual(8);
});
})

***หมายเหตุ: จะลง Code ไว้สอง snippet นะครับ อันแรกไว้ดูว่ามี bold เน้นส่วนตรงไหน และอันที่สองจะเป็น gist เพื่อให้มี syntax hilighting ที่อ่านง่ายครับ***

“เสร็จแล้ว เย้ๆ เขียน Test ง่ายมากเลย”

พอกลับไปลองมอง Code ในงานของตัวเอง

import { CreditCardService } from 'some-payment-platform';
import Customer from './models/Customer'
import ShoppingItem from './models/ShoppingItem'
...router.post('/customer/:customerId/checkout', async (req, res) => {
const { customerId } = req.params;
const customer = await Customer.findOne(customerId);
if (customer === null) {
throw new Error('Customer not found');
}
const shoppingItems = await ShoppingItem.findMany({ customerId });
const itemIds = shoppingItems.map(item => item.itemId);
const stockItems = await StockItem.findMany({ $in: itemIds });
const boughtItemIds: string[] = [];
let totalPrice = 0;
shoppingItems.forEach((item, idx) => {
if (stockItems[idx].quantity === 0) {
return;
}
boughtItemIds.push(item.itemId);
const { price } = item;
if (customer.memberType === 'VIP') {
totalPrice += price * 0.8;
} else if (customer.memberType === 'CHILDREN') {
if (item.type === 'TOYS') {
totalPrice += price * 0.5;
} else {
totalPrice += price;
}
}
});
await StockItem.update(
{ $in: boughtItemIds },
{ quantity: { $inc: -1 } }
);
if (totalPrice > 0) {
await CreditCardService.charge({
userId: customerId,
amount: totalPrice,
});
}
res.json({ totalPrice, boughtItemIds, customer });
});
...

“…”

“เขียน Test ไงอะ?”

เริ่มจากใน Code นี้มีอะไรบ้าง

  1. ดึงข้อมูลจาก request params ของ Express
  2. หาข้อมูลใน model จาก database ทั้งข้อมูล Customer, ShoppingItems, StockItems
  3. คำนวณราคาทั้งหมดสรุปรวม โดยมีเงื่อนไขต่างๆ ตามประเภทลูกค้า และสินค้า
  4. สร้าง array ของ id สินค้าที่มีเหลืออยู่ใน Stock และได้ทำการซื้อ
  5. อัพเดทข้อมูล model StockItem เพื่อตัด stock ลง 1 หน่วย ใน database
  6. เรียก CreditCardService เพื่อทำการ charge ค่าใช้จ่ายทั้งหมดของลูกค้า
  7. ส่ง Express JSON response กลับ

Code นี้ถ้าจะเขียน Test ก็จะเหมาะกับเป็น E2E เพราะ Code นี้ทำหลายๆ อย่างมาก ถ้าหากจะเขียน Unit Test ก็มีความยากสูงเนื่องจากปัจจัยหลายๆ อย่าง ดังนี้

  1. Code นี้ ทำหลายๆ อย่างมากเกินไป เช่น จัดการกับ Express, เรียก DB, คำนวณ Logic, และเรียก Service Third-party ที่ต้อง setup หลายๆอย่างมาก ในการที่จะเขียน Test สำหรับ Code นี้ ทั้ง Database, Third Party Service และ Express โดยทั้งหมดนี้เป็นเรื่องปัญหาของ Cohesion
  2. การเรียกโดยตรงบน method ต่างๆ ไม่ว่าจะเป็น Model class (ShoppingItem, StockItem ที่เป็น static method ด้วย) และเรียก CreditCardService โดยตรง (ซึ่งตัดเงินจริงด้วย) ทำให้เขียน Test ได้ลำบาก ซึ่งทั้งหมดนี้เป็นปัญหาเรื่องของ Dependency หรือ Coupling

เรามาเริ่มเขียน Unit Test ของ Code นี้กันดีกว่า

  1. ดึงส่วนที่สำคัญที่สุด ที่เราอยากจะทดสอบออกมาก่อน และเป็นส่วนที่ Test ง่ายที่สุดด้วย นั่นก็คือ ส่วน Business Logic นั่นเอง
const boughtItemIds: string[] = [];  let totalPrice = 0;
shoppingItems.forEach((item, idx) => {
if (stockItems[idx].quantity === 0) {
return;
}
boughtItemIds.push(item.itemId);
const { price } = item;
if (customer.memberType === 'VIP') {
totalPrice += price * 0.8;
} else if (customer.memberType === 'CHILDREN') {
if (item.type === 'TOYS') {
totalPrice += price * 0.5;
} else {
totalPrice += price;
}
}
});

ทำให้มันเป็น function ซะก่อน

...function calculateSomething() {
const boughtItemIds: string[] = [];
let totalPrice = 0;
shoppingItems.forEach((item, idx) => {
if (stockItems[idx].quantity === 0) {
return;
}
boughtItemIds.push(item.itemId);
const { price } = item;
if (customer.memberType === 'VIP') {
totalPrice += price * 0.8;
} else if (customer.memberType === 'CHILDREN') {
if (item.type === 'TOYS') {
totalPrice += price * 0.5;
} else {
totalPrice += price;
}
}
});
return { boughtItemIds, totalPrice };
}
const { boughItemIds, totalPrice } = calculateSomething();
...

แล้วก็ย้ายออกมาด้านนอกเป็น function แยก และนำข้อมูลต่างๆ ที่ต้องการใช้ มาเป็น arguments ให้หมด (โดยใช้ compiler หรือ linter ช่วยบอกว่ามีอะไรที่ขาดบ้าง)

...export function calculateSomething(
shoppingItems: ShoppingItem[],
stockItems: StockItem[],
customer: Customer,

) {
let totalPrice = 0;
const boughtItemIds: string[] = [];
shoppingItems.forEach((item, idx) => {
if (stockItems[idx].quantity === 0) {
return;
}
boughtItemIds.push(item.itemId);
const { price } = item;
if (customer.memberType === 'VIP') {
totalPrice += price * 0.8;
} else if (customer.memberType === 'CHILDREN') {
if (item.type === 'TOYS') {
totalPrice += price * 0.5;
} else {
totalPrice += price;
}
}
});
return { totalPrice, boughtItemIds };
}
router.post('/customer/:customerId/checkout', async (req, res) => {
const { customerId } = req.params;
const customer = await Customer.findOne(customerId);
if (customer === null) {
throw new Error('Customer not found');
}
const shoppingItems = await ShoppingItem.findMany({ customerId });
const itemIds = shoppingItems.map(item => item.itemId);
const stockItems = await StockItem.findMany({ $in: itemIds });
const { totalPrice, boughtItemIds } = calculateSomething(
shoppingItems,
stockItems,
customer,
);

await StockItem.update(
{ $in: boughtItemIds },
{ quantity: { $inc: -1 } }
);
if (totalPrice > 0) {
await CreditCardService.charge({
userId: customerId,
amount: totalPrice,
});
}
res.json({ totalPrice, boughtItemIds, customer });
});
...

สังเกตว่าในตอนนี้ function ที่เราแยกออกมานั้น จะไม่มีการ query database หรือ side effect อะไรเลย ซึ่งทุกอย่างที่ต้องการจะถูกโยนเข้ามาเป็น arguments ทั้งหมด ทำให้ทั้งหมดนี้สามารถเขียน Unit Test ได้อย่างง่ายดาย

ทีนี้เราก็เขียน Test ให้กับ calculateSomething ได้แล้ว

describe('calculateSomething', () => {
test('children case', () => {
const shoppingItems: ShoppingItem[] = [
{
itemId: 'itemId1',
type: 'TOYS',
price: 30,
},
{
itemId: 'itemId2',
type: 'BOOKS',
price: 50,
},
];
const stockItems: StockItem[] = [
{
quantity: 50,
},
{
quantity: 30,
},
];
const customer: Customer = {
memberType: 'CHILDREN',
};
const result = calculateSomething(
shoppingItems,
stockItems,
customer
);
expect(result).toEqual({
totalPrice: 30 / 2 + 50,
boughtItemIds: ['itemId1', 'itemId2'],
});
});
});

ตอนนี้เราก็ได้ Code ที่มี Unit Test มา cover ส่วน logic ของเราแล้ว ซึ่งทำให้ตอนนี้เราก็สามารถสบายใจได้แล้วว่า bug ที่เกิดขึ้นในส่วนของ logic นั้น จะถูกตรวจสอบได้โดย Test ของเรา

ซึ่งถ้าหากว่า Test ส่วนนี้ของเรารัน Passed แต่ว่า ระบบโดยรวมเกิด Bug ขึ้น ก็มีโอกาสสูงมากๆ ที่ว่า Bug จะเกิดขึ้นในส่วนอื่นๆ ของ code นี้มากกว่า เช่น การดึงข้อมูลจาก DB ที่เขียน query ผิด หรือ ส่วน Third-party service ที่มีปัญหา ทำให้เราสามารถระบุตำแหน่งของ Bug ได้ง่ายขึ้น

หลังจากที่เราเขียน Test Logic ของ calculateSomething จนพอใจแล้ว เราก็มาทำส่วนอื่นๆ ต่อ ซึ่งก็คือเรื่อง Dependency & Coupling ของ code เรากัน

router.post('/customer/:customerId/checkout', async (req, res) => {
const { customerId } = req.params;
const customer = await Customer.findOne(customerId);
if (customer === null) {
throw new Error('Customer not found');
}
const shoppingItems = await ShoppingItem.findMany({ customerId });
const itemIds = shoppingItems.map(item => item.itemId);
const stockItems = await StockItem.findMany({ $in: itemIds });
const { totalPrice, boughtItemIds } = calculateSomething(
shoppingItems,
stockItems,
customer,
);
await StockItem.update(
{ $in: boughtItemIds },
{ quantity: { $inc: -1 } }
);

if (totalPrice > 0) {
await CreditCardService.charge({
userId: customerId,
amount: totalPrice,
});

}
res.json({ totalPrice, boughtItemIds, customer });
});

สังเกตว่า code นี้จะเรียก static method บน class ตรงๆ รวมถึงเรียกตัว credit card service ตรงๆ ด้วยเช่นกัน ทำให้เวลาเขียน Test นั้น จะเปลี่ยนให้การทำงานของ method เหล่านั้นเป็นแบบที่สมมติขึ้นได้ยาก หรือการทำ stub นั้น ได้ยาก

ซึ่งถ้า code ยังเป็นแบบนี้อยู่ วิธีการ Test ก็จำเป็นจะต้อง Setup ทั้งตัว Database และ CreditCardService จริงๆ (ซึ่งตัดเงินจริง) และถ้าอยากจะลองหลายๆ กรณีที่เป็นไปได้ของ route function นี้นั้น ก็จะต้อง Setup Database และ Service ไว้หลายๆแบบ ตามไปด้วย ซึ่งจะกินเวลา ทำได้ลำบาก และยังทำให้ตัว Test ที่เขียนขึ้นนั้น ใช้เวลาในการ run ที่นานขึ้นด้วย (เพราะเรียก DB และ Service จริงๆ)

ดังนั้นเรามาจัดการทำให้ Code นี้สามารถเขียน Unit Test กันได้ก่อนดีกว่า

โดยขั้นตอนแรกเริ่มจากสร้าง class ใหม่ขึ้นมา (ใช้เทคนิคเดิมที่ใส่สิ่งที่จำเป็นต้องใช้มาเป็น argument ของ method) แล้วย้าย Code ส่วนที่ไม่เกี่ยวกับ express เข้าไปทั้งหมดแบบนี้

class CheckoutService {
async checkout(customerId: string) {
const customer = await Customer.findOne(customerId);
if (customer === null) {
throw new Error('Customer not found');
}
const shoppingItems = await ShoppingItem.findMany(
{ customerId }
);
const itemIds = shoppingItems.map(item => item.itemId);
const stockItems = await StockItem.findMany({ $in: itemIds });
const { totalPrice, boughtItemIds } = calculateSomething(
shoppingItems,
stockItems,
customer,
);
await StockItem.update(
{ $in: boughtItemIds },
{ quantity: { $inc: -1 } }
);
if (totalPrice > 0) {
await CreditCardService.charge({
userId: customerId,
amount: totalPrice,
});
}
}
}

ต่อมาให้ compiler หรือ linter ของเราช่วยเตือนได้โดยการ comment พวก import ต่างๆ ของ dependency ออกซะ

class CheckoutService {
async checkout(customerId: string) {
const customer = await Customer.findOne(customerId);
if (customer === null) {
throw new Error('Customer not found');
}
const shoppingItems = await ShoppingItem.findMany(
{ customerId }
);
const itemIds = shoppingItems.map(item => item.itemId);
const stockItems = await StockItem.findMany({ $in: itemIds });
const { totalPrice, boughtItemIds } = calculateSomething(
shoppingItems,
stockItems,
customer,
);
await StockItem.update(
{ $in: boughtItemIds },
{ quantity: { $inc: -1 } }
);
if (totalPrice > 0) {
await CreditCardService.charge({
userId: customerId,
amount: totalPrice,
});
}
}
}

คราวนี้เราก็จะเห็นว่า method นี้นั้นมี Dependency อยู่ตรงจุดไหนบ้าง เราก็ทำการเปลี่ยนมันให้กลายเป็น field ของ class นี้เลย

class CheckoutService {
customerRepository: any;
shoppingItemRepository: any;
stockItemRepository: any;
creditCardService: any;
async checkout(customerId: string) {
const customer = await this.customerRepository.findOne(customerId);
if (customer === null) {
throw new Error('Customer not found');
}
const shoppingItems = await this.shoppingItemRepository.findMany({
customerId,
});
const itemIds = shoppingItems.map(item => item.itemId);
const stockItems = await this.stockItemRepository.findMany({
$in: itemIds,
});
const { totalPrice, boughtItemIds } = calculateSomething(
shoppingItems,
stockItems,
customer,
);
await this.stockItemRepository.update(
{ $in: boughtItemIds },
{ quantity: { $inc: -1 } },
);
if (totalPrice > 0) {
await this.creditCardService.charge({
userId: customerId,
amount: totalPrice,
});
}
}
}

เปลี่ยน type จาก any ให้เป็น interface เพื่อทำให้ Code มี typesafety

interface ICustomerRepository {
findOne(customerId: string): Promise<Customer>;
}
interface IShoppingItemRepository {
findMany(where: any): Promise<ShoppingItem[]>;
}
interface IStockItemRepository {
findMany(where: any): Promise<StockItem[]>;
update(where: any, value: any): Promise<void>;
}
interface ICreditCardService {
charge(args: { userId: string; amount: number }): Promise<void>;
}
class CheckoutService {
customerRepository: ICustomerRepository;
shoppingItemRepository: IShoppingItemRepository;
stockItemRepository: IStockItemRepository;
creditCardService: ICreditCardService;
async checkout(customerId: string) {
const customer = await this.customerRepository.findOne(customerId);
if (customer === null) {
throw new Error('Customer not found');
}
const shoppingItems = await this.shoppingItemRepository.findMany({
customerId,
});
const itemIds = shoppingItems.map(item => item.itemId);
const stockItems = await this.stockItemRepository.findMany({
$in: itemIds,
});
const { totalPrice, boughtItemIds } = calculateSomething(
shoppingItems,
stockItems,
customer,
);
await this.stockItemRepository.update(
{ $in: boughtItemIds },
{ quantity: { $inc: -1 } },
);
if (totalPrice > 0) {
await this.creditCardService.charge({
userId: customerId,
amount: totalPrice,
});
}
}
}

ใส่ constructor ที่มี arguments เป็น dependency ต่างๆของ class นี้ให้เรียบร้อย

class CheckoutService {
customerRepository: ICustomerRepository;
shoppingItemRepository: IShoppingItemRepository;
stockItemRepository: IStockItemRepository;
creditCardService: ICreditCardService;
constructor(
customerRepository: ICustomerRepository,
shoppingItemRepository: IShoppingItemRepository,
stockItemRepository: IStockItemRepository,
creditCardService: ICreditCardService,
) {
this.customerRepository = customerRepository;
this.shoppingItemRepository = shoppingItemRepository;
this.stockItemRepository = stockItemRepository;
this.creditCardService = creditCardService;
}
async checkout(customerId: string) {
const customer = await this.customerRepository.findOne(customerId);
if (customer === null) {
throw new Error('Customer not found');
}
const shoppingItems = await this.shoppingItemRepository.findMany({
customerId,
});
const itemIds = shoppingItems.map(item => item.itemId);
const stockItems = await this.stockItemRepository.findMany({
$in: itemIds,
});
const { totalPrice, boughtItemIds } = calculateSomething(
shoppingItems,
stockItems,
customer,
);
await this.stockItemRepository.update(
{ $in: boughtItemIds },
{ quantity: { $inc: -1 } },
);
if (totalPrice > 0) {
await this.creditCardService.charge({
userId: customerId,
amount: totalPrice,
});
}
}
}

(สำหรับ Typescript จะสามารถเขียน constructor ด้านบนแบบสั้นกว่าได้แบบนี้)

class CheckoutService {
constructor(
private readonly customerRepository: ICustomerRepository,
private readonly shoppingItemRepository: IShoppingItemRepository,
private readonly stockItemRepository: IStockItemRepository,
private readonly creditCardService: ICreditCardService,
) {}
async checkout(customerId: string) {
const customer = await this.customerRepository.findOne(customerId);
if (customer === null) {
throw new Error('Customer not found');
}
const shoppingItems = await this.shoppingItemRepository.findMany({
customerId,
});
const itemIds = shoppingItems.map(item => item.itemId);
const stockItems = await this.stockItemRepository.findMany({
$in: itemIds,
});
const { totalPrice, boughtItemIds } = calculateSomething(
shoppingItems,
stockItems,
customer,
);
await this.stockItemRepository.update(
{ $in: boughtItemIds },
{ quantity: { $inc: -1 } },
);
if (totalPrice > 0) {
await this.creditCardService.charge({
userId: customerId,
amount: totalPrice,
});
}
}
}

และกลับมาแก้ที่ Code ของ route function

router.post('/customer/:customerId/checkout', async (req, res) => {
const { customerId } = req.params;
const checkoutService = new CheckoutService(
Customer,
ShoppingItem,
StockItem,
CreditCardService,
);
const {
totalPrice,
boughtItemIds,
customer,
} = await checkoutService.checkout(customerId);

res.json({ totalPrice, boughtItemIds, customer });
});

แต่ Code นี้ยังทำงานไม่ได้เนื่องจาก method checkout ของ service นี้นั้น ยังไม่ได้ return ค่าอะไรกลับมา

เราก็จัดการในส่วนนี้ให้เรียบร้อย

export class CheckoutService {...async checkout(customerId: string) {
const customer = await this.customerRepository.findOne(customerId);
if (customer === null) {
throw new Error('Customer not found');
}
const shoppingItems = await this.shoppingItemRepository.findMany({
customerId,
});
const itemIds = shoppingItems.map(item => item.itemId);
const stockItems = await this.stockItemRepository.findMany({
$in: itemIds,
});
const { totalPrice, boughtItemIds } = calculateSomething(
shoppingItems,
stockItems,
customer,
);
await this.stockItemRepository.update(
{ $in: boughtItemIds },
{ quantity: { $inc: -1 } },
);
if (totalPrice > 0) {
await this.creditCardService.charge({
userId: customerId,
amount: totalPrice,
});
}
return { totalPrice, boughtItemIds, customer };
}
}

เพียงเท่านี้เราก็จะได้ class CheckoutService ที่ loose coupling และพร้อมทำ Unit Test แล้วเรียบร้อย

ว่าแล้วก็จัดการเขียน Test ของ CheckoutService กันดีกว่า

describe('CheckoutService', () => {
test('checkout', async () => {
const customerRepository: ICustomerRepository = {
async findOne(customerId: string) {
expect(customerId).toEqual('customerId');
const customer: Customer = {
memberType: 'VIP',
};
return customer;
},
};
const shoppingItemRepository: IShoppingItemRepository = {
async findMany(where) {
expect(where).toEqual({
customerId: 'customerId',
});

const shoppingItems: ShoppingItem[] = [
{
itemId: 'itemId1',
price: 500,
type: 'TOYS',
},
];
return shoppingItems;
},
};
const stockItemRepository: IStockItemRepository = {
async findMany(where) {
expect(where).toEqual({ $in: ['itemId1'] });
const stockItems: StockItem[] = [
{
quantity: 20,
},
];
return stockItems;
},
async update(where, value) {
expect(where).toEqual({ $in: ['itemId1'] });
expect(value).toEqual({ quantity: { $inc: -1 } });

},
};
const creditCardService: ICreditCardService = {
async charge(args) {
expect(args).toEqual({
amount: 400,
userId: 'customerId',
});

},
};
const service = new CheckoutService(
customerRepository,
shoppingItemRepository,
stockItemRepository,
creditCardService,
);
const result = await service.checkout('customerId');
expect(result).toEqual({
boughtItemIds: ['itemId1'],
customer: {
memberType: 'VIP',
},
totalPrice: 400,
});

});
});

สังเกตว่าเราจะสร้าง customerRepository, shoppingItemRepository, stockItemRepository ที่เป็น stub ขึ้นมาเอง โดยให้คืนค่าตามที่เราต้องการ และใส่การ expect เข้าไป

ซึ่ง Test นี้เราจะอ่านยากพอสมควร เพราะลำดับการ expect จะแปลกเล็กน้อยที่ต้องใส่เข้าไปใน stub repository ของเรานั่นเอง

ซึ่งเราสามารถแก้ปัญหานี้ ทำให้ Test อ่านง่ายขึ้นได้โดยการใช้ Jest Mock Function

describe('CheckoutService', () => {
test('checkout', async () => {
const customerRepository: ICustomerRepository = {
findOne: jest.fn(() => {
const customer: Customer = {
memberType: 'VIP',
};
return custer;
}),
};
const shoppingItemRepository: IShoppingItemRepository = {
findMany: jest.fn(() => {
const shoppingItems: ShoppingItem[] = [
{
itemId: 'itemId1',
price: 500,
type: 'TOYS',
},
];
return shoppingItems;
}),
};
const stockItemRepository: IStockItemRepository = {
findMany: jest.fn(() => {
const stockItems: StockItem[] = [
{
quantity: 20,
},
];
return stockItems;
}),
update: jest.fn(),
};
const creditCardService: ICreditCardService = {
charge: jest.fn(),
};
const service = new CheckoutService(
customerRepository,
shoppingItemRepository,
stockItemRepository,
creditCardService,
);
const result = await service.checkout('customerId');
expect(result).toEqual({
boughtItemIds: ['itemId1'],
customer: {
memberType: 'VIP',
},
totalPrice: 400,
});
expect(customerRepository.findOne).toBeCalledWith('customerId');
expect(shoppingItemRepository.findMany).toBeCalledWith({
customerId: 'customerId',
});
expect(stockItemRepository.findMany).toBeCalledWith({
$in: ['itemId1'],
});
expect(stockItemRepository.update).toBeCalledWith(
{ $in: ['itemId1'] },
{
quantity: { $inc: -1 },
},
);
expect(creditCardService.charge).toBeCalledWith({
amount: 400,
userId: 'customerId',
});

});
});

สังเกตว่าการเขียน Unit Test แบบนี้ จะเขียนเยอะกว่า และซับซ้อนกว่า Unit Test แบบแรกที่ไว้ทดสอบ function เดี่ยวๆ

ถ้าหากแอพของเรานั้นมี scenario หลายๆ แบบที่ควรจะทดสอบ และถ้าเราพึ่งวิธีนี้ในการเขียน Test เพียงอย่างเดียว จะทำให้เราต้องเขียน Test เยอะมากๆ ในขณะที่ Test ด้วย function เดี่ยวๆนั้น ทำได้ง่ายกว่า เร็วกว่า จึงทำให้เขียนได้ครอบคลุมง่ายกว่า (ในส่วนที่เราคิดว่าสำคัญ)

ตอนนี้ก็เป็นอันเสร็จเรียบร้อยในการเขียน unit test ของ การ checkout สินค้านี้ ทั้งในแบบ unit test กับ function เดี่ยวๆ ที่ทำง่ายและรวดเร็ว และแบบ test กับ class service ที่จำเป็นต้องพึ่งพาการใช้ stub และ mock ต่างๆ เข้ามาช่วยในการ test ซึ่งจะซับซ้อนกว่าในระดับหนึ่ง ทั้งนี้จำเป็นต้องจัดการเรื่อง dependency ต่างๆ ของ code ให้สามารถที่จะเปลี่ยน dependency ได้ในระหว่าง test อย่างที่ได้ทำมาแล้วข้างบน

“แต่แบบนี้พวก database กับ service ก็ไม่ได้ถูกเรียกจริงๆ สิ?”

ถูกต้องครับ เพราะว่า เราได้แยกส่วนของการทำงานของ checkout ออกเป็นส่วนๆ แยกกัน ส่วนแรกก็คือส่วน logic ที่เป็น code ที่ทำการคำนวณและทำงาน โดยที่ไม่มี dependency บน repo หรือ service ต่างๆ ส่วนที่สองก็คือส่วน service class ที่เป็นการนำส่วน function logic มาประกอบกับ repository ต่างๆ รวมถึงเชื่อมกับ service อื่นๆ ด้วย

ซึ่งหน้าที่หลักของ logic และ service class นั้น ก็ได้ถูกทดสอบด้วยการเขียน Test แบบด้านบนนี้แล้ว และในส่วนของ database นั้น เราจะไปเขียนในส่วนของ Integration test ของ repository ที่ทำการ hit database จริงๆ อีกที และท้ายที่สุด Test ที่จะทดสอบว่า route นี้สามารถใช้งานได้ตามที่เราคิดจริงๆ หรือก็คือ End-to-end Test หรือ E2E Test นั่นเอง

“แล้ว Integration Test และ E2E Test เขียนยังไงล่ะ?”

ติดตามต่อได้ใน Part2 นะครับ :)

--

--