Topic 42 Property-Based Testing (การทดสอบเชิงคุณสมบัติ)

Доверяй , но проверяй      (ไว้ใจ แต่ต้องตรวจสอบ)

Russian proverb

เราแนะนำให้คุณเขียน unit test สำหรับ function ของคุณครับ วิธีทำก็คือคิดถึงสิ่งทั่ว ๆ ไปที่อาจจะเป็นปัญหา โดยอาศัยความรู้ที่คุณมีเกี่ยวกับสิ่งที่คุณกำลัง test อยู่

แต่มันก็มีปัญหาเล็ก ๆ ที่อาจสำคัญไม่น้อยแอบซ่อนอยู่ในย่อหน้านั้น ถ้าคุณเป็นคนเขียนทั้ง code ต้นฉบับและ test เอง เป็นไปได้ไหมที่ข้อสันนิษฐานที่ผิดจะถูกแฝงอยู่ในทั้งสองฝั่ง? code ผ่าน test ก็เพราะมันทำงานตามที่คุณเข้าใจเท่านั้นเอง

วิธีหนึ่งที่จะเลี่ยงปัญหานี้คือให้คนละคนกันเขียน test กับ code ที่ถูก test แต่เราไม่ค่อยชอบวิธีนี้เท่าไหร่ครับ อย่างที่เราพูดไว้ใน Topic 41, ​ Test to Code ​ หนึ่งในประโยชน์ที่ยิ่งใหญ่ที่สุดของการคิดเรื่อง test ก็คือมันช่วยชี้นำ code ที่คุณเขียนขึ้นมา คุณจะเสียสิ่งนี้ไปทันทีถ้าแยกงาน test ออกจากงานเขียน code

แต่เรากลับชอบอีกทางเลือกหนึ่งมากกว่าครับ นั่นคือให้คอมพิวเตอร์ ซึ่งไม่มีอคติหรือข้อสันนิษฐานล่วงหน้าแบบคุณ เป็นคนช่วย test ให้บ้าง

Contracts, Invariants, and Properties (Contract, Invariant และ Property)

ใน Topic 23, ​ Design by Contract ​ เราพูดถึงแนวคิดที่ว่า code มี contract ที่มันต้องทำตาม: คุณทำตามเงื่อนไขตอนป้อน input ให้มัน แล้วมันก็จะรับประกันบางอย่างเกี่ยวกับ output ที่มันสร้างออกมา

นอกจากนี้ยังมี invariant ของ code ด้วย ซึ่งก็คือสิ่งที่ยังคงเป็นจริงอยู่เสมอเกี่ยวกับ state บางอย่าง เมื่อมันถูกส่งผ่านเข้าไปใน function ยกตัวอย่างเช่น ถ้าคุณ sort list หนึ่ง ผลลัพธ์ที่ได้จะมีจำนวน element เท่ากับต้นฉบับเสมอ—ความยาวเป็น invariant นั่นเอง

เมื่อเราหา contract และ invariant ของเราได้แล้ว (ซึ่งเราจะรวมเรียกมันว่า property ) เราก็สามารถใช้มัน automate การ test ของเราได้ สิ่งที่เราทำอยู่นี้เรียกว่า property-based testing นั่นเองครับ

Tip 71 ใช้ Property-Based Test เพื่อตรวจสอบข้อสันนิษฐานของคุณ

ยกตัวอย่างง่าย ๆ ที่สร้างขึ้นมาเอง เราสามารถสร้าง test สำหรับ list ที่ถูก sort แล้วได้ครับ เราได้กำหนด property หนึ่งไปแล้ว นั่นคือ list ที่ถูก sort แล้วต้องมีขนาดเท่ากับต้นฉบับ เรายังบอกได้อีกว่า ไม่มี element ไหนใน list ผลลัพธ์ที่จะมีค่ามากกว่า element ที่ตามหลังมันได้

ตอนนี้เราสามารถเขียนมันออกมาเป็น code ได้แล้ว ภาษาส่วนใหญ่มี property-based testing framework ของตัวเองครับ ตัวอย่างนี้เขียนด้วย Python และใช้เครื่องมือ Hypothesis คู่กับ pytest แต่หลักการโดยรวมนั้นใช้ได้กับทุกภาษา

นี่คือ source code เต็ม ๆ ของ test ครับ:

proptest/sort.py

​ ​ from ​ hypothesis ​ import ​ given
​ ​ import ​ hypothesis.strategies ​ as ​ some
​
​ ​ @given ​(some.lists(some.integers()))
​ ​ def ​ test_list_size_is_invariant_across_sorting(a_list):
​ original_length = len(a_list)
​ a_list.sort()
​ ​ assert ​ len(a_list) == original_length
​
​ ​ @given ​(some.lists(some.text()))
​ ​ def ​ test_sorted_result_is_ordered(a_list):
​ a_list.sort()
​ ​ for ​ i ​ in ​ range(len(a_list) - 1):
​ ​ assert ​ a_list[i] <= a_list[i + 1]

นี่คือสิ่งที่เกิดขึ้นเมื่อเรารันมันครับ:

​ ​ $ ​​ pytest ​​ ​​ sort.py ​
​ ======================= test session starts ========================
​ ​ ... ​
​ plugins: hypothesis-4.14.0
​
​ sort.py .. [100%]
​
​ ===================== 2 passed in 0.95 seconds =====================

ดูไม่มีอะไรน่าตื่นเต้นเลยใช่ไหมครับ แต่เบื้องหลังการทำงานนั้น Hypothesis รัน test ทั้งสองตัวของเราถึงหนึ่งร้อยครั้ง โดยป้อน list ที่แตกต่างกันในทุก ๆ ครั้ง list เหล่านั้นจะมีความยาวและเนื้อหาที่แตกต่างกันไป เหมือนกับว่าเราสร้าง test แยกกันถึง 200 ตัว โดยใช้ list สุ่มถึง 200 list เลยทีเดียว

Test Data Generation (การสร้างข้อมูลสำหรับ Test)

เช่นเดียวกับ property-based testing library ส่วนใหญ่ Hypothesis มี minilanguage ให้คุณใช้อธิบายข้อมูลที่มันควรจะสร้างขึ้นมา ภาษานี้อิงอยู่กับการเรียก function ต่าง ๆ ใน module hypothesis.strategies ซึ่งเราตั้งชื่อ alias ให้มันว่า some เพราะมันอ่านแล้วลื่นกว่าครับ

ถ้าเราเขียนแบบนี้:

​ ​ @given ​(some.integers())

test function ของเราจะถูกรันหลายครั้ง แต่ละครั้งจะได้รับ integer ที่ต่างกันไป แต่ถ้าเราเขียนแบบนี้แทน:

​ ​ @given ​(some.integers(min_value=5, max_value=10).map(​ lambda ​ x: x * 2))

เราก็จะได้เลขคู่ระหว่าง 10 ถึง 20

คุณยัง compose type เข้าด้วยกันได้เหมือนกัน ดังนั้น

​ ​ @given ​(some.lists(some.integers(min_value=1), max_size=100))

จะได้ list ของจำนวนธรรมชาติ ที่มีความยาวไม่เกิน 100 element

บทนี้ไม่ได้ตั้งใจจะเป็น tutorial ของ framework ตัวไหนโดยเฉพาะ ดังนั้นเราจะข้ามรายละเอียดเจ๋ง ๆ อีกหลายอย่างไป แล้วไปดูตัวอย่างจากโลกจริงกันดีกว่าครับ

Finding Bad Assumptions (การหาข้อสันนิษฐานที่ผิดพลาด)

สมมติว่าเรากำลังเขียนระบบประมวลผล order และควบคุม stock แบบง่าย ๆ (เพราะโลกนี้ยังมีที่ว่างให้ระบบแบบนี้อีกระบบเสมอ) มันจำลอง stock level ด้วย object Warehouse เราสามารถ query warehouse เพื่อดูว่ามีของอยู่ใน stock หรือเปล่า นำของออกจาก stock และดู stock level ปัจจุบันได้

นี่คือ code ครับ:

proptest/stock.py

​ ​ class ​ Warehouse:
​ ​ def ​ init(self, stock):
​ self.stock = stock
​
​ ​ def ​ in_stock(self, item_name):
​ ​ return ​ (item_name ​ in ​ self.stock) ​ and ​ (self.stock[item_name] > 0)
​
​ ​ def ​ take_from_stock(self, item_name, quantity):
​ ​ if ​ quantity <= self.stock[item_name]:
​ self.stock[item_name] -= quantity
​ ​ else ​:
​ ​ raise ​ Exception(​ "Oversold {}" ​.format(item_name))
​
​ ​ def ​ stock_count(self, item_name):
​ ​ return ​ self.stock[item_name]

เราเขียน unit test พื้นฐานขึ้นมา ซึ่งก็ผ่านครับ:

proptest/stock.py

​ ​ def ​ test_warehouse():
​ wh = Warehouse({​ "shoes" ​: 10, ​ "hats" ​: 2, ​ "umbrellas" ​: 0})
​ ​ assert ​ wh.in_stock(​ "shoes" ​)
​ ​ assert ​ wh.in_stock(​ "hats" ​)
​ ​ assert ​ ​ not ​ wh.in_stock(​ "umbrellas" ​)
​
​ wh.take_from_stock(​ "shoes" ​, 2)
​ ​ assert ​ wh.in_stock(​ "shoes" ​)
​
​ wh.take_from_stock(​ "hats" ​, 2)
​ ​ assert ​ ​ not ​ wh.in_stock(​ "hats" ​)

จากนั้นเราเขียน function ที่ประมวลผล request สำหรับ order สินค้าจาก warehouse มันจะคืนค่าเป็น tuple ที่ element แรกเป็น "ok" หรือ "not available" ตามด้วย item และจำนวนที่ต้องการ เราเขียน test ให้มันด้วย และมันก็ผ่านครับ:

proptest/stock.py

​ ​ def ​ order(warehouse, item, quantity):
​ ​ if ​ warehouse.in_stock(item):
​ warehouse.take_from_stock(item, quantity)
​ ​ return ​ ( ​ "ok" ​, item, quantity )
​ ​ else ​:
​ ​ return ​ ( ​ "not available" ​, item, quantity )

proptest/stock.py

​ ​ def ​ test_order_in_stock():
​ wh = Warehouse({​ "shoes" ​: 10, ​ "hats" ​: 2, ​ "umbrellas" ​: 0})
​ status, item, quantity = order(wh, ​ "hats" ​, 1)
​ ​ assert ​ status == ​ "ok" ​
​ ​ assert ​ item == ​ "hats" ​
​ ​ assert ​ quantity == 1
​ ​ assert ​ wh.stock_count(​ "hats" ​) == 1
​
​ ​ def ​ test_order_not_in_stock():
​ wh = Warehouse({​ "shoes" ​: 10, ​ "hats" ​: 2, ​ "umbrellas" ​: 0})
​ status, item, quantity = order(wh, ​ "umbrellas" ​, 1)
​ ​ assert ​ status == ​ "not available" ​
​ ​ assert ​ item == ​ "umbrellas" ​
​ ​ assert ​ quantity == 1
​ ​ assert ​ wh.stock_count(​ "umbrellas" ​) == 0
​
​ ​ def ​ test_order_unknown_item():
​ wh = Warehouse({​ "shoes" ​: 10, ​ "hats" ​: 2, ​ "umbrellas" ​: 0})
​ status, item, quantity = order(wh, ​ "bagel" ​, 1)
​ ​ assert ​ status == ​ "not available" ​
​ ​ assert ​ item == ​ "bagel" ​
​ ​ assert ​ quantity == 1

ดูผิวเผินแล้วทุกอย่างดูโอเคดีครับ แต่ก่อนที่เราจะ ship code นี้ออกไป มาเพิ่ม property test กันสักหน่อยดีกว่า

สิ่งหนึ่งที่เรารู้แน่ ๆ ก็คือ stock จะปรากฏขึ้นมาเองหรือหายไปเฉย ๆ ระหว่างการทำ transaction ไม่ได้ นั่นหมายความว่า ถ้าเรานำ item บางอย่างออกจาก warehouse จำนวนที่เรานำออกไป บวกกับจำนวนที่เหลืออยู่ใน warehouse ตอนนี้ ควรจะเท่ากับจำนวนเดิมที่มีอยู่ใน warehouse ตั้งแต่แรก ใน test ต่อไปนี้ เรารัน test โดยสุ่มเลือก parameter item จาก "hat" หรือ "shoe" และสุ่ม quantity ตั้งแต่ 1 ถึง 4:

proptest/stock.py

​ ​ @given ​(item = some.sampled_from([​ "shoes" ​, ​ "hats" ​]),
​ quantity = some.integers(min_value=1, max_value=4))
​
​ ​ def ​ test_stock_level_plus_quantity_equals_original_stock_level(item, quantity):
​ wh = Warehouse({​ "shoes" ​: 10, ​ "hats" ​: 2, ​ "umbrellas" ​: 0})
​ initial_stock_level = wh.stock_count(item)
​ (status, item, quantity) = order(wh, item, quantity)
​ ​ if ​ status == ​ "ok" ​:
​ ​ assert ​ wh.stock_count(item) + quantity == initial_stock_level

มารันมันดูกันครับ:

​ ​ $ ​​ pytest ​​ ​​ stock.py ​
​ . . .
​ stock.py:72:
​
​ stock.py:76: in test_stock_level_plus_quantity_equals_original_stock_level
​ (status, item, quantity) = order(wh, item, quantity)
​ stock.py:40: in order
​ warehouse.take_from_stock(item, quantity)
​
​
​ self = <stock.Warehouse object at 0x10cf97cf8>, item_name = 'hats'
​ quantity = 3
​
​ def take_from_stock(self, item_name, quantity):
​ if quantity <= self.stock[item_name]:
​ self.stock[item_name] -= quantity
​ else:
​ ​ > ​​ ​​ raise ​​ ​​ Exception( ​​ "Oversold {}" ​​ .format(item_name)) ​
​ E Exception: Oversold hats
​
​ stock.py:16: Exception
​ ---------------------------- Hypothesis ----------------------------
​ Falsifying example:
​ test_stock_level_plus_quantity_equals_original_stock_level(
​ item='hats', quantity=3)

มัน error ที่ warehouse.take_from_stock : เราพยายามนำ hat ออกจาก warehouse สามชิ้น แต่ใน stock มีอยู่แค่สองชิ้นเท่านั้น

property test ของเราเจอข้อสันนิษฐานที่ผิดพลาด: function in_stock ของเราตรวจสอบแค่ว่ามี item นั้นอยู่ใน stock อย่างน้อยหนึ่งชิ้นเท่านั้น แต่ที่จริงเราต้องตรวจสอบว่ามีเพียงพอสำหรับเติมเต็ม order ต่างหาก:

proptest/stock1.py

​ ​ def ​ in_stock(self, item_name, quantity):
» ​ return ​ (item_name ​ in ​ self.stock) ​ and ​ (self.stock[item_name] >= quantity)

และเราก็เปลี่ยน function order ด้วยเช่นกัน:

proptest/stock1.py

​ ​ def ​ order(warehouse, item, quantity):
» ​ if ​ warehouse.in_stock(item, quantity):
​ warehouse.take_from_stock(item, quantity)
​ ​ return ​ ( ​ "ok" ​, item, quantity )
​ ​ else ​:
​ ​ return ​ ( ​ "not available" ​, item, quantity )

และตอนนี้ property test ของเราก็ผ่านแล้วครับ

Property-Based Tests Often Surprise You (Property-Based Test มักจะสร้างความประหลาดใจให้คุณ)

ในตัวอย่างก่อนหน้านี้ เราใช้ property-based test เพื่อตรวจสอบว่า stock level ถูกปรับให้ถูกต้อง test ตัวนี้เจอบั๊กจริง แต่ไม่ใช่บั๊กที่เกี่ยวกับการปรับ stock level เลย มันกลับไปเจอบั๊กใน function in_stock ของเราแทนครับ

นี่คือทั้งจุดแข็งและความน่าหงุดหงิดของ property-based testing มันทรงพลังเพราะคุณแค่กำหนดกฎสำหรับสร้าง input ตั้ง assertion สำหรับตรวจสอบ output แล้วก็ปล่อยให้มันรันไปเลย คุณไม่มีทางรู้แน่ชัดว่าจะเกิดอะไรขึ้น test อาจจะผ่าน assertion อาจจะ fail หรือ code อาจจะพังไปเลยเพราะมันรับมือกับ input ที่ได้รับไม่ไหว

ส่วนความน่าหงุดหงิดก็คือ บางทีมันก็ยากที่จะระบุให้ชัดว่าอะไรที่ fail ไปกันแน่ครับ

ข้อแนะนำของเราคือ เวลา property-based test ตัวไหน fail ให้หาให้เจอว่ามัน pass parameter อะไรเข้าไปใน test function แล้วเอาค่าเหล่านั้นมาสร้าง unit test ธรรมดา ๆ แยกต่างหากขึ้นมา unit test นั้นจะช่วยคุณสองอย่างครับ อย่างแรก มันช่วยให้คุณโฟกัสกับปัญหาได้โดยไม่ต้องมีการเรียก code เพิ่มเติมจาก property-based testing framework มากวนใจ อย่างที่สอง unit test ตัวนั้นทำหน้าที่เป็น regression test เพราะ property-based test สร้างค่าแบบสุ่มมาป้อนให้ test ของคุณ จึงไม่มีการรับประกันว่าค่าเดิมจะถูกใช้อีกครั้งในการรันครั้งถัดไป การมี unit test ที่บังคับให้ใช้ค่าเหล่านั้นซ้ำ จะช่วยให้มั่นใจได้ว่าบั๊กนี้จะไม่หลุดรอดไปอีก

Property-Based Tests Also Help Your Design (Property-Based Test ยังช่วยเรื่องการออกแบบของคุณด้วย)

ตอนที่เราพูดถึง unit testing เราบอกว่าประโยชน์หลักอย่างหนึ่งของมันคือวิธีที่มันทำให้คุณคิดเกี่ยวกับ code ของคุณ: unit test ก็คือ client ตัวแรกของ API คุณนั่นเองครับ

property-based test ก็เป็นแบบเดียวกัน แต่ในมุมที่ต่างออกไปนิดหน่อย มันทำให้คุณคิดถึง code ของคุณในแง่ของ invariant และ contract คุณจะคิดถึงสิ่งที่ต้องไม่เปลี่ยนแปลง และสิ่งที่ต้องเป็นจริงเสมอ insight เพิ่มเติมแบบนี้ส่งผลราวกับเวทมนตร์ต่อ code ของคุณ มันช่วยกำจัด edge case และชี้ให้เห็น function ที่ปล่อยให้ data อยู่ในสถานะที่ไม่สอดคล้องกัน

เราเชื่อว่า property-based testing เป็นส่วนเสริมของ unit testing ครับ ทั้งสองแก้ปัญหาคนละแบบกัน และต่างก็มีประโยชน์ของตัวเอง ถ้าคุณยังไม่ได้ใช้มัน ลองเอาไปใช้ดูสักตั้งนะครับ

Related Sections Include (หัวข้อที่เกี่ยวข้อง)

  • Topic 23, ​ Design by Contract ​
  • Topic 25, ​ Assertive Programming ​
  • Topic 45, ​ The Requirements Pit ​

Exercises (แบบฝึกหัด)

Exercise 31 ( คำตอบที่เป็นไปได้ )

ลองย้อนกลับไปดูตัวอย่าง warehouse อีกครั้งครับ มี property อื่น ๆ อีกไหมที่คุณสามารถ test ได้บ้าง?

Exercise 32 ( คำตอบที่เป็นไปได้ )

บริษัทของคุณ ship เครื่องจักรออกไป เครื่องจักรแต่ละเครื่องมาในลัง (crate) และลังแต่ละใบเป็นทรงสี่เหลี่ยมผืนผ้า ลังมีขนาดแตกต่างกันไป งานของคุณคือเขียน code เพื่อจัดวางลังให้ได้มากที่สุดเท่าที่จะเป็นไปได้ในชั้นเดียว ให้พอดีกับรถบรรทุกที่ใช้ส่งของ ผลลัพธ์ของ code คุณคือ list ของลังทั้งหมด สำหรับลังแต่ละใบ list จะบอกตำแหน่งในรถบรรทุก พร้อมทั้งความกว้างและความสูง มี property อะไรบ้างของผลลัพธ์ที่คุณสามารถ test ได้?

Challenges (แบบฝึกหัดชวนคิด)

ลองคิดถึง code ที่คุณกำลังทำอยู่ตอนนี้ครับ อะไรคือ property ของมัน—contract และ invariant ต่าง ๆ? คุณสามารถใช้ property-based testing framework มาช่วยตรวจสอบสิ่งเหล่านี้โดยอัตโนมัติได้ไหม?