iT邦幫忙

2025 iThome 鐵人賽

DAY 29
0
Modern Web

每天一點 API:打造我的生活小工具系列 第 29

Day 29 — 測試與除錯:讓 API 穩穩運作

  • 分享至 

  • xImage
  •  

在過去幾天,我已經完成了多個 API 整合成生活助手 App 的開發。

今天主要是學習讓這些 API 在各種不同情況下都能正常運作,並學會如何找到問題、分析原因,最後修正錯誤。

這一天的重點是學習測試和除錯,透過這些方法,能讓專案變得更穩定,更符合專業水準。

今天的小專案目標

今天我的目標是建立一個自動化測試腳本,用來測試我寫的三個 API 模組,分別是天氣、空氣品質和新聞 API。

我會測試兩個重要部分:

  • 功能測試(Functional Tests):確認這三個 API 都能正常工作,回傳正確的資料。

  • 負向測試(Negative Tests):故意輸入錯誤的參數,或模擬錯誤狀況,看看程式能不能正確地報錯,防止系統出錯時崩潰。

最後,我會把常見的錯誤訊息和對應的除錯方法整理出來,這樣以後遇到 API 問題時,可以更快找到原因並修正。這樣的練習能讓我的程式更穩定、更專業,也更容易維護。

實作流程

準備環境

  • 建立 day29 資料夾

  • 確保可匯入前面 Day28 的三個模組(weather、aqi、news)

  • 準備 .env

OWM_API_KEY=我們的金鑰
CITY=Taipei
NEWS_LIMIT=5

建立測試主程式

這支程式會自動呼叫三個 API,並顯示成功與失敗結果。

  • 匯入與基礎設定
import os, json, requests
from pathlib import Path
from datetime import datetime
from dotenv import load_dotenv
# 直接使用 Day28 的三個模組
from weather_client import get_weather, WeatherError
from aqi_client import get_aqi_by_coord, AQIError
from news_client import get_news, NewsError

ROOT = Path(__file__).resolve().parent
  • 小工具
def line(ch="—", n=54): print(ch * n)

def ok(msg):    print(f"[OK] {msg}")
def fail(msg):  print(f"[ERROR] {msg}")
def info(msg):  print(f"[INFO] {msg}")

def preview(text: str, n=120):
    """回應內文只看前 n 字,避免刷屏。"""
    s = text.strip().replace("\n", " ")
    return (s[:n] + "…") if len(s) > n else s

def assert_key(d: dict, key: str):
    if key not in d or d[key] is None:
        raise AssertionError(f"缺少或為 None:{key}")
  • 測試:天氣(正向)
def test_weather_ok(city="Taipei", units="metric", lang="zh_tw"):
    """功能測試:可以拿到城市天氣與座標"""
    w = get_weather(city, units=units, lang=lang)
    assert_key(w, "name"); assert_key(w, "temp")
    assert_key(w, "lat");  assert_key(w, "lon")
    ok(f"[Weather OK] {w['name']} {w['temp']} ({w['desc']})")
    return w

呼叫天氣 API,檢查關鍵欄位(城市、溫度、經緯度)皆存在;通過就回傳結果。

  • 測試:天氣(不存在城市,應拋錯)
def test_weather_bad_city():
    # 負向測試:不存在的城市要回報錯誤(404)
    bad = "__NO_SUCH_CITY__"
    try:
        get_weather(bad)
        fail("[Weather 404] 應該拋錯,但沒有")
    except WeatherError as e:
        ok(f"[Weather 404] 捕捉到預期錯誤:{e}")

用錯誤城市測試應產生 WeatherError;若沒有拋錯即視為失敗。

  • 測試:AQI(正向)
def test_aqi_ok(lat, lon, key):
    # 功能測試:用座標查 AQI
    aqi = get_aqi_by_coord(lat, lon, key=key)
    # aqi 可能偶爾沒有,但至少欄位存在
    assert "aqi" in aqi and "pm2_5" in aqi and "pm10" in aqi
    ok(f"[AQI OK] AQI={aqi['aqi']} PM2.5={aqi['pm2_5']} PM10={aqi['pm10']}")
    return aqi

用經緯度+金鑰呼叫 AQI,檢查必備欄位在;通過則列印數值並回傳。

  • 測試:新聞(正向)
def test_news_ok(limit=5):
    # 功能測試:能拿到新聞清單(免金鑰)
    news = get_news(limit=limit)
    assert "count" in news and "items" in news
    ok(f"[News OK] 共 {news['count']} 則,Top1:{news['items'][0]['title'] if news['count'] else '無'}")
    return news

最新新聞 N 則,確認 count/items 存在;印出總數與第一則標題。

  • 測試:逾時(負向)
def test_timeout():
    # 負向測試:超時(用 httpbin 製造 5 秒延遲,timeout=1)
    url = "https://httpbin.org/delay/5"
    try:
        requests.get(url, timeout=1)
        fail("[Timeout] 應該超時,但沒有")
    except requests.exceptions.Timeout:
        ok("[Timeout] 成功捕捉超時例外")

透過 httpbin 製造延遲,驗證 Timeout 例外能被捕捉。

  • 測試:非 JSON 回應(負向)
def test_non_json():
    # 負向測試:非 JSON 回應(JSONDecodeError)
    url = "https://httpbin.org/html"
    r = requests.get(url, timeout=10)
    info(f"[Non-JSON] Content-Type: {r.headers.get('Content-Type')}")
    try:
        r.json()
        fail("[Non-JSON] 應該無法解析 JSON,但沒有錯")
    except ValueError as e:
        ok(f"[Non-JSON] 無法解析 JSON(預期):{type(e).__name__}")

取回 HTML 後嘗試 r.json() 應丟 ValueError;否則視為失敗。

  • 測試:HTTP 404/429(負向)
def test_http_404():
    # 負向測試:HTTP 404
    url = "https://httpbin.org/status/404"
    r = requests.get(url, timeout=10)
    try:
        r.raise_for_status()
        fail("[HTTP 404] 應該 raise,但沒有")
    except requests.exceptions.HTTPError as e:
        ok(f"[HTTP 404] 捕捉到預期錯誤:{e}")
def test_http_429():
    # 負向測試:HTTP 429(太多請求)
    url = "https://httpbin.org/status/429"
    r = requests.get(url, timeout=10)
    info(f"[HTTP 429] Status={r.status_code}, Retry-After={r.headers.get('Retry-After')}")
    try:
        r.raise_for_status()
        fail("[HTTP 429] 應該 raise,但沒有")
    except requests.exceptions.HTTPError as e:
        ok(f"[HTTP 429] 捕捉到預期錯誤:{e}")

利用 httpbin 直接返回 404/429,檢查 raise_for_status() 會丟 HTTPError

  • 主流程
def main():
    line(); print("Day29 測試 & 除錯(功能 + 負向)"); line()
    load_dotenv(ROOT / ".env")

    # 1) 環境檢查
    key = os.getenv("OWM_API_KEY")
    if not key:
        fail("找不到 OWM_API_KEY(請在 .env 設定)"); return
    city = os.getenv("CITY", "Taipei")

    # 2) 功能測試:Weather / AQI / News
    try:
        w = test_weather_ok(city=city)
    except Exception as e:
        fail(f"Weather 功能失敗:{e}")
        return

    try:
        aqi = test_aqi_ok(lat=w["lat"], lon=w["lon"], key=w["api_key"])
    except Exception as e:
        fail(f"AQI 功能失敗:{e}")

    try:
        news = test_news_ok(limit=int(os.getenv("NEWS_LIMIT", "5")))
    except Exception as e:
        fail(f"News 功能失敗:{e}")

    # 3) 負向測試:404 / Timeout / 非 JSON / 429
    line()
    try: test_weather_bad_city()
    except Exception as e: fail(f"Weather 404 測試程式出錯:{e}")

    try: test_timeout()
    except Exception as e: fail(f"Timeout 測試程式出錯:{e}")

    try: test_non_json()
    except Exception as e: fail(f"Non-JSON 測試程式出錯:{e}")

    try: test_http_404()
    except Exception as e: fail(f"HTTP 404 測試程式出錯:{e}")

    try: test_http_429()
    except Exception as e: fail(f"HTTP 429 測試程式出錯:{e}")

    line(); ok("測試完成")
if __name__ == "__main__":
    main()

執行結果:
https://ithelp.ithome.com.tw/upload/images/20251011/20178708PEhWukqQZm.png

功能測試主要確認三個 API 能順利拿到正確資料:天氣部分可以取得城市名稱、溫度和座標(經緯度);空氣品質(AQI)部分需要回傳 AQI 數值、PM2.5 與 PM10 的含量;新聞部分要抓到每則新聞的標題和連結。

負向測試則是故意模擬錯誤狀況,測試程式能否正確處理並回報錯誤,包括:

  • 輸入錯誤的城市名稱會導致 404(找不到資料)

  • 模擬 API 請求超時(timeout)

  • API 回傳非 JSON 格式,造成資料格式錯誤

  • 收到 HTTP 錯誤碼,例如 429(請求過多,被限制使用)

這些測試確保程式在正常和異常情況下都能穩定運作,並能幫助我們快速發現和修正問題。

今日總結

今天我學會了怎麼為自己的 API 專案建立自動化測試系統。透過這個系統,可以自動確認程式功能是否正常運作,並且還能做負向測試,讓程式能在錯誤情況下正確回報錯誤,而不是直接崩潰。這次練習讓我了解到,好的程式不只是能跑,而且要能穩定運作並且值得信賴。明天我會進入最後的收尾階段,準備完成這 30 天的挑戰。這次的經驗讓我更有信心面對未來專案中的問題。


上一篇
Day 28 — 最終專案:打造生活資訊系統
系列文
每天一點 API:打造我的生活小工具29
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言