用Postman與pytest
驗證智慧養殖API
「Postman回201」不等於API已經可靠。智慧養殖API還要證明:未授權請求會被拒絕、異常水質資料不會寫入、裝置不能冒用別座魚塭、斷線重送不會產生重複紀錄,而且每次改版都能自動重測。
一、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_id | 403 Forbidden | 不能跨裝置寫入 |
| 缺欄位、錯型別或超出合理範圍 | 400 Bad Request | 錯誤指出欄位,資料庫不變 |
| 相同event_uuid重送 | 依契約採200、409或其他明確結果 | 資料庫仍只有一筆,行為必須一致 |
狀態碼要先約定:例如重複event_uuid究竟回200或409,沒有唯一答案;團隊必須先定義契約,再讓Postman與pytest依同一規則驗證。
三、Postman第一步:建立不洩密的環境
| 變數 | 開發環境例 | 用途 |
|---|---|---|
base_url | http://127.0.0.1:8000 | 切換本機、測試站與正式站 |
device_token | 不在教材顯示真值 | Authorization Header |
device_id | POND01-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
合法Token、資料與201。
移除Authorization,預期401且不寫入。
合法Token冒用別台device_id,預期403。
缺少event_uuid或measured_at,預期400。
例如pH 99,預期400。
相同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,實際401 | Token、Authentication Class、Header格式 | 不要把Permission關掉 |
| 預期400,實際201 | Serializer是否真的驗證範圍 | 不要改測試去接受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完整通過後才允許部署 |
十七、延伸閱讀(官方文件)
- Postman:Write scripts to test API response data
- Postman:Test script examples
- Postman:Environment variables
- pytest:How to use fixtures
- pytest:Parametrizing tests
- pytest-django:Database access
- Django REST Framework:Testing
工具介面與API會隨版本更新;實作時應以目前安裝版本的官方文件為準。
沒有留言:
張貼留言