Coding Gun

Test Python ด้วย Pytest

pytest คือ testing framework สำหรับการเขียน Test Code เพื่อทดสอบ Python script ที่เราเขียนขึ้นมา ซึ่ง pytest สามารถทำได้ตั้งแต่ Unit test, Integration Test ไปจนถึง End-2-End test

Pytest vs Unittest

ใน Python Standard Library จะมี Unittest มาให้ใช้อยู่แล้วจริงๆเราสามารถเขียน Test ได้โดยเลย โดยที่ไม่ต้องติดต้ังอะไรเลย แต่ Unittest จะเหมือนกับการใช้งาน JUnit หรือ XUnit ที่ออกแบบมาสำหรับการทำ Unit Test ซึ่งเหมาะสำหรับผู้ที่ต้องการทำ Unit Test และเคยใช้งาน JUnit หรือ XUnit มาอยู่แล้ว

ส่วน pytest นั้นจะสามารถทำ Test ได้ตั้งแต่ Unit Test ไปจนถึง End-2-End Test และยังมีความสามารถที่มากกว่า Unittest เช่น การใช้ Fixture และ Parameterize

ติดตั้ง Pytest

ก่อนจะใช้งาน pytest เราต้อง ติดตั้ง pytest เข้ามาก่อนโดยเราจะติดตั้งด้วย pip แบบนี้

$ pip install -U pytest

หลังจากติดตั้ง pytest เรียบร้อยแล้วให้เราลองสร้าง Python script ที่ต้องถูกทดสอบขึ้นมาก่อนโดยเราจะใช้ว่า calculator.py

def add(a, b):
    return a + b

Test script ขึ้นมาก่อนโดยจะตั้งชื่อไฟล์ว่า test_scripts.py

Pytest จะเลือก run ไฟล์ที่นำหน้าด้วย test_ หรือลงท้ายด้วย _test.py

import pytest
from calculator import add

def test_add_number():
    assert add(5, 5) == 10

def test_add_negative_number():
    assert add(5, -3) == 2

ลองรัน Test ด้วยคำสั่ง

$ pytest

ถ้าต้องการระบุชื่อ Test script ที่ต้องการ run เราจะใช้คำสั่ง

$ pytest test_scripts.py

ถ้าผลลัพธ์ที่ได้ออกมาถูกต้องเราจะได้ผลลัพธ์ออกมาแบบนี้่

============================== test session starts ===============================
platform darwin -- Python 3.11.6, pytest-8.2.1, pluggy-1.5.0
rootdir: /Users/demo/Documents/Workshop/pytest-demo
plugins: anyio-4.0.0, time-machine-2.13.0
collected 2 items                                                                

test_scripts.py ..                                                             [100%]

=============================== 2 passed in 0.01s ================================

Pytest Marker

เราสามารถจัดกลุ่มของ Test ด้วย @pytest.mark แล้วตามด้วยชื่อของ group

import pytest

@pytest.mark.great
def test_greater_than():
   n = 150
   assert n > 100

@pytest.mark.great
def test_greater_than_or_equal():
   n = 100
   assert n >= 100

@pytest.mark.others
def test_less_than():
   n = 90
   assert n < 100

ในตัวอย่างเราจะ run Test 2 case แรกพร้อมกันเนื่องจากเป็นการทดสอบในกรณีเดียวกัน(num >= 100)

เวลาจะ run Test เราสามารถเลือก group ที่ต้องการ run ด้วยการใส่ -m เข้าไปแบบนี้

$ pytest -m great

Pytest Fixture

ในการทดสอบที่ต้องมีการ Integration กับระบบอื่นๆเช่นดึงข้อมูลจาก Database หรือส่ง Email เราจะใช้ Fixture ในการสร้าง Setup(คำสั่งตอนเริ่มต้น) และ Tear down(คำสั่งตอนก่อนจบ Test)

Fixture จะคล้ายๆกับ Mark แต่จะเป็นการจัดกลุ่มของ Test และกำหนดค่าเริ่มต้น(Setup)แบบนี้

import pytest

@pytest.fixture
def input_value():
   input = 39
   return input

def test_divisible_by_3(input_value):
   assert input_value % 3 == 0

def test_divisible_by_6(input_value):
   assert input_value % 6 == 0

ในตัวอย่างนี้เราจะเลือก run test 2 ตัวคือ test_divisible_by_3 และ test_divisible_by_6 โดยใช้คำสั่ง (-k จะเลือก run เฉพาะ Test ที่มีคำส่า divisible อยู่ในชื่อ)

$ pytest -k divisible

ซึ่งจะมีการเรียก method input_value ก่อนจะเข้าไปทำงานใน Test case แต่ละตัว จะเห็นว่าเราสามารถกำหนดค่าเริ่มต้นได้ใน method input_value ที่เดียว

Pytest Parameterize

Pytest สามารถใส่ Parameter เข้าไปใน Test ได้ทำให้เราเขียน Test ครั้งเดียวแล้ว re-use ใช้หลายๆครั้งได้ หรือถ้าเรามี Data สำหรับการทดสอบหลายๆตัว เราก็สามารถส่งเข้าไปใน Parameter ของ Test Code ได้ ยกตัวอย่างเช่น

  1. เริ่มต้นจากการเขียน Function add ที่เราต้องการทดสอบขึ้นมาก่อน

    def add(a, b):
        return a + b
    
  2. เขียน Unittest เพื่อทดสอบ Function add ที่เราได้สร้างขึ้น โดยจะใส่ Parameter เข้าไปด้วย

    import  pytest
    from calculator import add
    
    @pytest.mark.parametrize("a, b,expected", [(3, 5, 8), (2, -4, -2), (-2, -3, -5)])
    def test_add(a, b, expected):
        assert add(a, b) == expected
    

    ในตัวอย่างนี้เป็นการส่ง patameter เข้าไป 3 ชุดคือ

    • 3 + 5 = 8
    • 2 + (-4) = -2
    • (-2) + (-3) = -5

    ซึ่งสมาชิก 2 ตัวแรกใน tupleจะถูกส่งเข้าไปเป็น parameter a และ b ใน function add และสมาชิกตัวหลังสุดจะเป็นค่าที่เรา expected(คำตอบที่คาดว่าจะได้)

  3. run pytest เพื่อทดสอบ

    $ pytest
    

    เราจะได้ผลลัพธ์ออกมาแบบนี้

    ============================== test session starts ===============================
    platform darwin -- Python 3.11.6, pytest-8.2.1, pluggy-1.5.0
    rootdir: /Users/demo/Documents/Workshop/pytest-demo
    plugins: anyio-4.0.0, time-machine-2.13.0
    collected 3 items                                                                
    
    test_scripts.py ...                                                            [100%]
    
    =============================== 3 passed in 0.01s ================================
    

Pytest Plugins

เราสามารถติดตั้ง plugins เข้ามาใน Pytest เพื่อให้ Pytest มีความสามารถมากขึ้น ยกตัวอย่างเช่น

Pytest Mock

เราสามารถใช้ Pytest-Mock เพื่อจำลองข้อมูลขึ้นมาโดยที่เราไม่ต้องใส่เข้าไป ซึ่งการใช้ Mock สำหรับ pytest นั้นจะสามารถทำ Test Double ได้ทั้ง

ซึ่งการทำ Test Double มีหลายวิธีตามรายการด้านบนแต่ทุกตัวล้วนมีจุดประสงค์เดียวกันคือ เขียน Test เฉพาะสิ่งที่กำลังสนใจ ส่วนอื่นๆรอบข้างก็สร้าง Code จำลองขึ้นมาทั้งหมด ตัวอย่างการใช้ Mock มีดังนี้

  1. ติดต้ัง Mock สำหรับ Pytest เข้ามาก่อน

    $ pip install pytest-mock
    
  2. หลังจากนั้น Mock สิ่งที่เราต้องการขึ้นมา

    import os
    
    class UnixFS:
    
        @staticmethod
        def rm(filename):
            os.remove(filename)
    
    def test_unix_fs(mocker):
        mocker.patch('os.remove')
        UnixFS.rm('file')
        os.remove.assert_called_once_with('file')
    

    ในตัวอย่างนี้เราใช้ Mock(ชื่อ library) เพื่อสร้าง Class จำลองขึ้นมา ซึ่ง Class นี้ชื่อ UnixFS และ class นี้จะมี Static Method ชื่อ rm และ method rm จะรับชื่อไฟล์เข้ามา เวลา test เราก็จะสามารถเรียก UnixFS.rm() ใน function test ของเราได้เลย โดยที่ไม่ต้องเสียเวลาไปสร้าง Class UnixFS ขึ้นมาจริงๆ

    โดยที่เราจะสร้าง Function Foo ขึ้นมา และนอกจากนี้เรายังสามารถใช้ mock.patch สำหรับสร้าง function ปลอมๆขึ้นมา เหมือนในตัวอย่าง os.remove() เป็นคำสั่งที่ใช้ลบไฟล์ที่มีอยู่ในเครื่อง ถ้าเราใช้ function ของจริงไฟล์ที่อยู่ในเครื่องเราจะหายไป แต่ในกรณีนี้เราจะใช้ mock.patch ทำให้ os.remove กลายเป็นของปลอม

    หลังจากนั้นเราจะใช้ os.remove.assert_called_once_with() เพื่อตรวจสอบว่ามีการเรียกใช้ os.remove โดยส่ง parameter เป็นชื่อ file เข้ามารึเปล่า

Pytest Coverage

Code Coverage คือตัววัดที่จะบ่งชี้ถึงคุณภาพของการทดสอบ เป็นตัววัดที่จะบอกว่า Code ของเราถูกทดสอบไปกี่เปอร์เซ็นต์แล้ว วิธีการสร้าง Code Coverage Report มีดังนี้

  1. ติดตั้ง Library ชื่อ Coverage

    $ pip install coverage
    
  2. หลังจากนั้นให้เปลี่ยนคำสั่งที่ใช้ในการ run จาก pytest เป็น coverage run แบบนี้

    $ coverage run test_scripts.py
    

    เราสามารถใช้ parameters ต่างๆของ pytest ต่อท้ายได้เลย เช่น

    $ coverage run -m great
    
  3. เรียกดู Code Coverage Report ด้วยคำสั่ง

    $ coverage report -m
    
  4. ถ้าต้องการ Export ออกเป็น HTML ให้เราใช้คำสั่ง

    $ coverage html
    

    หลังจากนั้นเราจะได้ไฟล์ index.html อยู่ใน Folder ชื่อว่า hmtlcov คุณจะได้ report ออกมาดังรูป

    Pytest Code Coverage
    ตัวอย่าง Code Coverage Report

หวังว่าผู้อ่านจะได้นำเอา Pytest ไปลองเขียน Test กัน แลัวพบกันใหม่ในบทความถัดๆไปนะครับ

Phanupong Permpimol
Follow me