2026年8月22日 星期六

用Postman與pytest 驗證智慧養殖API

封包旅行記 EP06|從人工驗證到自動化測試

用Postman與pytest
驗證智慧養殖API

「Postman回201」不等於API已經可靠。智慧養殖API還要證明:未授權請求會被拒絕、異常水質資料不會寫入、裝置不能冒用別座魚塭、斷線重送不會產生重複紀錄,而且每次改版都能自動重測。

一、Postman與pytest不是二選一

Postman探索人工送出請求,快速觀察Header、JSON、狀態碼與回應內容。
Postman契約把環境變數、Request與斷言整理成Collection,形成可分享規格。
pytest回歸每次修改Serializer、View或Model後,自動執行相同測試。
CI守門測試失敗就阻止不合格版本進入場域或正式主機。
Postman看懂請求寫出預期結果pytest固定成測試持續整合自動執行

核心觀念:測試不是證明「程式沒有錯」,而是累積可重複的證據,證明已知的重要行為仍符合API契約。

二、先定義智慧養殖API契約

本文以POST /api/v1/readings/為例。每台ESP32使用自己的Token,送出一筆水質資料:

{
  "event_uuid": "POND01-20260822-000001",
  "device_id": "POND01-ESP32-01",
  "measured_at": "2026-08-22T07:30:00+08:00",
  "water_temp_c": 28.4,
  "dissolved_oxygen_mg_l": 5.8,
  "ph": 7.6
}
情境預期狀態必須驗證
合法Token+合法資料201 Created回傳event_uuid,資料庫只增加一筆
沒有Token或Token錯誤401 Unauthorized資料庫不變
合法Token但冒用別台device_id403 Forbidden不能跨裝置寫入
缺欄位、錯型別或超出合理範圍400 Bad Request錯誤指出欄位,資料庫不變
相同event_uuid重送依契約採200409或其他明確結果資料庫仍只有一筆,行為必須一致

狀態碼要先約定:例如重複event_uuid究竟回200或409,沒有唯一答案;團隊必須先定義契約,再讓Postman與pytest依同一規則驗證。

三、Postman第一步:建立不洩密的環境

變數開發環境例用途
base_urlhttp://127.0.0.1:8000切換本機、測試站與正式站
device_token不在教材顯示真值Authorization Header
device_idPOND01-ESP32-01測試裝置身分
event_uuid由Pre-request Script產生每次測試的唯一事件
POST {{base_url}}/api/v1/readings/
Authorization: Token {{device_token}}
Content-Type: application/json

不要把真實Token寫進Collection、截圖或Git。敏感值應保存在個人/祕密儲存範圍;分享或匯出前先檢查。測試Token也只應擁有測試裝置的最小權限。

四、Postman第二步:讓每次Request自動產生UUID

在Request的Pre-request Script中產生測試值,避免手動修改造成誤判:

const id = pm.variables.replaceIn("{{$guid}}");
const eventUuid = `POSTMAN-${id}`;

pm.collectionVariables.set("event_uuid", eventUuid);
pm.collectionVariables.set("measured_at", new Date().toISOString());
{
  "event_uuid": "{{event_uuid}}",
  "device_id": "{{device_id}}",
  "measured_at": "{{measured_at}}",
  "water_temp_c": 28.4,
  "dissolved_oxygen_mg_l": 5.8,
  "ph": 7.6
}

UUID只用來避免碰撞,不應被當成授權憑證。伺服器仍需依Token確認裝置身分。

五、Postman第三步:不要只看綠色的201

在Post-response Script加入斷言,讓Postman自動判定是否符合契約:

pm.test("建立成功:HTTP 201", function () {
  pm.response.to.have.status(201);
});

pm.test("回應為JSON", function () {
  pm.expect(pm.response.headers.get("Content-Type"))
    .to.include("application/json");
});

pm.test("回傳正確event_uuid", function () {
  const data = pm.response.json();
  pm.expect(data.event_uuid).to.eql(
    pm.collectionVariables.get("event_uuid")
  );
});

pm.test("回應時間在教學門檻內", function () {
  pm.expect(pm.response.responseTime).to.be.below(1500);
});

效能門檻要看環境:本機、校園網路與PythonAnywhere的延遲不同。應先建立基準,再設定合理門檻;單次responseTime不能取代正式負載測試。

最少建立六個Request

01 Create valid reading

合法Token、資料與201。

02 Missing token

移除Authorization,預期401且不寫入。

03 Wrong device

合法Token冒用別台device_id,預期403。

04 Missing field

缺少event_uuid或measured_at,預期400。

05 Out-of-range value

例如pH 99,預期400。

06 Duplicate UUID

相同Body送兩次,確認不產生兩筆。

六、從Postman案例轉成pytest測試矩陣

測試面向正向案例反向/邊界案例
Authentication有效Token缺Token、錯Token、撤銷Token
Authorization寫入自己裝置冒用其他device_id
Validation正常水溫、DO、pH缺欄位、字串代替數字、NaN、極端值
Idempotency新event_uuid相同event_uuid重送
Time含時區ISO 8601未來過久、過舊、格式錯誤
Persistence成功後增加一筆4xx後資料庫完全不變

七、pytest+pytest-django基本設定

pip install pytest pytest-django djangorestframework
# pytest.ini
[pytest]
DJANGO_SETTINGS_MODULE = config.settings
python_files = tests.py test_*.py *_tests.py
addopts = -q

pytest-django預設禁止未宣告的資料庫存取。需要資料庫的測試加上@pytest.mark.django_db或使用dbfixture;測試資料庫與正式資料庫應完全分離。

絕對不要讓測試指向正式智慧養殖資料庫。測試會建立、修改與回滾資料;部署前應再次確認測試settings、環境變數及資料庫名稱。

八、用Fixture建立可重複的裝置身分

# tests/conftest.py
import pytest
from django.contrib.auth import get_user_model
from rest_framework.authtoken.models import Token
from rest_framework.test import APIClient


@pytest.fixture
def device_user(db):
    User = get_user_model()
    return User.objects.create_user(
        username="pond01_device",
        password="test-only-password",
    )


@pytest.fixture
def token(device_user):
    return Token.objects.create(user=device_user)


@pytest.fixture
def api_client(token):
    client = APIClient()
    client.credentials(HTTP_AUTHORIZATION=f"Token {token.key}")
    return client


@pytest.fixture
def valid_payload():
    return {
        "event_uuid": "TEST-POND01-0001",
        "device_id": "POND01-ESP32-01",
        "measured_at": "2026-08-22T07:30:00+08:00",
        "water_temp_c": 28.4,
        "dissolved_oxygen_mg_l": 5.8,
        "ph": 7.6,
    }

實際專案需再建立「使用者/Token與device_id的授權關聯」。本文Fixture為教學骨架,請依你的Device Model與Permission調整。

九、第一組pytest:成功、未授權與資料庫結果

# tests/test_readings_api.py
import pytest
from rest_framework.test import APIClient
from aquaculture.models import PondReading

URL = "/api/v1/readings/"


@pytest.mark.django_db
def test_create_valid_reading(api_client, valid_payload):
    response = api_client.post(URL, valid_payload, format="json")

    assert response.status_code == 201
    assert response.data["event_uuid"] == valid_payload["event_uuid"]
    assert PondReading.objects.filter(
        event_uuid=valid_payload["event_uuid"]
    ).count() == 1


@pytest.mark.django_db
def test_reject_request_without_token(valid_payload):
    client = APIClient()
    before = PondReading.objects.count()

    response = client.post(URL, valid_payload, format="json")

    assert response.status_code == 401
    assert PondReading.objects.count() == before

三層斷言:同時檢查HTTP狀態、Response Body與Database State。只檢查其中一層,很容易把「回應看似成功但沒有存檔」或「拒絕了請求卻留下髒資料」漏掉。

十、參數化測試:一次驗證多個不合法數值

import pytest
from aquaculture.models import PondReading


@pytest.mark.django_db
@pytest.mark.parametrize(
    ("field", "bad_value"),
    [
        ("water_temp_c", -20),
        ("water_temp_c", 80),
        ("dissolved_oxygen_mg_l", -1),
        ("ph", -0.1),
        ("ph", 14.1),
        ("ph", "not-a-number"),
    ],
)
def test_reject_invalid_water_values(
    api_client, valid_payload, field, bad_value
):
    payload = {**valid_payload, field: bad_value}
    payload["event_uuid"] = f"BAD-{field}-{str(bad_value)}"
    before = PondReading.objects.count()

    response = api_client.post(URL, payload, format="json")

    assert response.status_code == 400
    assert field in response.data
    assert PondReading.objects.count() == before

合理範圍不是自然常數:上述界線只是測試示例。應由養殖專業、感測器規格與場域需求共同定義,並區分「資料不合法」與「數值合法但需告警」。

十一、驗證斷線重送:相同UUID不能重複入庫

@pytest.mark.django_db
def test_duplicate_event_is_idempotent(api_client, valid_payload):
    first = api_client.post(URL, valid_payload, format="json")
    second = api_client.post(URL, valid_payload, format="json")

    assert first.status_code == 201
    assert second.status_code in (200, 409)  # 依團隊契約固定成其中一種
    assert PondReading.objects.filter(
        event_uuid=valid_payload["event_uuid"]
    ).count() == 1

資料庫Model應對event_uuid建立唯一約束,不能只靠「先查再寫」;並發請求可能同時通過查詢。View/Serializer需捕捉重複情況並回傳團隊約定結果。

# models.py(概念示例)
class PondReading(models.Model):
    event_uuid = models.CharField(max_length=80, unique=True)
    device_id = models.CharField(max_length=80)
    measured_at = models.DateTimeField()
    water_temp_c = models.FloatField()
    dissolved_oxygen_mg_l = models.FloatField()
    ph = models.FloatField()

十二、不要用force_authenticate取代所有安全測試

DRF的force_authenticate()可快速繞過認證流程,適合專注測試View邏輯;但若所有測試都使用它,就無法驗證Token Header、401與真正的Authentication設定。

方法適合限制
client.credentials(...)驗證真實TokenAuthentication流程需建立Token,較接近整合測試
client.force_authenticate(user=...)隔離測試View、Serializer與Permission邏輯不會證明Authorization Header真的可用
Postman呼叫測試站驗證URL、TLS、Proxy、部署與完整HTTP路徑速度較慢,資料與環境需管理

十三、如何讀懂pytest失敗訊息

失敗先判斷不要急著做
預期201,實際401Token、Authentication Class、Header格式不要把Permission關掉
預期400,實際201Serializer是否真的驗證範圍不要改測試去接受201
資料庫筆數多一筆重複UUID、Transaction與錯誤流程不要只刪掉筆數斷言
單獨通過、整批失敗測試共享狀態、固定UUID、順序依賴不要依靠測試執行順序
本機通過、部署失敗環境變數、資料庫、時區、Proxy與套件版本不要直接在正式站手動改資料

十四、建議的專案測試目錄

project/
├─ aquaculture/
│  ├─ models.py
│  ├─ serializers.py
│  ├─ permissions.py
│  └─ views.py
├─ tests/
│  ├─ conftest.py
│  ├─ test_authentication.py
│  ├─ test_permissions.py
│  ├─ test_reading_validation.py
│  ├─ test_reading_idempotency.py
│  └─ test_reading_api.py
├─ pytest.ini
└─ requirements.txt

依失敗原因分類,比把所有測試塞進一個檔案更容易維護。測試名稱應直接描述行為,例如test_device_cannot_write_other_pond

十五、學生實作:從會按Send到能守住品質

No-AI|人工建立證據

完成六個Postman Request;每個案例記錄Request、預期狀態碼、實際回應與資料庫是否改變,並解釋原因。

AI Pair|測試案例審查

讓AI補充邊界案例,但學生要逐項判斷是否符合智慧養殖語意;把合理案例轉成Postman斷言與pytest。

Challenge AI|故意破壞API

教師提供含漏洞的Serializer或Permission;學生先寫會失敗的測試,再修程式直到通過,並證明修補沒有破壞其他功能。

十六、驗收清單

檢核項目通過證據
Postman環境可切換base_url與Token不寫死,匯出檔無祕密
正向與反向案例齊全至少涵蓋201、400、401、403與重複UUID
三層斷言HTTP、Response Body與Database State一致
測試彼此獨立任意順序與單獨執行都能通過
正式資料不受影響使用獨立測試資料庫與測試裝置憑證
修改後自動回歸pytest完整通過後才允許部署

十七、延伸閱讀(官方文件)

工具介面與API會隨版本更新;實作時應以目前安裝版本的官方文件為準。

封包旅行記 EP06|Postman讓我們看見一次請求;pytest讓每一次改版都重新證明:智慧養殖API仍然可信。

沒有留言:

張貼留言