เรียนรู้การทดสอบเชิงคุณสมบัติด้วย Hypothesis ใน Python ที่จะช่วยให้คุณก้าวข้ามการทดสอบแบบเดิมๆ เพื่อค้นหา edge case และสร้างซอฟต์แวร์ที่แข็งแกร่งและเชื่อถือได้ยิ่งขึ้น
ก้าวข้าม Unit Tests: เจาะลึกการทดสอบเชิงคุณสมบัติด้วย Hypothesis ใน Python
ในโลกของการพัฒนาซอฟต์แวร์ การทดสอบคือรากฐานของคุณภาพ ตลอดหลายทศวรรษที่ผ่านมา กระบวนทัศน์หลักคือ การทดสอบแบบอิงตามตัวอย่าง (example-based testing) เราสร้างอินพุตอย่างพิถีพิถัน กำหนดผลลัพธ์ที่คาดหวัง และเขียน assertions เพื่อตรวจสอบว่าโค้ดของเราทำงานตามที่วางแผนไว้ แนวทางนี้ ซึ่งพบได้ในเฟรมเวิร์กอย่าง unittest และ pytest นั้นทรงพลังและจำเป็นอย่างยิ่ง แต่ถ้าผมบอกคุณว่ามีแนวทางเสริมที่สามารถค้นหาบั๊กที่คุณไม่เคยคิดจะมองหามาก่อนล่ะ
ขอต้อนรับสู่โลกของ การทดสอบเชิงคุณสมบัติ (property-based testing) กระบวนทัศน์ที่เปลี่ยนจุดสนใจจากการทดสอบตัวอย่างที่เฉพาะเจาะจงไปสู่การตรวจสอบคุณสมบัติทั่วไปของโค้ดของคุณ และในระบบนิเวศของ Python ผู้ที่เป็นแชมป์ในแนวทางนี้อย่างไม่มีใครเทียบได้คือไลบรารีที่ชื่อว่า Hypothesis
คู่มือฉบับสมบูรณ์นี้จะนำคุณจากผู้เริ่มต้นไปสู่ผู้ปฏิบัติการทดสอบเชิงคุณสมบัติด้วย Hypothesis อย่างมั่นใจ เราจะสำรวจแนวคิดหลัก เจาะลึกตัวอย่างที่ใช้งานได้จริง และเรียนรู้วิธีผสานรวมเครื่องมืออันทรงพลังนี้เข้ากับขั้นตอนการพัฒนาประจำวันของคุณเพื่อสร้างซอฟต์แวร์ที่แข็งแกร่งขึ้น เชื่อถือได้มากขึ้น และทนทานต่อบั๊กมากขึ้น
การทดสอบเชิงคุณสมบัติคืออะไร? การปรับเปลี่ยนกระบวนทัศน์ทางความคิด
เพื่อให้เข้าใจ Hypothesis เราต้องเข้าใจแนวคิดพื้นฐานของการทดสอบเชิงคุณสมบัติก่อน ลองเปรียบเทียบกับการทดสอบแบบอิงตามตัวอย่างที่เราคุ้นเคยกันดี
การทดสอบแบบอิงตามตัวอย่าง: เส้นทางที่คุ้นเคย
สมมติว่าคุณได้เขียนฟังก์ชันจัดเรียงข้อมูลแบบกำหนดเองชื่อ my_sort() ด้วยการทดสอบแบบอิงตามตัวอย่าง กระบวนการคิดของคุณจะเป็นดังนี้:
- "ลองทดสอบกับลิสต์ที่เรียงลำดับแล้วแบบง่ายๆ" ->
assert my_sort([1, 2, 3]) == [1, 2, 3] - "แล้วถ้าเป็นลิสต์ที่เรียงจากหลังมาหน้าล่ะ?" ->
assert my_sort([3, 2, 1]) == [1, 2, 3] - "แล้วถ้าเป็นลิสต์ว่าง?" ->
assert my_sort([]) == [] - "ลิสต์ที่มีข้อมูลซ้ำ?" ->
assert my_sort([5, 1, 5, 2]) == [1, 2, 5, 5] - "และลิสต์ที่มีเลขติดลบ?" ->
assert my_sort([-1, -5, 0]) == [-5, -1, 0]
วิธีนี้มีประสิทธิภาพ แต่ก็มีข้อจำกัดพื้นฐานคือ: คุณกำลังทดสอบเฉพาะกรณีที่คุณนึกถึงเท่านั้น การทดสอบของคุณจะดีได้เท่ากับจินตนาการของคุณ คุณอาจพลาด edge case ที่เกี่ยวกับตัวเลขขนาดใหญ่มาก ความไม่แม่นยำของทศนิยม ตัวอักษร unicode ที่เฉพาะเจาะจง หรือการผสมผสานข้อมูลที่ซับซ้อนซึ่งนำไปสู่พฤติกรรมที่ไม่คาดคิด
การทดสอบเชิงคุณสมบัติ: คิดในเชิงคุณสมบัติที่ไม่เปลี่ยนแปลง (Invariants)
การทดสอบเชิงคุณสมบัติจะเปลี่ยนแนวทางโดยสิ้นเชิง แทนที่จะให้ตัวอย่างที่เฉพาะเจาะจง คุณจะกำหนด คุณสมบัติ (properties) หรือ อินแวเรียนต์ (invariants) ของฟังก์ชันของคุณ ซึ่งเป็นกฎที่ควรจะเป็นจริงสำหรับอินพุตที่ถูกต้องใดๆ ก็ตาม สำหรับฟังก์ชัน my_sort() ของเรา คุณสมบัติเหล่านี้อาจเป็น:
- ผลลัพธ์ถูกจัดเรียงแล้ว: สำหรับลิสต์ของตัวเลขใดๆ ทุกองค์ประกอบในลิสต์ผลลัพธ์จะต้องน้อยกว่าหรือเท่ากับองค์ประกอบที่ตามมา
- ผลลัพธ์มีองค์ประกอบเหมือนกับอินพุต: ลิสต์ที่จัดเรียงแล้วเป็นเพียงการเรียงสับเปลี่ยนของลิสต์ดั้งเดิม ไม่มีการเพิ่มหรือสูญเสียองค์ประกอบใดๆ
- ฟังก์ชันเป็น Idempotent: การจัดเรียงลิสต์ที่ถูกจัดเรียงแล้วไม่ควรเปลี่ยนแปลงลิสต์นั้น กล่าวคือ
my_sort(my_sort(some_list)) == my_sort(some_list)
ด้วยแนวทางนี้ คุณไม่ได้เป็นคนเขียนข้อมูลทดสอบ แต่คุณกำลังเขียนกฎเกณฑ์ จากนั้นคุณปล่อยให้เฟรมเวิร์กอย่าง Hypothesis สร้างอินพุตแบบสุ่มที่หลากหลายและมักจะซับซ้อนคาดไม่ถึงนับร้อยหรือนับพันรายการเพื่อพยายามพิสูจน์ว่าคุณสมบัติของคุณผิด หากมันพบอินพุตที่ทำลายคุณสมบัติ มันก็พบบั๊กนั่นเอง
ขอแนะนำ Hypothesis: เครื่องมือสร้างข้อมูลทดสอบอัตโนมัติของคุณ
Hypothesis เป็นไลบรารีการทดสอบเชิงคุณสมบัติชั้นนำสำหรับ Python มันจะรับคุณสมบัติที่คุณกำหนดและทำงานหนักในการสร้างข้อมูลทดสอบเพื่อท้าทายคุณสมบัติเหล่านั้น มันไม่ใช่แค่เครื่องสร้างข้อมูลแบบสุ่ม แต่เป็นเครื่องมือที่ชาญฉลาดและทรงพลังซึ่งออกแบบมาเพื่อค้นหาบั๊กอย่างมีประสิทธิภาพ
ฟีเจอร์หลักของ Hypothesis
- การสร้างกรณีทดสอบอัตโนมัติ: คุณกำหนด *รูปแบบ* ของข้อมูลที่คุณต้องการ (เช่น "ลิสต์ของจำนวนเต็ม" "สตริงที่ประกอบด้วยตัวอักษรเท่านั้น" "datetime ในอนาคต") และ Hypothesis จะสร้างตัวอย่างที่หลากหลายตามรูปแบบนั้น
- การลดรูปตัวอย่างที่ชาญฉลาด (Intelligent Shrinking): นี่คือฟีเจอร์เด็ด เมื่อ Hypothesis พบกรณีทดสอบที่ล้มเหลว (เช่น ลิสต์ของจำนวนเชิงซ้อน 50 ตัวที่ทำให้ฟังก์ชัน sort ของคุณพัง) มันจะไม่เพียงแค่รายงานลิสต์ขนาดใหญ่นั้น มันจะลดความซับซ้อนของอินพุตโดยอัตโนมัติและอย่างชาญฉลาดเพื่อค้นหา ตัวอย่างที่เล็กที่สุดเท่าที่จะเป็นไปได้ ที่ยังคงทำให้เกิดข้อผิดพลาด แทนที่จะเป็นลิสต์ 50 รายการ มันอาจรายงานว่าข้อผิดพลาดเกิดขึ้นกับแค่
[inf, nan]สิ่งนี้ทำให้การดีบักรวดเร็วและมีประสิทธิภาพอย่างเหลือเชื่อ - การผสานรวมที่ไร้รอยต่อ: Hypothesis ผสานรวมเข้ากับเฟรมเวิร์กการทดสอบยอดนิยมอย่าง
pytestและunittestได้อย่างสมบูรณ์แบบ คุณสามารถเพิ่มการทดสอบเชิงคุณสมบัติควบคู่ไปกับการทดสอบแบบอิงตามตัวอย่างที่มีอยู่ได้โดยไม่ต้องเปลี่ยนขั้นตอนการทำงานของคุณ - ไลบรารี Strategies ที่หลากหลาย: มันมาพร้อมกับคอลเลกชัน "strategies" (กลยุทธ์) ในตัวจำนวนมาก สำหรับการสร้างทุกสิ่งตั้งแต่จำนวนเต็มและสตริงธรรมดาไปจนถึงโครงสร้างข้อมูลที่ซับซ้อนและซ้อนกัน, datetime ที่รับรู้โซนเวลา และแม้กระทั่ง NumPy arrays
- การทดสอบเชิงสถานะ (Stateful Testing): สำหรับระบบที่ซับซ้อนมากขึ้น Hypothesis สามารถทดสอบลำดับของการกระทำเพื่อค้นหาบั๊กในการเปลี่ยนสถานะ ซึ่งเป็นสิ่งที่ทำได้ยากมากกับการทดสอบแบบอิงตามตัวอย่าง
เริ่มต้นใช้งาน: การทดสอบ Hypothesis ครั้งแรกของคุณ
มาลงมือทำกันเลยดีกว่า วิธีที่ดีที่สุดในการทำความเข้าใจ Hypothesis คือการได้เห็นมันทำงานจริง
การติดตั้ง
ก่อนอื่น คุณจะต้องติดตั้ง Hypothesis และ test runner ที่คุณเลือก (เราจะใช้ pytest) ง่ายๆ แค่นี้:
pip install pytest hypothesis
ตัวอย่างง่ายๆ: ฟังก์ชันค่าสัมบูรณ์
ลองพิจารณาฟังก์ชันง่ายๆ ที่ควรจะคำนวณค่าสัมบูรณ์ของตัวเลข การนำไปใช้งานที่มีบั๊กเล็กน้อยอาจมีลักษณะดังนี้:
# ในไฟล์ชื่อ `my_math.py`
def custom_abs(x):
"""ฟังก์ชันค่าสัมบูรณ์ที่สร้างขึ้นเอง"""
if x < 0:
return -x
return x
ตอนนี้ มาเขียนไฟล์ทดสอบ test_my_math.py กัน เริ่มจากแนวทาง pytest แบบดั้งเดิมก่อน:
# test_my_math.py (แบบอิงตามตัวอย่าง)
def test_abs_positive():
assert custom_abs(5) == 5
def test_abs_negative():
assert custom_abs(-5) == 5
def test_abs_zero():
assert custom_abs(0) == 0
การทดสอบเหล่านี้ผ่านทั้งหมด ฟังก์ชันของเราดูเหมือนจะถูกต้องตามตัวอย่างเหล่านี้ แต่ตอนนี้ เรามาเขียนการทดสอบเชิงคุณสมบัติด้วย Hypothesis กัน คุณสมบัติหลักของฟังก์ชันค่าสัมบูรณ์คืออะไร? ผลลัพธ์ต้องไม่เป็นค่าลบ
# test_my_math.py (เชิงคุณสมบัติด้วย Hypothesis)
from hypothesis import given
from hypothesis import strategies as st
from my_math import custom_abs
@given(st.integers())
def test_abs_property_is_non_negative(x):
"""คุณสมบัติ: ค่าสัมบูรณ์ของจำนวนเต็มใดๆ จะต้อง >= 0 เสมอ"""
assert custom_abs(x) >= 0
มาดูรายละเอียดกัน:
from hypothesis import given, strategies as st: เรานำเข้าส่วนประกอบที่จำเป็นgivenคือเดคคอเรเตอร์ที่เปลี่ยนฟังก์ชันทดสอบธรรมดาให้เป็นการทดสอบเชิงคุณสมบัติstrategiesคือโมดูลที่เราใช้หาเครื่องมือสร้างข้อมูลของเรา@given(st.integers()): นี่คือหัวใจของการทดสอบ เดคคอเรเตอร์@givenบอกให้ Hypothesis รันฟังก์ชันทดสอบนี้หลายครั้ง ในแต่ละครั้ง มันจะสร้างค่าโดยใช้ strategy ที่ให้มาคือst.integers()และส่งค่านั้นเป็นอาร์กิวเมนต์xไปยังฟังก์ชันทดสอบของเราassert custom_abs(x) >= 0: นี่คือคุณสมบัติของเรา เรายืนยันว่าสำหรับจำนวนเต็มxใดๆ ที่ Hypothesis สร้างขึ้นมา ผลลัพธ์ของฟังก์ชันของเราจะต้องมากกว่าหรือเท่ากับศูนย์
เมื่อคุณรันโค้ดนี้ด้วย pytest มันน่าจะผ่านสำหรับหลายๆ ค่า Hypothesis จะลอง 0, -1, 1, เลขบวกค่ามากๆ, เลขลบค่ามากๆ และอื่นๆ อีกมากมาย ฟังก์ชันง่ายๆ ของเรารับมือกับทั้งหมดนี้ได้อย่างถูกต้อง ตอนนี้ลองใช้ strategy อื่นเพื่อดูว่าเราจะหาจุดอ่อนได้หรือไม่
# ลองทดสอบกับเลขทศนิยม
@given(st.floats())
def test_abs_floats_property(x):
assert custom_abs(x) >= 0
ถ้าคุณรันโค้ดนี้ Hypothesis จะพบกรณีที่ล้มเหลวอย่างรวดเร็ว!
Falsifying example: test_abs_floats_property(x=nan) ... assert custom_abs(nan) >= 0 AssertionError: assert nan >= 0
Hypothesis ค้นพบว่าฟังก์ชันของเราเมื่อได้รับ float('nan') (Not a Number) จะคืนค่า nan ออกมา ซึ่ง assertion nan >= 0 เป็นเท็จ เราเพิ่งพบบั๊กเล็กๆ น้อยๆ ที่เราอาจไม่เคยคิดจะทดสอบด้วยตนเอง เราสามารถแก้ไขฟังก์ชันของเราเพื่อจัดการกับกรณีนี้ได้ เช่น อาจจะให้ raise ValueError หรือคืนค่าเฉพาะเจาะจง
ยิ่งไปกว่านั้น ถ้าบั๊กเกิดจากเลขทศนิยมที่เฉพาะเจาะจงมากๆ ตัว shrinker ของ Hypothesis ก็จะทำการลดรูปตัวเลขที่ซับซ้อนที่ทำให้เกิดข้อผิดพลาดนั้น ให้กลายเป็นเวอร์ชันที่ง่ายที่สุดที่ยังคงทำให้เกิดบั๊กได้
พลังของ Strategies: การสร้างข้อมูลทดสอบของคุณ
Strategies คือหัวใจของ Hypothesis มันคือสูตรสำหรับการสร้างข้อมูล ไลบรารีนี้มี strategies ในตัวมากมาย และคุณสามารถรวมและปรับแต่งมันเพื่อสร้างโครงสร้างข้อมูลแทบทุกอย่างที่คุณจะจินตนาการได้
Strategies ในตัวที่ใช้บ่อย
- ตัวเลข (Numeric):
st.integers(min_value=0, max_value=1000): สร้างจำนวนเต็ม ซึ่งสามารถกำหนดช่วงที่ต้องการได้st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False): สร้างเลขทศนิยม พร้อมการควบคุมค่าพิเศษต่างๆ ได้อย่างละเอียดst.fractions(),st.decimals()
- ข้อความ (Text):
st.text(min_size=1, max_size=50): สร้างสตริง unicode ที่มีความยาวตามกำหนดst.text(alphabet='abcdef0123456789'): สร้างสตริงจากชุดตัวอักษรที่ระบุ (เช่น สำหรับรหัส hex)st.characters(): สร้างตัวอักษรเดี่ยวๆ
- คอลเลกชัน (Collections):
st.lists(st.integers(), min_size=1): สร้างลิสต์ที่แต่ละองค์ประกอบเป็นจำนวนเต็ม สังเกตว่าเราส่ง strategy อื่นเป็นอาร์กิวเมนต์! นี่เรียกว่าการประกอบ (composition)st.tuples(st.text(), st.booleans()): สร้าง tuple ที่มีโครงสร้างคงที่st.sets(st.integers())st.dictionaries(keys=st.text(), values=st.integers()): สร้าง dictionary ที่มีคีย์และค่าตามประเภทที่ระบุ
- เวลา (Temporal):
st.dates(),st.times(),st.datetimes(),st.timedeltas()สิ่งเหล่านี้สามารถทำให้รับรู้โซนเวลาได้
- เบ็ดเตล็ด (Miscellaneous):
st.booleans(): สร้างTrueหรือFalsest.just('constant_value'): สร้างค่าเดียวเดิมๆ เสมอ มีประโยชน์สำหรับการประกอบ strategies ที่ซับซ้อนst.one_of(st.integers(), st.text()): สร้างค่าจากหนึ่งใน strategies ที่ให้มาst.none(): สร้างเฉพาะNone
การรวมและแปลง Strategies
พลังที่แท้จริงของ Hypothesis มาจากความสามารถในการสร้าง strategies ที่ซับซ้อนจากอันที่ง่ายกว่า
การใช้ .map()
เมธอด .map() ช่วยให้คุณนำค่าจาก strategy หนึ่งมาแปลงเป็นสิ่งอื่นได้ เหมาะอย่างยิ่งสำหรับการสร้างอ็อบเจกต์ของคลาสที่คุณกำหนดเอง
# data class แบบง่ายๆ
from dataclasses import dataclass
@dataclass
class User:
user_id: int
username: str
# strategy สำหรับสร้างอ็อบเจกต์ User
user_strategy = st.builds(
User,
user_id=st.integers(min_value=1),
username=st.text(min_size=3, alphabet='abcdefghijklmnopqrstuvwxyz')
)
@given(user=user_strategy)
def test_user_creation(user):
assert isinstance(user, User)
assert user.user_id > 0
assert user.username.isalpha()
การใช้ .filter() และ assume()
บางครั้งคุณจำเป็นต้องปฏิเสธค่าที่สร้างขึ้นมาบางค่า ตัวอย่างเช่น คุณอาจต้องการลิสต์ของจำนวนเต็มที่ผลรวมไม่เท่ากับศูนย์ คุณสามารถใช้ .filter() ได้:
st.lists(st.integers()).filter(lambda x: sum(x) != 0)
อย่างไรก็ตาม การใช้ .filter() อาจไม่มีประสิทธิภาพ หากเงื่อนไขเป็นเท็จบ่อยครั้ง Hypothesis อาจใช้เวลานานในการพยายามสร้างตัวอย่างที่ถูกต้อง แนวทางที่ดีกว่ามักจะเป็นการใช้ assume() ภายในฟังก์ชันทดสอบของคุณ:
from hypothesis import assume
@given(st.lists(st.integers()))
def test_something_with_non_zero_sum_list(numbers):
assume(sum(numbers) != 0)
# ... ตรรกะการทดสอบของคุณอยู่ที่นี่ ...
assume() บอกกับ Hypothesis ว่า: "ถ้าเงื่อนไขนี้ไม่เป็นจริง ก็แค่ทิ้งตัวอย่างนี้ไปแล้วลองตัวใหม่" มันเป็นวิธีที่ตรงไปตรงมาและมักจะมีประสิทธิภาพมากกว่าในการจำกัดข้อมูลทดสอบของคุณ
การใช้ st.composite()
สำหรับการสร้างข้อมูลที่ซับซ้อนจริงๆ ซึ่งค่าที่สร้างขึ้นค่าหนึ่งขึ้นอยู่กับอีกค่าหนึ่ง st.composite() คือเครื่องมือที่คุณต้องการ มันช่วยให้คุณเขียนฟังก์ชันที่รับฟังก์ชันพิเศษ draw เป็นอาร์กิวเมนต์ ซึ่งคุณสามารถใช้ดึงค่าจาก strategies อื่นๆ ทีละขั้นตอนได้
ตัวอย่างคลาสสิกคือการสร้างลิสต์และดัชนีที่ถูกต้องสำหรับลิสต์นั้น
@st.composite
def list_and_index(draw):
# ขั้นแรก ดึงลิสต์ที่ไม่ว่างเปล่า
my_list = draw(st.lists(st.integers(), min_size=1))
# จากนั้น ดึงดัชนีที่รับประกันว่าจะถูกต้องสำหรับลิสต์นั้น
index = draw(st.integers(min_value=0, max_value=len(my_list) - 1))
return (my_list, index)
@given(data=list_and_index())
def test_list_access(data):
my_list, index = data
# การเข้าถึงนี้รับประกันว่าจะปลอดภัยเพราะวิธีที่เราสร้าง strategy
element = my_list[index]
assert element is not None # assertion ง่ายๆ
Hypothesis ในการใช้งานจริง: สถานการณ์ในโลกแห่งความเป็นจริง
ลองนำแนวคิดเหล่านี้ไปใช้กับปัญหาที่สมจริงมากขึ้นที่นักพัฒนาซอฟต์แวร์ต้องเผชิญทุกวัน
สถานการณ์ที่ 1: การทดสอบฟังก์ชัน Data Serialization
ลองนึกภาพฟังก์ชันที่แปลงข้อมูลโปรไฟล์ผู้ใช้ (dictionary) ให้อยู่ในรูปแบบสตริงที่ปลอดภัยสำหรับ URL และอีกฟังก์ชันหนึ่งที่แปลงกลับ คุณสมบัติที่สำคัญคือกระบวนการนี้ควรจะย้อนกลับได้อย่างสมบูรณ์
import json
import base64
def serialize_profile(data: dict) -> str:
"""แปลง dictionary เป็นสตริง base64 ที่ปลอดภัยสำหรับ URL"""
json_string = json.dumps(data)
return base64.urlsafe_b64encode(json_string.encode('utf-8')).decode('utf-8')
def deserialize_profile(encoded_str: str) -> dict:
"""แปลงสตริงกลับเป็น dictionary"""
json_string = base64.urlsafe_b64decode(encoded_str.encode('utf-8')).decode('utf-8')
return json.loads(json_string)
# ตอนนี้มาทดสอบกัน
# เราต้องการ strategy ที่สร้าง dictionary ที่เข้ากันได้กับ JSON
json_dictionaries = st.dictionaries(
keys=st.text(),
values=st.recursive(st.none() | st.booleans() | st.floats(allow_nan=False) | st.text(),
lambda children: st.lists(children) | st.dictionaries(st.text(), children),
max_leaves=10)
)
@given(profile=json_dictionaries)
def test_serialization_roundtrip(profile):
"""คุณสมบัติ: การแปลงกลับโปรไฟล์ที่เข้ารหัสควรได้โปรไฟล์เดิมคืนมา"""
encoded = serialize_profile(profile)
decoded = deserialize_profile(encoded)
assert profile == decoded
การทดสอบเพียงครั้งเดียวนี้จะทดสอบฟังก์ชันของเราอย่างหนักหน่วงด้วยข้อมูลที่หลากหลายมหาศาล: dictionary ว่าง, dictionary ที่มีลิสต์ซ้อนกัน, dictionary ที่มีอักขระ unicode, dictionary ที่มีคีย์แปลกๆ และอื่นๆ อีกมากมาย ซึ่งละเอียดถี่ถ้วนกว่าการเขียนตัวอย่างด้วยตนเองเพียงไม่กี่ตัวอย่างมาก
สถานการณ์ที่ 2: การทดสอบอัลกอริทึมการเรียงลำดับ
กลับมาที่ตัวอย่างการเรียงลำดับของเรา นี่คือวิธีที่คุณจะทดสอบคุณสมบัติที่เราได้กำหนดไว้ก่อนหน้านี้
from collections import Counter
def my_buggy_sort(numbers):
# ลองใส่บั๊กเล็กๆ น้อยๆ: มันจะลบข้อมูลที่ซ้ำกันออกไป
return sorted(list(set(numbers)))
@given(st.lists(st.integers()))
def test_sorting_properties(numbers):
sorted_list = my_buggy_sort(numbers)
# คุณสมบัติที่ 1: ผลลัพธ์ถูกจัดเรียงแล้ว
for i in range(len(sorted_list) - 1):
assert sorted_list[i] <= sorted_list[i+1]
# คุณสมบัติที่ 2: องค์ประกอบเหมือนเดิม (อันนี้จะเจอบั๊ก)
assert Counter(numbers) == Counter(sorted_list)
# คุณสมบัติที่ 3: ฟังก์ชันเป็น idempotent
assert my_buggy_sort(sorted_list) == sorted_list
เมื่อคุณรันการทดสอบนี้ Hypothesis จะพบตัวอย่างที่ล้มเหลวสำหรับคุณสมบัติที่ 2 อย่างรวดเร็ว เช่น numbers=[0, 0] ฟังก์ชันของเราคืนค่า [0] และ Counter([0, 0]) ไม่เท่ากับ Counter([0]) ตัว shrinker จะช่วยให้แน่ใจว่าตัวอย่างที่ล้มเหลวนั้นง่ายที่สุดเท่าที่จะเป็นไปได้ ทำให้เห็นสาเหตุของบั๊กได้ทันที
สถานการณ์ที่ 3: การทดสอบเชิงสถานะ (Stateful Testing)
สำหรับอ็อบเจกต์ที่มีสถานะภายในที่เปลี่ยนแปลงตลอดเวลา (เช่น การเชื่อมต่อฐานข้อมูล, ตะกร้าสินค้า หรือแคช) การค้นหาบั๊กอาจทำได้ยากอย่างเหลือเชื่อ อาจต้องใช้ลำดับการทำงานที่เฉพาะเจาะจงเพื่อทำให้เกิดข้อผิดพลาด Hypothesis มี `RuleBasedStateMachine` สำหรับจุดประสงค์นี้โดยเฉพาะ
ลองนึกภาพ API ง่ายๆ สำหรับ key-value store ในหน่วยความจำ:
class SimpleKeyValueStore:
def __init__(self):
self._data = {}
def set(self, key, value):
self._data[key] = value
def get(self, key):
return self._data.get(key)
def delete(self, key):
if key in self._data:
del self._data[key]
def size(self):
return len(self._data)
เราสามารถจำลองพฤติกรรมและทดสอบมันด้วย state machine:
from hypothesis.stateful import RuleBasedStateMachine, rule, Bundle
class KeyValueStoreMachine(RuleBasedStateMachine):
def __init__(self):
super().__init__()
self.model = {}
self.sut = SimpleKeyValueStore() # sut = System Under Test
# Bundle() ใช้สำหรับส่งข้อมูลระหว่าง rules
keys = Bundle('keys')
@rule(target=keys, key=st.text(), value=st.integers())
def set_key(self, key, value):
self.model[key] = value
self.sut.set(key, value)
return key
@rule(key=keys)
def delete_key(self, key):
del self.model[key]
self.sut.delete(key)
@rule(key=st.text())
def get_key(self, key):
model_val = self.model.get(key)
sut_val = self.sut.get(key)
assert model_val == sut_val
@rule()
def check_size(self):
assert len(self.model) == self.sut.size()
# ในการรันการทดสอบ คุณเพียงแค่ subclass จาก machine และ unittest.TestCase
# ใน pytest คุณสามารถกำหนด test ให้กับ machine class ได้เลย
TestKeyValueStore = KeyValueStoreMachine.TestCase
ตอนนี้ Hypothesis จะดำเนินการลำดับการทำงานแบบสุ่มของ `set_key`, `delete_key`, `get_key` และ `check_size` เพื่อพยายามค้นหาลำดับที่ทำให้ assertion ใดๆ ล้มเหลวอย่างไม่ลดละ มันจะตรวจสอบว่าการ get คีย์ที่ถูกลบไปแล้วทำงานถูกต้องหรือไม่, ขนาดของ store สอดคล้องกันหรือไม่หลังจากการ set และ delete หลายครั้ง และสถานการณ์อื่นๆ อีกมากมายที่คุณอาจไม่เคยคิดจะทดสอบด้วยตนเอง
แนวปฏิบัติที่ดีที่สุดและเคล็ดลับขั้นสูง
- ฐานข้อมูลตัวอย่าง (The Example Database): Hypothesis ฉลาดมาก เมื่อมันพบบั๊ก มันจะบันทึกตัวอย่างที่ล้มเหลวไว้ในไดเร็กทอรีท้องถิ่น (
.hypothesis/) ครั้งต่อไปที่คุณรันการทดสอบ มันจะเล่นซ้ำตัวอย่างที่ล้มเหลวนั้นก่อน เพื่อให้คุณได้รับฟีดแบ็กทันทีว่าบั๊กยังคงอยู่ เมื่อคุณแก้ไขแล้ว ตัวอย่างนั้นจะไม่ถูกเล่นซ้ำอีก - ควบคุมการทำงานของเทสต์ด้วย
@settings: คุณสามารถควบคุมหลายแง่มุมของการรันเทสต์ได้โดยใช้เดคคอเรเตอร์@settingsคุณสามารถเพิ่มจำนวนตัวอย่าง, ตั้งเวลาสูงสุดที่ตัวอย่างหนึ่งๆ สามารถรันได้ (เพื่อจับ infinite loops) และปิดการตรวจสอบบางอย่างได้@settings(max_examples=500, deadline=1000) # รัน 500 ตัวอย่าง, กำหนดเวลา 1 วินาที @given(...) ...
- การทำซ้ำข้อผิดพลาด (Reproducing Failures): ทุกครั้งที่ Hypothesis รัน จะมีการพิมพ์ค่า seed ออกมา (เช่น
@reproduce_failure('version', 'seed')) หากเซิร์ฟเวอร์ CI พบบั๊กที่คุณไม่สามารถทำซ้ำได้ในเครื่องของคุณ คุณสามารถใช้เดคคอเรเตอร์นี้พร้อมกับ seed ที่ให้มาเพื่อบังคับให้ Hypothesis รันลำดับตัวอย่างเดียวกันเป๊ะ - การผสานรวมกับ CI/CD: Hypothesis เหมาะอย่างยิ่งสำหรับไปป์ไลน์ continuous integration ใดๆ ความสามารถในการค้นหาบั๊กที่ซ่อนเร้นก่อนที่จะไปถึง production ทำให้มันเป็นเครือข่ายความปลอดภัยที่ประเมินค่าไม่ได้
การปรับเปลี่ยนกระบวนทัศน์ทางความคิด: การคิดในเชิงคุณสมบัติ
การนำ Hypothesis มาใช้เป็นมากกว่าการเรียนรู้ไลบรารีใหม่ มันคือการยอมรับวิธีคิดใหม่เกี่ยวกับความถูกต้องของโค้ดของคุณ แทนที่จะถามว่า "ฉันควรทดสอบอินพุตอะไรบ้าง?" คุณจะเริ่มถามว่า "ความจริงสากลเกี่ยวกับโค้ดนี้คืออะไร?"
นี่คือคำถามบางส่วนที่จะช่วยนำทางคุณเมื่อพยายามระบุคุณสมบัติ:
- มีการดำเนินการย้อนกลับหรือไม่? (เช่น serialize/deserialize, encrypt/decrypt, compress/decompress) คุณสมบัติคือการดำเนินการไปและย้อนกลับควรให้ผลลัพธ์เป็นอินพุตดั้งเดิม
- การดำเนินการนั้นเป็น idempotent หรือไม่? (เช่น
abs(abs(x)) == abs(x)) การใช้ฟังก์ชันมากกว่าหนึ่งครั้งควรให้ผลลัพธ์เช่นเดียวกับการใช้เพียงครั้งเดียว - มีวิธีอื่นที่ง่ายกว่าในการคำนวณผลลัพธ์เดียวกันหรือไม่? คุณสามารถทดสอบได้ว่าฟังก์ชันที่ซับซ้อนและปรับให้เหมาะสมของคุณให้ผลลัพธ์เหมือนกับเวอร์ชันที่เรียบง่ายและถูกต้องอย่างเห็นได้ชัด (เช่น การทดสอบฟังก์ชัน sort สุดเจ๋งของคุณกับฟังก์ชัน
sorted()ที่มีใน Python) - อะไรที่ควรจะเป็นจริงเสมอเกี่ยวกับผลลัพธ์? (เช่น ผลลัพธ์ของฟังก์ชัน `find_prime_factors` ควรประกอบด้วยจำนวนเฉพาะเท่านั้น และผลคูณของพวกมันควรเท่ากับอินพุต)
- สถานะเปลี่ยนแปลงอย่างไร? (สำหรับการทดสอบเชิงสถานะ) อินแวเรียนต์อะไรที่ต้องคงไว้หลังจากการดำเนินการที่ถูกต้องใดๆ? (เช่น จำนวนสินค้าในตะกร้าสินค้าต้องไม่เป็นค่าลบ)
สรุป: ความมั่นใจในระดับใหม่
การทดสอบเชิงคุณสมบัติด้วย Hypothesis ไม่ได้มาแทนที่การทดสอบแบบอิงตามตัวอย่าง คุณยังคงต้องการการทดสอบที่เขียนขึ้นเองอย่างเฉพาะเจาะจงสำหรับตรรกะทางธุรกิจที่สำคัญและข้อกำหนดที่เข้าใจกันดี (เช่น "ผู้ใช้จากประเทศ X ต้องเห็นราคา Y")
สิ่งที่ Hypothesis มอบให้คือวิธีการสำรวจพฤติกรรมของโค้ดของคุณแบบอัตโนมัติและทรงพลัง และป้องกัน edge case ที่ไม่คาดฝัน มันทำหน้าที่เป็นคู่หูที่ไม่รู้จักเหน็ดเหนื่อย สร้างการทดสอบนับพันที่หลากหลายและซับซ้อนเกินกว่าที่มนุษย์จะเขียนขึ้นได้จริง ด้วยการกำหนดคุณสมบัติพื้นฐานของโค้ดของคุณ คุณกำลังสร้างข้อกำหนดที่แข็งแกร่งซึ่ง Hypothesis สามารถทดสอบได้ ทำให้คุณมีความมั่นใจในซอฟต์แวร์ของคุณในระดับใหม่
ครั้งต่อไปที่คุณเขียนฟังก์ชัน ลองใช้เวลาสักครู่เพื่อคิดให้ไกลกว่าแค่ตัวอย่าง ถามตัวเองว่า "กฎคืออะไร? อะไรที่ต้องเป็นจริงเสมอ?" จากนั้น ให้ Hypothesis ทำงานหนักในการพยายามทำลายกฎเหล่านั้น คุณจะประหลาดใจกับสิ่งที่มันพบ และโค้ดของคุณจะดีขึ้นอย่างแน่นอน