ผลต่างระหว่างรุ่นของ "01204223/flask-testing"

จาก Theory Wiki
ไปยังการนำทาง ไปยังการค้นหา
 
(ไม่แสดง 5 รุ่นระหว่างกลางโดยผู้ใช้คนเดียวกัน)
แถว 210: แถว 210:
 
</syntaxhighlight>
 
</syntaxhighlight>
  
== ทดสอบ view functions ==
+
== ทดสอบ view functions: จัดการกับ jwt ==
 +
 
 +
เราจะเรียกฟังก์ชันต่าง ๆ ที่รับ request ใน main.py ว่าเป็น view function  &nbsp;&nbsp; ฟังก์ชันเหล่านี้จะรับ request ดังนั้นการจะเรียกใช้เพื่อทดสอบ จะทำโดยการจำลองการส่ง request ไปที่ฟังก์ชันเหล่านี้ ผ่านทาง client (ที่ทำตัวเหมือนเป็น browser)
 +
 
 +
ฟังก์ชันแรกที่เราจะทดสอบคือฟังก์ชัน get_todos  ที่มีโค้ดแสดงดังด้านล่าง
 +
 
 +
<syntaxhighlight lang="python">
 +
# ตัดมาให้ดูเฉย ๆ ยังไม่ต้องแก้
 +
@app.route('/api/todos/', methods=['GET'])
 +
@jwt_required()
 +
def get_todos():
 +
    todos = TodoItem.query.all()
 +
    return jsonify([todo.to_dict() for todo in todos])
 +
</syntaxhighlight>
 +
 
 +
เราจะสร้างไฟล์ backend/tests/test_views.py  จากนั้นให้เพิ่มฟังก์ชัน test_get_empty_todo_items ดังด้านล่าง
  
 
<syntaxhighlight lang="python">
 
<syntaxhighlight lang="python">
แถว 221: แถว 236:
 
</syntaxhighlight>
 
</syntaxhighlight>
  
จะพบ error ดังนี้
+
ฟังก์ชันดังกล่าว จะเรียก request ไปที่ /api/todos แบบ GET และคาดหวังว่าจะได้ status_code เป็น OK และได้ผลลัพธ์เป็นรายการเปล่า ๆ
 +
 
 +
อย่างไรก็ตาม เมื่อเรียก pytest จะพบ error ดังนี้
  
 
  ====================================== FAILURES ======================================
 
  ====================================== FAILURES ======================================
แถว 240: แถว 257:
 
  ======================= 1 failed, 4 passed, 1 warning in 3.74s =======================
 
  ======================= 1 failed, 4 passed, 1 warning in 3.74s =======================
  
เราได้รับ 401 กลับมา เพราะว่าไม่ผ่านการตรวจสอบ jwt token
+
เราได้รับ 401 กลับมา โดยที่ view function ยังไม่ทันได้ทำงาน เพราะว่าการเรียกของเราไม่ผ่านการตรวจสอบ jwt token (ที่ decorator @jwt_required)
 +
 
 +
เราจะ bypass การทดสอบนี้ โดยไป mock ฟังก์ชัน verify_jwt_in_request (ซึ่งเป็นฟังก์ชันภายใน @jwt_required) การจัดการดังกล่าวค่อนข้างยุ่งเล็กน้อย  หลัก ๆ เราจะใช้ fixture พิเศษของ pytest ชื่อ monkeypatch เพื่อ '''patch''' ฟังก์ชันดังกล่าวให้เป็นฟังก์ชัน no_verify ที่เรากำหนดไว้แทน  แต่เนื่องจากถ้าถัด ๆ ไป เราอาจจะมีการเรียกถาม get_jwt_identity โค้ดด้านล่างจึงสร้าง identity ปลอมชื่อ test_user ไว้ด้วย เผื่ออาจจะจำเป็นต้องใช้ในอนาคต
 +
 
 +
ให้แปะโค้ดนี้ก่อนฟังก์ชัน test_get_empty_todo_items แล้วลองเรียก pytest อีกครั้ง
  
 
<syntaxhighlight lang="python">
 
<syntaxhighlight lang="python">
แถว 247: แถว 268:
 
from flask_jwt_extended.config import config
 
from flask_jwt_extended.config import config
  
@pytest.fixture(autouse=True)
 
 
@pytest.fixture(autouse=True)
 
@pytest.fixture(autouse=True)
 
def no_jwt(monkeypatch):
 
def no_jwt(monkeypatch):
แถว 260: แถว 280:
 
     monkeypatch.setattr(view_decorators, 'verify_jwt_in_request', no_verify)
 
     monkeypatch.setattr(view_decorators, 'verify_jwt_in_request', no_verify)
 
</syntaxhighlight>
 
</syntaxhighlight>
 +
 +
สังเกตว่า fixture ด้านบนถูกกำหนดให้ทำงานโดยอัตโนมัติด้วย (autouse=True) ทำให้ถัด ๆ ไปเราไม่จำเป็นต้องระบุ fixture นี้ในการเขียนเทสฟังก์ชัน
 +
 +
== ทดสอบ view functions: สร้างข้อมูลทดสอบและ fixture ==
 +
 +
เราจะทดสอบกรณีที่มี todo item บ้างแล้ว ในการทดสอบดังกล่าว เราจะต้องใส่โค้ดเพื่อเพิ่ม todo item เข้าไป ก่อนที่จะเรียก client.get('/api/todos/')  เพื่อความสะดวกในการแก้ไข เราจะสร้างฟังก์ชัน create_todo เพื่อการสร้างนี้ และเรียกใช้ฟังก์ชันดังกล่าวในฟังก์ชัน test_get_sample_todo_items ดังด้านล่าง
 +
 +
<syntaxhighlight lang="python">
 +
from models import TodoItem, db
 +
 +
def create_todo(title='Sample todo', done=False):
 +
    todo = TodoItem(title=title, done=done)
 +
    db.session.add(todo)
 +
    db.session.commit()
 +
    return todo
 +
 +
def test_get_sample_todo_items(client, app_context):
 +
    todo1 = create_todo(title='Todo 1', done=False)
 +
    todo2 = create_todo(title='Todo 2', done=True)
 +
    response = client.get('/api/todos/')
 +
    assert response.status_code == HTTPStatus.OK
 +
    assert response.get_json() == [todo.to_dict() for todo in [todo1, todo2]]
 +
</syntaxhighlight>
 +
 +
ให้ทดลองเรียก pytest เพื่อทดสอบ
 +
 +
สังเกตว่าถ้าเราต้องการจะทดสอบต่อ ๆ ไป เรามักจะต้องการให้มี todo item อยู่บ้างเพื่อความสะดวกในการทดสอบ  ถ้าเราต้องเขียนคำสั่ง create_todo ซ้ำ ๆ ในทุก ๆ test มันคงจะดูซ้ำซ้อนและทำให้เขียนเทสยาก
 +
 +
เราจะสร้าง fixture ชื่อ sample_todo_items ที่ทำงานดังกล่าวและคืนรายการของ todo item กลับมาดังด้านล่าง
 +
 +
<syntaxhighlight lang="python">
 +
@pytest.fixture
 +
def sample_todo_items(app_context):
 +
    todo1 = create_todo(title='Todo 1', done=False)
 +
    todo2 = create_todo(title='Todo 2', done=True)
 +
    return [todo1, todo2]
 +
</syntaxhighlight>
 +
 +
เราจะนำ fixture ดังกล่าวไปทำให้ฟังก์ชัน test_get_sample_todo_items ระชับขึ้น โดยสามารถแก้ฟังก์ชดังกล่าวได้ดังนี้
 +
 +
<syntaxhighlight lang="python">
 +
def test_get_sample_todo_items(client, sample_todo_items):
 +
    # ภายในโค้ดนี้ sample_todo_items จะประกอบด้วย todo1 และ todo2 ที่ส่งมา
 +
    response = client.get('/api/todos/')
 +
    assert response.status_code == HTTPStatus.OK
 +
    assert response.get_json() == [todo.to_dict() for todo in sample_todo_items]  # ไล่ดู todo ในรายการ sample_todo_items
 +
</syntaxhighlight>
 +
 +
หมายเหตุ: สังเกตว่าเราเรียกใช้ todo จาก sample_todo_items ได้เลย
 +
 +
เราสามารถเขียนฟังก์ชัน test_toggle_todo_item เพื่อทดสอบการ toggle ได้ดังด้านล่าง โดยฟังก์ชันนี้ก็ใช้ fixture sample_todo_items ด้วยเช่นกัน
 +
 +
<syntaxhighlight lang="python">
 +
def test_toggle_todo_item(client, sample_todo_items):
 +
    item1, item2 = sample_todo_items
 +
 +
    response = client.patch(f'/api/todos/{item1.id}/toggle/')
 +
    assert response.status_code == HTTPStatus.OK
 +
 +
    data = response.get_json()
 +
    assert data['done'] is True
 +
    assert TodoItem.query.get(item1.id).done is True
 +
</syntaxhighlight>
 +
 +
ถ้าสามารถเทสได้เรียบร้อย อย่าลืม
 +
{{กล่องฟ้า|
 +
🄶 commit งานที่ทำ
 +
}}
  
 
== Github action ==
 
== Github action ==
แถว 319: แถว 407:
 
</syntaxhighlight>
 
</syntaxhighlight>
  
ให้ add ไฟล์ที่ทำทั้งหมด รวมทั้งไฟล์ workflow ใหม่นี้ด้วย (อย่าลืมดู requirements.txt) จากนั้นให้...
+
ถ้าเราลองอ่าน workflow ดังกล่าวจะพบว่ามีขั้นตอนการทำงานไม่ต่างจาก github action เดิมของเราเท่าใดนัก กล่าวคือ จะมีการติดตั้งไลบรารีด้วย pip จากนั้นเรียกให้ pytest ทำงาน
 +
 
 +
เมื่อเสร็จแล้วให้ add ไฟล์ที่เพิ่ม รวมทั้งไฟล์ workflow ใหม่นี้ด้วย (อย่าลืมดู requirements.txt) จากนั้นให้...
  
 
{{กล่องฟ้า|
 
{{กล่องฟ้า|

รุ่นแก้ไขปัจจุบันเมื่อ 05:44, 27 กุมภาพันธ์ 2569

หน้านี้เป็นส่วนหนึ่งของวิชา 01204223

เราจะทดสอบส่วน Flask backend ด้วย pytest โดยการทดสอบเราจะเป็นกึ่ง ๆ unit test ที่จะเน้นทดสอบแค่บาง api end-point แต่เนื่องจาก backend เรามีการติดต่อกับ database ผ่านทาง SQLAlchemy ซึ่ง mock ยุ่งยากมาก เราจึงจะไม่ได้ mock แต่จะสร้าง test database มาแทน ทำให้การทำงานของ test เรานั้นจะมีการติดต่อไปยังฐานข้อมูลด้วย (จึงอาจจะเป็น unit test ที่ติดต่อกับระบบอื่นๆ มากเกินไปสักนิด)

ติดตั้งและตั้งค่าเริ่มต้น

เข้าไปทำงานในไดเร็กทอรี backend

ก่อนอื่นเราจะต้อง activate virtual environment ก่อน

จากนั้นจะติดตั้ง pytest โดยเรียก

pip install pytest

ถ้าติดตั้งเรียบร้อยจะเรียก

pytest

ได้ และเห็นผลลัพธ์ที่แสดงว่าเราไม่มี test case อะไรเลย (จะมีข้อความว่า collected 0 items)

ก่อนจะทำงานต่อ ให้เก็บรายการของ packages ลงใน requirements.txt โดยสั่ง

pip freeze > requirements.txt

เพื่อที่เราจะได้ไปเทสใน github action ได้ต่อไป

ตัวอย่างเทสเคสแรก

เราจะสร้างไดเร็กทอรี backend/tests เพื่อเก็บไฟล์ test case ต่างๆ จากนั้นให้สร้างไฟล์ backend/tests/test_models.py ที่มีเทสเคสตัวอย่างดังนี้

def test_add():
    assert 2+3 == 5

สังเกตว่าฟังก์ชันชื่อขึ้นต้นด้วย test และอยู่ในไฟล์ที่ชื่อขึ้นต้นด้วย test ซึ่งเป็นกฎที่ pytest ใช้ในการหา test functions

เมื่อเขียนเสร็จแล้ว ให้ลองเรียก (เรียกจากไดเร็กทอรี backend)

pytest

จะเห็นผลลัพธ์ดังนี้

============================= test session starts ==============================
platform linux -- Python 3.13.7, pytest-8.3.4, pluggy-1.6.0
rootdir: /home/jittat/prog/test/flask-react-todo-start/backend
collected 1 item                                                               

tests/test_models.py .                                                   [100%]

============================== 1 passed in 0.01s ===============================

แสดงว่าเทสทำงานถูกต้อง ให้ลองแก้ฟังก์ชันด้านบนเป็น

def test_add():
    assert 2+3 == 6

แล้วลองเรียก pytest อีกครั้ง จะเห็นว่ามีการฟ้องว่าทดสอบไม่ผ่าน

ให้ลบฟังก์ชัน test_add ออกก่อน เพราะเราจะทดลองเพิ่มฟังก์ชันเทสแบบจริงจัง

ทดสอบการตรวจสอบรหัสผ่าน

เราจะทดสอบฟังก์ชัน set_password และ check_password ที่เราเขียนไว้ในคลาส User โดยเราจะสร้าง test functions เพื่อทดสอบสองฟังก์ชันดังนี้

def test_check_correct_password():
    user = User()
    user.set_password("testpassword")
    assert user.check_password("testpassword") == True

def test_check_incorrect_password():
    user = User()
    user.set_password("testpassword")
    assert user.check_password("testpassworx") == False

แต่ฟังก์ชันทั้งสองยังไม่สามารถทำงานได้ เพราะว่าเราต้อง import คลาส User ให้ได้ก่อน แต่ test cases เราอยู่ในไดเร็กทอรี tests ซึ่งทำให้การสั่ง import ตรง ๆ จะทำไม่ได้

เพื่อความสะดวกในการจัดการ test และยังจำเป็นในการสร้าง test database เราจะเพิ่มโค้ดเพื่อตั้งค่าและเตรียมสภาพแวดล้อมเพื่อการทดสอบเสียก่อน

ให้สร้างไฟล์ชื่อ tests/conftest.py ซึ่งเป็นไฟล์สำหรับตั้งค่าของ pytest โดยใส่คำสั่งดังนี้

import sys
from pathlib import Path

PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
    sys.path.insert(0, str(PROJECT_ROOT))

คำสั่งดังกล่าวจะเพิ่ม path ของ backend ลงไปใน sys.path ทำให้สามารถ import คลาส User ได้

จากนั้นให้แก้ไฟล์ tests/test_models.py ให้เป็น

from models import User

def test_check_correct_password():
    user = User()
    user.set_password("testpassword")
    assert user.check_password("testpassword") == True

def test_check_incorrect_password():
    user = User()
    user.set_password("testpassword")
    assert user.check_password("testpassworx") == False

แล้วทดลองเรียก pytest เพื่อทำงาน และดูผลลัพธ์การทดสอบ

ฐานข้อมูลสำหรับทดสอบ

สมมติว่าเราต้องการทดสอบโมเดล TodoItem ว่ามี comments ติดมาด้วยเวลาเราเรียกใช้ เราจำเป็นจะต้องมีข้อมูลตัวอย่างที่คงเส้นคงวาในการทดสอบ อย่างไรก็ตาม ในโค้ดปัจจุบันที่เราเขียน เมื่อเราเรียก pytest โค้ดของเราจะทำงานกับฐานข้อมูลหลักที่เราใช้ ซึ่งอาจมีค่าไม่แน่นอน ทำให้การเขียน test case ทำแทบไม่ได้ นอกจากนี้ ถ้าเราทดสอบลบหรือแก้ไข TodoItem ก็อาจจะทำให้ข้อมูลจริงเปลี่ยนไปได้ด้วย

เราจะจัดการกับปัญญหานี้โดยสร้างฐานข้อมูลสำหรับ test ขึ้นมาใหม่โดยเฉพาะเมื่อเริ่มต้นทดสอบ และจะทำลายทิ้งเมื่อทดสอบเสร็จ

เราจะใช้ fixtures ใน pytest เพื่อจัดการขั้นตอนการกำหนดค่าเริ่มต้นก่อนเทสและการเก็บกวาดหลังเทส

เราจะเพิ่มบรรทัดต่อไปนี้ลงใน conftest.py (ต่อจากโค้ดเดิมที่เพิ่ม sys.path)

import pytest

from main import app as flask_app
from models import db

@pytest.fixture
def app():
    flask_app.config.update(
        {
            'TESTING': True,
            'SQLALCHEMY_DATABASE_URI': f'sqlite:///:memory:',
        }
    )

    with flask_app.app_context():
        db.drop_all()
        db.create_all()

    return flask_app

@pytest.fixture
def client(app):
    return app.test_client()

@pytest.fixture
def app_context(app):
    with app.app_context():
        yield

โค้ดข้างต้นจะสร้าง fixtures สาม fixture คือ

  • app - จะสร้าง flask app ใหม่ โดยปรับโหมดเป็น testing และเปลี่ยน SQLALCHEMY_DATABASE_URI ให้เป็น database แบบ in-memory (คือเก็บฐานข้อมูลทั้งหมดในหน่วยความจำ ซึ่งจะทำงานได้รวดเร็วกว่าการสร้างฐานข้อมูลเป็นไฟล์โดยตรง)
  • client (ซึ่งจะสร้างจาก fixture app) - จะสร้าง client สำหรับทดสอบ route ต่าง ๆ
  • app_context (ซึ่งจะสร้างจาก fixture app) - สร้าง app_context สำหรับทดสอบโมเดลที่ทำงานกับฐานข้อมูล

เมื่อระบุ fixture แล้ว เราสามารถระบุให้ test case เรียกใช้ (หรือทำงานภายใต้) fixture ที่ระบุได้ โดยใส่ fixture ไว้ในพารามิเตอร์ ดังจะได้ดูตัวอย่างต่อไป

Test แรก ใน application context

เราจะทดสอบโค้ดของโมเดลเพิ่มเติม ก่อนอื่นให้เพิ่ม import ใน test_models.py

from models import TodoItem, Comment, db

ให้ลองเพิ่ม test ด้านล่างลงใน test_models.py

def test_empty_todoitem():
    assert TodoItem.query.count() == 0

เมื่อเรียก pytest จะพบ error ยาวๆ แต่สรุปท้ายดังนี้

========================== short test summary info ===========================
FAILED tests/test_models.py::test_empty_todoitem - RuntimeError: Working 
outside of application context.
=================== 1 failed, 3 passed, 1 warning in 2.44s ===================

เพราะว่าการเรียกใช้ db ต่าง ๆ ต้องทำภายใต้บริบทของ Flask app ให้เราแก้ฟังก์ชันดังกล่าว โดยระบุให้มีการเรียก fixture app_context ดังด้านล่าง

def test_empty_todoitem(app_context):
    assert TodoItem.query.count() == 0

แล้วเรียก pytest จะพบว่าทำงานได้แล้ว แต่อาจจะมี warning LegacyAPIWarning ซึ่งไม่เป็นปัญหาอะไร (เพราะว่าเราเลือกจะใช้ SQLAlchemy API ในรูปแบบโบราณ)

Test โมเดลเพิ่มเติม

def create_todo_item_1():
    todo = TodoItem(title='Todo with comments', done=True)
    comment = Comment(message='Nested', todo=todo)
    db.session.add_all([todo, comment])
    db.session.commit()
    return todo

def test_todo_to_dict_includes_nested_comments(app_context):
    todo = create_todo_item_1()
    id = todo.id

    test_todo = TodoItem.query.get(id)
    assert len(test_todo.comments) == 1

ทดสอบ view functions: จัดการกับ jwt

เราจะเรียกฟังก์ชันต่าง ๆ ที่รับ request ใน main.py ว่าเป็น view function    ฟังก์ชันเหล่านี้จะรับ request ดังนั้นการจะเรียกใช้เพื่อทดสอบ จะทำโดยการจำลองการส่ง request ไปที่ฟังก์ชันเหล่านี้ ผ่านทาง client (ที่ทำตัวเหมือนเป็น browser)

ฟังก์ชันแรกที่เราจะทดสอบคือฟังก์ชัน get_todos ที่มีโค้ดแสดงดังด้านล่าง

# ตัดมาให้ดูเฉย ๆ ยังไม่ต้องแก้
@app.route('/api/todos/', methods=['GET'])
@jwt_required()
def get_todos():
    todos = TodoItem.query.all()
    return jsonify([todo.to_dict() for todo in todos])

เราจะสร้างไฟล์ backend/tests/test_views.py จากนั้นให้เพิ่มฟังก์ชัน test_get_empty_todo_items ดังด้านล่าง

from http import HTTPStatus

def test_get_empty_todo_items(client):
    response = client.get('/api/todos/')
    assert response.status_code == HTTPStatus.OK
    assert response.get_json() == []

ฟังก์ชันดังกล่าว จะเรียก request ไปที่ /api/todos แบบ GET และคาดหวังว่าจะได้ status_code เป็น OK และได้ผลลัพธ์เป็นรายการเปล่า ๆ

อย่างไรก็ตาม เมื่อเรียก pytest จะพบ error ดังนี้

====================================== FAILURES ======================================
________________________________ test_get_todos_empty ________________________________

client = <FlaskClient <Flask 'main'>>

    def test_get_empty_todo_items(client):
        response = client.get('/api/todos/')
>       assert response.status_code == HTTPStatus.OK
E       assert 401 == <HTTPStatus.OK: 200>
E        +  where 401 = <WrapperTestResponse streamed [401 UNAUTHORIZED]>.status_code
E        +  and   <HTTPStatus.OK: 200> = HTTPStatus.OK

tests/test_views.py:16: AssertionError
============================== short test summary info ===============================
FAILED tests/test_views.py::test_get_empty_todo_items - assert 401 == <HTTPStatus.OK: 200>
======================= 1 failed, 4 passed, 1 warning in 3.74s =======================

เราได้รับ 401 กลับมา โดยที่ view function ยังไม่ทันได้ทำงาน เพราะว่าการเรียกของเราไม่ผ่านการตรวจสอบ jwt token (ที่ decorator @jwt_required)

เราจะ bypass การทดสอบนี้ โดยไป mock ฟังก์ชัน verify_jwt_in_request (ซึ่งเป็นฟังก์ชันภายใน @jwt_required) การจัดการดังกล่าวค่อนข้างยุ่งเล็กน้อย หลัก ๆ เราจะใช้ fixture พิเศษของ pytest ชื่อ monkeypatch เพื่อ patch ฟังก์ชันดังกล่าวให้เป็นฟังก์ชัน no_verify ที่เรากำหนดไว้แทน แต่เนื่องจากถ้าถัด ๆ ไป เราอาจจะมีการเรียกถาม get_jwt_identity โค้ดด้านล่างจึงสร้าง identity ปลอมชื่อ test_user ไว้ด้วย เผื่ออาจจะจำเป็นต้องใช้ในอนาคต

ให้แปะโค้ดนี้ก่อนฟังก์ชัน test_get_empty_todo_items แล้วลองเรียก pytest อีกครั้ง

import pytest
from flask import g
from flask_jwt_extended.config import config

@pytest.fixture(autouse=True)
def no_jwt(monkeypatch):
    # from https://github.com/vimalloc/flask-jwt-extended/issues/171
    def no_verify(*args, **kwargs):
        g._jwt_extended_jwt = {
            config.identity_claim_key: 'test_user'
        }

    from flask_jwt_extended import view_decorators

    monkeypatch.setattr(view_decorators, 'verify_jwt_in_request', no_verify)

สังเกตว่า fixture ด้านบนถูกกำหนดให้ทำงานโดยอัตโนมัติด้วย (autouse=True) ทำให้ถัด ๆ ไปเราไม่จำเป็นต้องระบุ fixture นี้ในการเขียนเทสฟังก์ชัน

ทดสอบ view functions: สร้างข้อมูลทดสอบและ fixture

เราจะทดสอบกรณีที่มี todo item บ้างแล้ว ในการทดสอบดังกล่าว เราจะต้องใส่โค้ดเพื่อเพิ่ม todo item เข้าไป ก่อนที่จะเรียก client.get('/api/todos/') เพื่อความสะดวกในการแก้ไข เราจะสร้างฟังก์ชัน create_todo เพื่อการสร้างนี้ และเรียกใช้ฟังก์ชันดังกล่าวในฟังก์ชัน test_get_sample_todo_items ดังด้านล่าง

from models import TodoItem, db

def create_todo(title='Sample todo', done=False):
    todo = TodoItem(title=title, done=done)
    db.session.add(todo)
    db.session.commit()
    return todo

def test_get_sample_todo_items(client, app_context):
    todo1 = create_todo(title='Todo 1', done=False)
    todo2 = create_todo(title='Todo 2', done=True)
    response = client.get('/api/todos/')
    assert response.status_code == HTTPStatus.OK
    assert response.get_json() == [todo.to_dict() for todo in [todo1, todo2]]

ให้ทดลองเรียก pytest เพื่อทดสอบ

สังเกตว่าถ้าเราต้องการจะทดสอบต่อ ๆ ไป เรามักจะต้องการให้มี todo item อยู่บ้างเพื่อความสะดวกในการทดสอบ ถ้าเราต้องเขียนคำสั่ง create_todo ซ้ำ ๆ ในทุก ๆ test มันคงจะดูซ้ำซ้อนและทำให้เขียนเทสยาก

เราจะสร้าง fixture ชื่อ sample_todo_items ที่ทำงานดังกล่าวและคืนรายการของ todo item กลับมาดังด้านล่าง

@pytest.fixture
def sample_todo_items(app_context):
    todo1 = create_todo(title='Todo 1', done=False)
    todo2 = create_todo(title='Todo 2', done=True)
    return [todo1, todo2]

เราจะนำ fixture ดังกล่าวไปทำให้ฟังก์ชัน test_get_sample_todo_items ระชับขึ้น โดยสามารถแก้ฟังก์ชดังกล่าวได้ดังนี้

def test_get_sample_todo_items(client, sample_todo_items):
    # ภายในโค้ดนี้ sample_todo_items จะประกอบด้วย todo1 และ todo2 ที่ส่งมา
    response = client.get('/api/todos/')
    assert response.status_code == HTTPStatus.OK
    assert response.get_json() == [todo.to_dict() for todo in sample_todo_items]  # ไล่ดู todo ในรายการ sample_todo_items

หมายเหตุ: สังเกตว่าเราเรียกใช้ todo จาก sample_todo_items ได้เลย

เราสามารถเขียนฟังก์ชัน test_toggle_todo_item เพื่อทดสอบการ toggle ได้ดังด้านล่าง โดยฟังก์ชันนี้ก็ใช้ fixture sample_todo_items ด้วยเช่นกัน

def test_toggle_todo_item(client, sample_todo_items):
    item1, item2 = sample_todo_items

    response = client.patch(f'/api/todos/{item1.id}/toggle/')
    assert response.status_code == HTTPStatus.OK

    data = response.get_json()
    assert data['done'] is True
    assert TodoItem.query.get(item1.id).done is True

ถ้าสามารถเทสได้เรียบร้อย อย่าลืม

🄶 commit งานที่ทำ

Github action

เราจะเพิ่ม github action ให้มีการรัน test ในส่วน backend เมื่อมีการ push ขึ้นไปที่ repository

เก็บกวาด requirements.txt

เราจะต้องรับประกันว่า package ที่เราจะใช้สามารถติดตั้งบน server ที่จะรันเทสได้อย่างครบถ้วน ดังนั้นเราควรจะรัน

pip freeze > requirements.txt

ใน backend (อย่าลืมเรียก activate virtual env ก่อนด้วย)

เพิ่ม action

ด้านล่างเป็นไฟล์ .github/workflows/backend-tests.yml

name: Backend Tests

on:
  push:
    paths:
      - 'backend/**'
      - '.github/workflows/backend-tests.yml'
  pull_request:
    paths:
      - 'backend/**'
      - '.github/workflows/backend-tests.yml'
  workflow_dispatch:

jobs:
  test-backend:
    runs-on: ubuntu-latest

    defaults:
      run:
        working-directory: backend

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.13'
          cache: 'pip'
          cache-dependency-path: 'backend/requirements.txt'

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt

      - name: Run pytest
        run: pytest -q

ถ้าเราลองอ่าน workflow ดังกล่าวจะพบว่ามีขั้นตอนการทำงานไม่ต่างจาก github action เดิมของเราเท่าใดนัก กล่าวคือ จะมีการติดตั้งไลบรารีด้วย pip จากนั้นเรียกให้ pytest ทำงาน

เมื่อเสร็จแล้วให้ add ไฟล์ที่เพิ่ม รวมทั้งไฟล์ workflow ใหม่นี้ด้วย (อย่าลืมดู requirements.txt) จากนั้นให้...

🄶 commit งานที่ทำ แล้ว push กลับไปที่ github ด้วย อย่าลืมรอดูผล Backend Tests

ถ้ารันได้เรียบร้อยน่าจะเห็นลักษณะนี้

233-githubaction-backend.png