tva
← บทความเชิงลึก

การทดสอบ backend หลายสิบกรณีใน PR เดียว: กลยุทธ์สำหรับ FastAPI

คำอธิบาย pull request ระบุว่า “เพิ่มชุดทดสอบ — 52 กรณี ครอบคลุมการยืนยันตัวตน ชั้นข้อมูล และ API” ชุดนี้เพิ่มหลัง backend ของ FastAPI ที่ทดสอบนั้นใช้งานจริงมาสามเดือน ซึ่งไม่ใช่วิธีที่ควรทำหรือสิ่งที่ควรฉลอง แต่ควรพิจารณาอย่างตรงไปตรงมา เพราะการเพิ่มทดสอบช้าต่างจากไม่เพิ่มเลยอย่างมาก ความต่างนั้นคือความต่างระหว่างฐานโค้ดที่ดูแลต่อได้กับฐานโค้ดที่ต้องเขียนใหม่ในที่สุด

บทความนี้อธิบายสถาปัตยกรรมทดสอบที่เราเลือก รูปแบบเฉพาะที่ใช้ได้ยั่งยืนในโครงการ FastAPI จริง และสิ่งที่ PR มีนอกเหนือจากจำนวนกรณีทดสอบ

บริบทของโครงการ

backend เป็นบริการ FastAPI ที่ดูแลงานสำหรับลูกค้าของผู้ประกอบการอีคอมเมิร์ซ ทั้งการจัดการคำสั่งซื้อ การค้นสินค้าคงคลัง กฎราคา และการเชื่อมต่อ API โลจิสติกส์ภายนอก ใช้ฐานข้อมูล PostgreSQL ผ่าน SQLAlchemy ที่รองรับการทำงานแบบอะซิงโครนัส ยืนยันตัวตนด้วย JWT โดยตรวจโทเคนกับบริการยืนยันตัวตนแยกต่างหาก ระหว่างการทำงานปกติเรียก API ภายนอกสามตัว ได้แก่ ผู้ให้บริการโลจิสติกส์ บริการแปลงสกุลเงิน และแหล่งข้อมูลสินค้า

สามเดือนของการใช้งานจริงหมายถึงสามเดือนของการแก้ข้อผิดพลาด เพิ่มฟีเจอร์ และเปลี่ยนแปลงทีละส่วน โดยไม่มีทดสอบเลย ไม่ใช่ว่าฐานโค้ดนี้ทดสอบไม่ได้ แต่สร้างโดยคำนึงถึงการทดสอบ: ส่ง dependency เข้ามา ไม่มีสถานะส่วนกลาง และสร้างเซสชันฐานข้อมูลต่อคำขอผ่านระบบ dependency injection ของ FastAPI เพียงแต่ยังไม่ได้เขียนทดสอบ PR ที่มีขอบเขตชัดจึงเหมาะ ไม่ใช่เพราะควรเพิ่มความครอบคลุมทีละมาก ๆ แต่เพราะ “PR เดียวที่มีทดสอบ” จะถูกตรวจ รวม และดูแลต่อ ขณะที่งาน “ค่อย ๆ เพิ่มทดสอบ” มักค้างในรายการงานไม่มีกำหนด

ลำดับชั้นของ fixture

ระบบ fixture ของ pytest ให้ผลดีเมื่อออกแบบอย่างตั้งใจ สำหรับโครงการ FastAPI ที่ใช้ฐานข้อมูลจริง ลำดับชั้นตามธรรมชาติแบ่งเป็นสามขอบเขต: session, module และ function โดยชุดทดสอบนี้ใช้ขอบเขต session และ function เป็นหลัก

fixture ระดับ session ทำงานครั้งเดียวต่อการรันทดสอบ เหมาะกับการสร้างฐานข้อมูล สร้างสคีมา (โครงสร้างข้อมูล) (ตัวอย่างด้านล่างใช้ Base.metadata.create_all ส่วนการย้ายสคีมาด้วย Alembic ก็ทำในขั้นตอนเดียวกันนี้ได้) และใส่ข้อมูลอ้างอิงที่ไม่เปลี่ยนระหว่างกรณีทดสอบ เอนจินฐานข้อมูลระดับ session ใช้ร่วมกันทุกกรณี จึงจ่ายต้นทุนการสร้างสคีมาครั้งเดียว ไม่ใช่ทุกไฟล์ทดสอบ

fixture ระดับ function ให้แต่ละกรณีมีสถานะแยกจากกัน ตัวสำคัญคือ fixture ที่ย้อนกลับธุรกรรม: เปิดธุรกรรมตอนเริ่มกรณีทดสอบและย้อนกลับตอนจบ การเขียนข้อมูลทั้งหมด ทั้งสร้างผู้ใช้ เพิ่มคำสั่งซื้อ และปรับราคา ถูกย้อนกลับอัตโนมัติ แต่ละกรณีจึงแยกจากกันได้โดยไม่ต้องล้างตารางระหว่างการรัน

import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker

@pytest_asyncio.fixture(scope="session")
async def engine():
    engine = create_async_engine(settings.TEST_DATABASE_URL, echo=False)
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    yield engine
    await engine.dispose()

@pytest_asyncio.fixture
async def db_session(engine):
    """Each test gets a transaction that rolls back on completion."""
    async with engine.begin() as conn:
        await conn.begin_nested()
        session_factory = sessionmaker(
            conn, class_=AsyncSession, expire_on_commit=False
        )
        async with session_factory() as session:
            yield session
            await session.rollback()

การเรียก begin_nested() สร้าง savepoint ภายในธุรกรรมชั้นนอก จุดประสงค์คือให้โค้ดทดสอบสามารถ commit ในเซสชันได้อย่างอิสระ โดยการย้อนกลับธุรกรรมชั้นนอกตอนเก็บกวาดจะยกเลิกการเปลี่ยนแปลงทั้งหมด แต่ fixture ที่แสดงยังทำเช่นนั้นได้ไม่สมบูรณ์: engine.begin() จะ commit เมื่อบล็อกจบตามปกติ และ session.rollback() จะย้อนกลับเฉพาะงานของเซสชันเอง หากต้องการแยกแต่ละกรณีทดสอบอย่างสมบูรณ์ ให้เปิดการเชื่อมต่อด้วย engine.connect() เริ่มธุรกรรมชั้นนอกด้วย conn.begin() ผูกเซสชันด้วย join_transaction_mode="create_savepoint" และสั่งย้อนกลับธุรกรรมชั้นนอกอย่างชัดเจนหลังจบการทดสอบ เมื่อตั้งค่าเช่นนี้ การเรียก session.commit() ในโค้ดแอปพลิเคชันจะปล่อย savepoint เท่านั้น และการย้อนกลับธุรกรรมชั้นนอกยังคงยกเลิกการเปลี่ยนแปลงเหล่านั้นได้

การจำลอง API ภายนอก

API ภายนอกสามตัวหมายถึงแหล่งที่อาจทำให้ผลทดสอบไม่คงที่สามแห่งเมื่อเรียกเครือข่าย วิธีมาตรฐานคือจำลองไคลเอนต์ HTTP ซึ่งทำได้ตรงไปตรงมาด้วย respx ที่ดักคำขอ httpx ในชั้นส่งข้อมูล

import respx
import httpx
import pytest

@pytest.mark.asyncio
async def test_shipping_estimate_returns_carrier_data(test_client, valid_token):
    mock_response = {
        "carrier": "DHL",
        "estimated_days": 3,
        "cost_eur": 8.50
    }

    with respx.mock:
        respx.post("https://api.logistics-provider.com/v2/rates").mock(
            return_value=httpx.Response(200, json=mock_response)
        )
        response = await test_client.post(
            "/api/v1/shipping/estimate",
            json={"destination_zip": "10115", "weight_kg": 2.5},
            headers={"Authorization": f"Bearer {valid_token}"}
        )

    assert response.status_code == 200
    assert response.json()["estimated_days"] == 3

ตัวจัดการบริบท respx.mock ส่งคำขอ httpx ขาออกทั้งหมดผ่านชั้นจำลอง คำขอที่ไม่ได้ลงทะเบียนอย่างชัดเจนจะทำให้เกิดข้อผิดพลาด ซึ่งเป็นค่าเริ่มต้นที่เหมาะสม การทดสอบที่เรียก API ใช้งานจริงโดยไม่ตั้งใจเป็นข้อผิดพลาดกลุ่มหนึ่งที่ตรวจพบยากและมีต้นทุนสูงเมื่อก่อผลข้างเคียง

สำหรับบริการแปลงสกุลเงิน ซึ่งถูกเรียกทุกคำสั่งซื้อที่ไม่ใช้ EUR มี fixture ร่วมให้ตัวกำหนดเส้นทาง respx ที่ตั้งค่าอัตราแลกเปลี่ยนมาตรฐานไว้แล้ว กรณีที่ทดสอบตรรกะค่าเงินจะแทนอัตราของคู่สกุลเงินเฉพาะ ส่วนกรณีที่ทดสอบพฤติกรรมอื่นแต่บังเอิญเรียก dependency ด้านค่าเงินใช้ค่าเริ่มต้นของ fixture ได้โดยไม่ต้องจัดการเอง การแยกเช่นนี้ทำให้เจตนาทดสอบชัด: ทดสอบการขนส่งก็ควรตรวจการขนส่ง ไม่ใช่อัตราแลกเปลี่ยนที่บังเอิญตั้งไว้

การทดสอบขั้นตอนยืนยันตัวตน

ระบบ dependency injection ของ FastAPI เป็นจุดแข็งสำคัญในการทดสอบ การยืนยันตัวตนในโครงการนี้เป็น dependency โดยทุกเส้นทางที่มีการป้องกันประกาศ current_user: User = Depends(get_current_user) ซึ่ง get_current_user ตรวจ JWT และคืนออบเจ็กต์ผู้ใช้ ในทดสอบ เราแทน dependency นี้ในระดับแอปพลิเคชัน:

from app.main import app
from app.dependencies import get_current_user
from app.models import User

def make_user(role: str = "operator") -> User:
    return User(id="test-user-id", email="[email protected]", role=role)

@pytest.fixture
def operator_client(db_session):
    app.dependency_overrides[get_current_user] = lambda: make_user("operator")
    client = TestClient(app)
    yield client
    app.dependency_overrides.clear()

@pytest.fixture
def admin_client(db_session):
    app.dependency_overrides[get_current_user] = lambda: make_user("admin")
    client = TestClient(app)
    yield client
    app.dependency_overrides.clear()

fixture แยกตามบทบาททำให้การทดสอบขอบเขตสิทธิ์ชัดเจน กรณีที่ยืนยันว่าผู้ปฏิบัติงานพยายามทำคำสั่งผู้ดูแลแล้วได้สถานะ 403 อ่านเข้าใจได้พอ ๆ กับกฎธุรกิจที่บังคับใช้ เมื่อ fixture ชื่อ operator_client และ admin_client ก็เห็นเจตนาทดสอบได้โดยไม่ต้องอ่านเนื้อหาข้างใน

ชุดทดสอบขนาดเล็กตรวจเส้นทางโค้ดตรวจ JWT โดยตรง ได้แก่ โทเคนหมดอายุ โทเคนรูปแบบผิด และโทเคนที่ระบุ audience ผิด กรณีเหล่านี้ไม่ใช้ fixture ที่แทน dependency แต่ส่งโทเคนดิบผ่านไคลเอนต์ทดสอบและยืนยันสถานะ 401 หรือ 403 เราแยกอย่างตั้งใจ: ทดสอบส่วนใหญ่สนใจพฤติกรรมหลังยืนยันตัวตนจึงใช้การแทน dependency ส่วนทดสอบการยืนยันตัวตนสนใจตรรกะนั้นโดยตรง การปะปนกันทำให้ไม่ชัดว่าแต่ละกรณีครอบคลุมอะไร

การทดสอบเส้นทางผิดพลาดอย่างตั้งใจ

ทดสอบเส้นทางสำเร็จเขียนง่าย และมักเป็นทดสอบชนิดเดียวที่เขียนเมื่อเวลาจำกัด แต่ข้อผิดพลาดที่หลุดไปใช้งานจริงมักไม่อยู่ในเส้นทางนี้ สำหรับฐานโค้ดนี้ พฤติกรรมที่ยังไม่ทดสอบและมีผลมากที่สุดอยู่ในการจัดการข้อผิดพลาด: เกิดอะไรขึ้นเมื่อ API โลจิสติกส์คืน 503? API คืนอะไรเมื่อฟิลด์คำขอที่จำเป็นขาดไป? และเกิดอะไรขึ้นเมื่อการแก้ไขพร้อมกันละเมิดข้อจำกัดฐานข้อมูล?

สำหรับการจำลอง API ภายนอกแต่ละตัว เราใส่กรณีผิดพลาดอย่างน้อยหนึ่งกรณีคู่กับกรณีสำเร็จ fixture ของ API โลจิสติกส์มีแบบที่คืน 503 เพื่อยืนยันว่า endpoint คืนข้อผิดพลาดที่มีโครงสร้างและรหัสสถานะ HTTP เหมาะสม แทนปล่อยข้อยกเว้นให้กลายเป็น 500 ทดสอบข้อจำกัดฐานข้อมูลลองเพิ่มข้อมูลที่ขัดแย้งกัน และยืนยันว่า API คืน 409 แทนข้อผิดพลาดความถูกต้องของข้อมูลที่ไม่ได้จัดการ

ความครอบคลุมนี้ไม่ได้เกิดเองจากการเขียนทดสอบภายหลัง ต้องแจกแจงรูปแบบความล้มเหลวอย่างตั้งใจระหว่างออกแบบทดสอบ นี่เป็นเหตุผลที่ตรงไปตรงมาข้อหนึ่งของการเขียนทดสอบพร้อมโค้ดแทนเขียนทีหลัง เพราะแจกแจงรูปแบบความล้มเหลวได้ง่ายกว่าเมื่อการเขียนโค้ดยังสดใหม่และยังจำกรณีขอบได้

ผลตอบแทนจากการเพิ่มทดสอบช้า

การเพิ่มทดสอบช้าสามเดือนยังดีกว่าไม่เพิ่ม ด้วยเหตุผลที่ไม่ใช่แค่หลักการ ระหว่างเขียน PR พบข้อผิดพลาดเดิมสามอย่าง: ไม่ตรวจ null ของฟิลด์ที่อยู่แบบไม่บังคับ ทำให้เกิด 500 เมื่อคำสั่งซื้อไม่มีบรรทัดที่สองของที่อยู่เรียกเก็บเงิน การแบ่งหน้าคลาดหนึ่งตำแหน่งที่ปรากฏเฉพาะเมื่อผลลัพธ์ว่าง และ endpoint สร้างทรัพยากรคืนรหัสสถานะ HTTP ผิด คือ 200 แทน 201 ทั้งหมดอยู่ในระบบใช้งานจริงมาหลายเดือนโดยไม่มีผู้ใช้ร้องเรียน ซึ่งเป็นเหตุให้ยังไม่ถูกตรวจพบ แต่ทดสอบพบได้ในช่วงบ่ายเดียว

ผลตอบแทนที่ยั่งยืนกว่าคือเอกสารพฤติกรรม ชุดทดสอบที่ครอบคลุมขอบเขตการยืนยันตัวตน การเรียก API ภายนอก และเงื่อนไขผิดพลาด เป็นข้อกำหนดพฤติกรรมของ API ที่ยังถูกต้องตราบใดที่ทดสอบผ่าน กรณี “ผู้ปฏิบัติงานเข้าถึง endpoint ผู้ดูแลไม่ได้” ไม่ได้แค่จับความผิดพลาดจากการแก้โค้ด แต่สื่อเจตนาให้ผู้ที่มาอ่านฐานโค้ดต่อ สำหรับบริการที่จะมีผู้ดูแลคนอื่นแทนผู้เขียนเดิม ซึ่งทุกบริการย่อมเป็นเช่นนั้นในที่สุด ข้อกำหนดนี้มีค่ามากกว่าข้อผิดพลาดที่ตรวจพบตอนเขียนอย่างมาก

เหตุผลค้านการเพิ่มทดสอบช้าคือต้องรับต้นทุนก้อนใหญ่ล่วงหน้า: PR ขนาดใหญ่ ต้องใช้แรงตรวจอย่างเข้มข้น และเวลาที่อาจนำไปพัฒนาฟีเจอร์ ข้อนี้จริง แต่ทางเลือกอย่างการปล่อยฐานโค้ดไม่มีทดสอบ เพราะเวลาที่เหมาะจะเพิ่มไม่เคยมาถึง มีต้นทุนสะสมที่แทบไม่ได้วัด เพราะกระจายอยู่ในการเปลี่ยนแปลงทุกครั้งหลังจากนั้น ชุดทดสอบที่เพิ่มช้ามีต้นทุนครั้งเดียว ส่วนการไม่มีชุดทดสอบมีต้นทุนต่อเนื่อง

บทความที่เกี่ยวข้อง

บทความที่เกี่ยวข้อง