Skip to content

ํ…Œ์ŠคํŠธ

Starlette ๋•๋ถ„์— FastAPI ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์„ ์‰ฝ๊ณ  ์ฆ๊ฒ๊ฒŒ ํ…Œ์ŠคํŠธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. .

์š”์ฒญ์„ ๊ธฐ๋ฐ˜์œผ๋กœ ํ•˜๋ฏ€๋กœ ๋งค์šฐ ์นœ์ˆ™ํ•˜๊ณ  ์ง๊ด€์ ์ž…๋‹ˆ๋‹ค.

์ด๋ฅผ ํ†ตํ•ด FastAPI์™€ ํ•จ๊ป˜ pytest๋ฅผ ์ง์ ‘ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

TestClient ์‚ฌ์šฉ

TestClient๋ฅผ ๊ฐ€์ ธ์˜ต๋‹ˆ๋‹ค.

FastAPI ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์— ์ „๋‹ฌํ•˜๋Š” TestClient๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.

test_๋กœ ์‹œ์ž‘ํ•˜๋Š” ์ด๋ฆ„์œผ๋กœ ํ•จ์ˆ˜๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค (ํ‘œ์ค€ pytest ๊ทœ์น™).

requests์™€ ๋™์ผํ•œ ๋ฐฉ์‹์œผ๋กœ TestClient ๊ฐ์ฒด๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.

ํ™•์ธํ•ด์•ผ ํ•˜๋Š” ํ‘œ์ค€ Python ํ‘œํ˜„์‹์œผ๋กœ ๊ฐ„๋‹จํ•œ assert ๋ฌธ์„ ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค(ํ‘œ์ค€ pytest ๊ทœ์น™).

from fastapi import FastAPI
from fastapi.testclient import TestClient

app = FastAPI()


@app.get("/")
async def read_main():
    return {"msg": "Hello World"}


client = TestClient(app)


def test_read_main():
    response = client.get("/")
    assert response.status_code == 200
    assert response.json() == {"msg": "Hello World"}

ํŒ

ํ…Œ์ŠคํŠธ ํ•จ์ˆ˜๋Š” async def๊ฐ€ ์•„๋‹Œ ์ผ๋ฐ˜ def์ž…๋‹ˆ๋‹ค.

๊ทธ๋ฆฌ๊ณ  ํด๋ผ์ด์–ธํŠธ์— ๋Œ€ํ•œ ํ˜ธ์ถœ๋„ await๋ฅผ ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š” ์ผ๋ฐ˜ ํ˜ธ์ถœ์ž…๋‹ˆ๋‹ค.

์ด๋ ‡๊ฒŒ ํ•˜๋ฉด ๋ณต์žกํ•จ ์—†์ด pytest๋ฅผ ์ง์ ‘ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๊ธฐ์ˆ ์  ์„ธ๋ถ€ ์‚ฌํ•ญ

from starlette.testclient import TestClient๋ฅผ ํ†ตํ•ด ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

FastAPI๋Š” ๊ฐœ๋ฐœ์ž ์—ฌ๋Ÿฌ๋ถ„์˜ ํŽธ์˜๋ฅผ ์œ„ํ•ด fastapi.testclient์™€ ๋™์ผํ•œ starlette.testclient๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. ๊ทธ๋Ÿฌ๋‚˜ ๊ทธ๊ฒƒ์€ Starlette์—์„œ ์ง์ ‘ ์ œ๊ณต๋ฉ๋‹ˆ๋‹ค.

ํŒ

FastAPI ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜(์˜ˆ: ๋น„๋™๊ธฐ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ํ•จ์ˆ˜)์— ์š”์ฒญ์„ ๋ณด๋‚ด๋Š” ๊ฒƒ๊ณผ ๋ณ„๋„๋กœ ํ…Œ์ŠคํŠธ์—์„œ async ํ•จ์ˆ˜๋ฅผ ํ˜ธ์ถœํ•˜๋ ค๋ฉด ๊ณ ๊ธ‰ ์ž์Šต์„œ์˜ Async Tests ๋ฅผ ์‚ดํŽด๋ณด์‹ญ์‹œ์˜ค.

ํ…Œ์ŠคํŠธ ๋ถ„๋ฆฌ

์‹ค์ œ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์—์„œ๋Š” ํ…Œ์ŠคํŠธ๊ฐ€ ๋‹ค๋ฅธ ํŒŒ์ผ์— ์žˆ์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๊ทธ๋ฆฌ๊ณ  FastAPI ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์€ ์—ฌ๋Ÿฌ ํŒŒ์ผ/๋ชจ๋“ˆ ๋“ฑ์œผ๋กœ ๊ตฌ์„ฑ๋  ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค.

FastAPI ์•ฑ ํŒŒ์ผ

FastAPI ์•ฑ์— main.py ํŒŒ์ผ์ด ์žˆ๋‹ค๊ณ  ๊ฐ€์ •ํ•ด ๋ณด๊ฒ ์Šต๋‹ˆ๋‹ค.

from fastapi import FastAPI

app = FastAPI()


@app.get("/")
async def read_main():
    return {"msg": "Hello World"}

ํ…Œ์ŠคํŠธ ํŒŒ์ผ

๊ทธ๋Ÿฐ ๋‹ค์Œ ํ…Œ์ŠคํŠธ์™€ ํ•จ๊ป˜ test_main.py ํŒŒ์ผ์„ ๋งŒ๋“ค๊ณ  main ๋ชจ๋“ˆ(main.py)์—์„œ app์„ ๊ฐ€์ ธ์˜ฌ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

from fastapi.testclient import TestClient

from .main import app

client = TestClient(app)


def test_read_main():
    response = client.get("/")
    assert response.status_code == 200
    assert response.json() == {"msg": "Hello World"}

ํ…Œ์ŠคํŠธ: ํ™•์žฅ๋œ ์˜ˆ์ œ

์ด์ œ ์ด ์˜ˆ์ œ๋ฅผ ํ™•์žฅํ•˜๊ณ  ์„ธ๋ถ€ ์‚ฌํ•ญ์„ ์ถ”๊ฐ€ํ•˜์—ฌ ๋‹ค๋ฅธ ๋ถ€๋ถ„์„ ํ…Œ์ŠคํŠธํ•˜๋Š” ๋ฐฉ๋ฒ•์„ ์‚ดํŽด๋ณด๊ฒ ์Šต๋‹ˆ๋‹ค.

ํ™•์žฅ๋œ FastAPI ์•ฑ ํŒŒ์ผ

FastAPI ์•ฑ์— main_b.py ํŒŒ์ผ์ด ์žˆ๋‹ค๊ณ  ๊ฐ€์ •ํ•ด ๋ณด๊ฒ ์Šต๋‹ˆ๋‹ค.

์˜ค๋ฅ˜๋ฅผ ๋ฐ˜ํ™˜ํ•  ์ˆ˜ ์žˆ๋Š” GET ์ž‘์—…์ด ์žˆ์Šต๋‹ˆ๋‹ค.

์—ฌ๋Ÿฌ ์˜ค๋ฅ˜๋ฅผ ๋ฐ˜ํ™˜ํ•  ์ˆ˜ ์žˆ๋Š” POST ์ž‘์—…์ด ์žˆ์Šต๋‹ˆ๋‹ค.

๋‘ ๊ฒฝ๋กœ ์ž‘์—… ๋ชจ๋‘ X-Token ํ—ค๋”๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.

from typing import Union

from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel

fake_secret_token = "coneofsilence"

fake_db = {
    "foo": {"id": "foo", "title": "Foo", "description": "There goes my hero"},
    "bar": {"id": "bar", "title": "Bar", "description": "The bartenders"},
}

app = FastAPI()


class Item(BaseModel):
    id: str
    title: str
    description: Union[str, None] = None


@app.get("/items/{item_id}", response_model=Item)
async def read_main(item_id: str, x_token: str = Header()):
    if x_token != fake_secret_token:
        raise HTTPException(status_code=400, detail="Invalid X-Token header")
    if item_id not in fake_db:
        raise HTTPException(status_code=404, detail="Item not found")
    return fake_db[item_id]


@app.post("/items/", response_model=Item)
async def create_item(item: Item, x_token: str = Header()):
    if x_token != fake_secret_token:
        raise HTTPException(status_code=400, detail="Invalid X-Token header")
    if item.id in fake_db:
        raise HTTPException(status_code=400, detail="Item already exists")
    fake_db[item.id] = item
    return item
from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel

fake_secret_token = "coneofsilence"

fake_db = {
    "foo": {"id": "foo", "title": "Foo", "description": "There goes my hero"},
    "bar": {"id": "bar", "title": "Bar", "description": "The bartenders"},
}

app = FastAPI()


class Item(BaseModel):
    id: str
    title: str
    description: str | None = None


@app.get("/items/{item_id}", response_model=Item)
async def read_main(item_id: str, x_token: str = Header()):
    if x_token != fake_secret_token:
        raise HTTPException(status_code=400, detail="Invalid X-Token header")
    if item_id not in fake_db:
        raise HTTPException(status_code=404, detail="Item not found")
    return fake_db[item_id]


@app.post("/items/", response_model=Item)
async def create_item(item: Item, x_token: str = Header()):
    if x_token != fake_secret_token:
        raise HTTPException(status_code=400, detail="Invalid X-Token header")
    if item.id in fake_db:
        raise HTTPException(status_code=400, detail="Item already exists")
    fake_db[item.id] = item
    return item

ํ™•์žฅ ํ…Œ์ŠคํŠธ ํŒŒ์ผ

๋‹ค์Œ์œผ๋กœ ํ™•์žฅ ํ…Œ์ŠคํŠธ๋ฅผ ํ†ตํ•ด ์ด์ „๊ณผ ๋™์ผํ•œ test_main_b.py๋ฅผ ๊ฐ€์งˆ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

from fastapi.testclient import TestClient

from .main import app

client = TestClient(app)


def test_read_item():
    response = client.get("/items/foo", headers={"X-Token": "coneofsilence"})
    assert response.status_code == 200
    assert response.json() == {
        "id": "foo",
        "title": "Foo",
        "description": "There goes my hero",
    }


def test_read_item_bad_token():
    response = client.get("/items/foo", headers={"X-Token": "hailhydra"})
    assert response.status_code == 400
    assert response.json() == {"detail": "Invalid X-Token header"}


def test_read_inexistent_item():
    response = client.get("/items/baz", headers={"X-Token": "coneofsilence"})
    assert response.status_code == 404
    assert response.json() == {"detail": "Item not found"}


def test_create_item():
    response = client.post(
        "/items/",
        headers={"X-Token": "coneofsilence"},
        json={"id": "foobar", "title": "Foo Bar", "description": "The Foo Barters"},
    )
    assert response.status_code == 200
    assert response.json() == {
        "id": "foobar",
        "title": "Foo Bar",
        "description": "The Foo Barters",
    }


def test_create_item_bad_token():
    response = client.post(
        "/items/",
        headers={"X-Token": "hailhydra"},
        json={"id": "bazz", "title": "Bazz", "description": "Drop the bazz"},
    )
    assert response.status_code == 400
    assert response.json() == {"detail": "Invalid X-Token header"}


def test_create_existing_item():
    response = client.post(
        "/items/",
        headers={"X-Token": "coneofsilence"},
        json={
            "id": "foo",
            "title": "The Foo ID Stealers",
            "description": "There goes my stealer",
        },
    )
    assert response.status_code == 400
    assert response.json() == {"detail": "Item already exists"}

ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์š”์ฒญ์— ์ •๋ณด๋ฅผ ์ „๋‹ฌํ•ด์•ผ ํ•˜๋Š”๋ฐ ๋ฐฉ๋ฒ•์„ ๋ชจ๋ฅผ ๋•Œ๋งˆ๋‹ค '์š”์ฒญ'์—์„œ ๋ฐฉ๋ฒ•์„ ๊ฒ€์ƒ‰ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค(Google).

๊ทธ๋Ÿฐ ๋‹ค์Œ ํ…Œ์ŠคํŠธ์—์„œ ๋™์ผํ•œ ์ž‘์—…์„ ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค.

์˜ˆ:

  • ๊ฒฝ๋กœ ํ˜น์€ ์ฟผ๋ฆฌ ๋งค๊ฐœ๋ณ€์ˆ˜๋ฅผ ์ „๋‹ฌํ•˜๋ ค๋ฉด URL ์ž์ฒด์— ์ถ”๊ฐ€ํ•˜์‹ญ์‹œ์˜ค.
  • JSON ๋ณธ๋ฌธ์„ ์ „๋‹ฌํ•˜๋ ค๋ฉด ํŒŒ์ด์ฌ ๊ฐ์ฒด(์˜ˆ: dict)๋ฅผ ๋งค๊ฐœ๋ณ€์ˆ˜ json์— ์ „๋‹ฌํ•ฉ๋‹ˆ๋‹ค.
  • JSON ๋Œ€์‹  ๋ฐ์ดํ„ฐ์—์„œ ๋ณด๋‚ด์•ผ ํ•œ๋‹ค๋ฉด data ๋งค๊ฐœ๋ณ€์ˆ˜๋ฅผ ๋Œ€์‹  ์‚ฌ์šฉํ•˜์‹ญ์‹œ์˜ค.
  • headers๋ฅผ ์ „๋‹ฌํ•˜๋ ค๋ฉด headers ๋งค๊ฐœ๋ณ€์ˆ˜์— dict๋ฅผ ์‚ฌ์šฉํ•˜์‹ญ์‹œ์˜ค.
  • cookies์˜ ๊ฒฝ์šฐ cookies ๋งค๊ฐœ๋ณ€์ˆ˜์˜ dict.

๋ฐ์ดํ„ฐ๋ฅผ ๋ฐฑ์—”๋“œ์— ์ „๋‹ฌํ•˜๋Š” ๋ฐฉ๋ฒ•(requests ๋˜๋Š” TestClient ์‚ฌ์šฉ)์— ๋Œ€ํ•œ ์ž์„ธํ•œ ๋‚ด์šฉ์€ ๋ฌธ์„œ ์š”์ฒญ์„ ํ™•์ธํ•˜์‹ญ์‹œ์˜ค.

์ •๋ณด

TestClient๋Š” Pydantic ๋ชจ๋ธ์ด ์•„๋‹Œ JSON์œผ๋กœ ๋ณ€ํ™˜ํ•  ์ˆ˜ ์žˆ๋Š” ๋ฐ์ดํ„ฐ๋ฅผ ์ˆ˜์‹ ํ•ฉ๋‹ˆ๋‹ค.

ํ…Œ์ŠคํŠธ์— Pydantic ๋ชจ๋ธ์ด ์žˆ๊ณ  ํ…Œ์ŠคํŠธ ์ค‘์— ํ•ด๋‹น ๋ฐ์ดํ„ฐ๋ฅผ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์œผ๋กœ ๋ณด๋‚ด๋ ค๋ฉด JSON ํ˜ธํ™˜ ์ธ์ฝ”๋”์— ์„ค๋ช…๋œ jsonable_encoder๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. .

์‹คํ–‰

๊ทธ๋Ÿฐ ๋‹ค์Œ pytest๋ฅผ ์„ค์น˜ํ•˜๊ธฐ๋งŒ ํ•˜๋ฉด ๋ฉ๋‹ˆ๋‹ค.

$ pip install pytest

---> 100%

ํŒŒ์ผ๊ณผ ํ…Œ์ŠคํŠธ๋ฅผ ์ž๋™์œผ๋กœ ๊ฐ์ง€ํ•˜๊ณ  ์‹คํ–‰ํ•˜์—ฌ ๊ฒฐ๊ณผ๋ฅผ ๋‹ค์‹œ ๋ณด๊ณ ํ•ฉ๋‹ˆ๋‹ค.

๋‹ค์Œ์„ ์‚ฌ์šฉํ•˜์—ฌ ํ…Œ์ŠคํŠธ๋ฅผ ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค.

$ pytest

================ test session starts ================
platform linux -- Python 3.6.9, pytest-5.3.5, py-1.8.1, pluggy-0.13.1
rootdir: /home/user/code/superawesome-cli/app
plugins: forked-1.1.3, xdist-1.31.0, cov-2.8.1
collected 6 items

---> 100%

test_main.py <span style="color: green; white-space: pre;">......                            [100%]</span>

<span style="color: green;">================= 1 passed in 0.03s =================</span>