본문 바로가기

테스트 작성하기

1. 손으로 하는 확인의 한계

5장에서 .http 파일로 여러 시나리오를 만들어 확인했습니다. 그 방식에는 두 가지 한계가 있습니다.

  1. 결과가 맞는지 사람이 눈으로 봐야 합니다.
  2. 실행할 때마다 데이터가 쌓여서 두 번째부터는 결과가 달라집니다.

자동 테스트는 이 두 가지를 해결합니다. 기대하는 결과를 코드로 적어두면 기계가 확인해주고, 테스트마다 깨끗한 상태에서 시작하게 만들 수 있습니다.

무엇보다 중요한 것은 고칠 용기가 생긴다는 점입니다. 테스트가 없으면 잘 돌아가는 코드를 건드리기가 무섭습니다. 성능을 개선하고 싶어도, 구조를 정리하고 싶어도, 무엇이 깨질지 몰라 그냥 두게 됩니다. 6장에서 파일을 나눌 때도 테스트가 있었다면 훨씬 편했을 것입니다.

1.1 실습 준비

6장에서 만든 구조에 7-1절의 설정 변경까지 적용한 프로젝트를 이어서 씁니다. 프로젝트 최상위에서 가상환경을 활성화하고 테스트 도구를 설치합니다.

pip install pytest httpx2
패키지역할
pytest테스트를 찾아 실행하고 결과를 보여줍니다
httpx2TestClient가 내부적으로 사용합니다

httpx2를 설치하지 않아도 테스트는 동작하지만, 실행할 때마다 아래 경고가 나옵니다.

StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated;
install `httpx2` instead.

6-3절에서 requirements-dev.txt를 만들었다면 pip install -r requirements-dev.txt로 설치해도 됩니다. pip은 개발용 패키지를 자동으로 구분하지 않으므로 pytest, httpx2, pytest-cov는 개발용 목록에 기록합니다.

7-1절에서 SECRET_KEY를 필수로 만들었으므로 프로젝트 최상위의 .env에 실습용 키가 있어야 합니다. 첫 테스트 이후에는 아래 conftest.py에서 테스트 전용 설정을 준비합니다.

2. 첫 번째 테스트

프로젝트 최상위에 tests 폴더를 만들고 빈 tests/__init__.py 파일을 만듭니다. 그다음 tests/test_first.py를 만들어봅니다.

from fastapi.testclient import TestClient

from app.main import app


def test_목록은_로그인_없이_볼_수_있다():
    with TestClient(app) as client:
        response = client.get("/blogs")

    assert response.status_code == 200

실행합니다.

python -m pytest
tests/test_first.py .                                              [100%]
1 passed in 0.42s

pytest는 아래 규칙으로 테스트를 찾습니다.

대상규칙
파일test_로 시작하거나 _test로 끝남
함수test_로 시작함

함수 이름을 한글로 적은 이유가 있습니다. 테스트 이름은 실행되지 않을 때 읽히는 문서입니다. 무엇이 깨졌는지 이름만 보고 알 수 있어야 하므로, 한국어 팀이라면 한글로 적는 편이 낫습니다.

assert는 파이썬 기본 문법입니다. 조건이 거짓이면 그 자리에서 실패합니다. 일부러 틀리게 바꿔서 실행해보세요.

assert response.status_code == 999
E       assert 200 == 999
E        +  where 200 = <Response [200 OK]>.status_code

pytest는 무엇과 무엇을 비교했는지까지 보여줍니다.

3. 테스트용 데이터베이스 분리하기

위 테스트에는 큰 문제가 있습니다. 실제 blogs.db 파일을 사용합니다. 테스트로 글을 만들면 실제 데이터에 남고, 테스트 결과도 그때그때 데이터베이스 상태에 따라 달라집니다.

4장에서 의존성 주입의 이점 중 하나로 "테스트할 때 바꿔 끼울 수 있다"고 했던 것을 실제로 해보겠습니다.

3.1 conftest.py 만들기

tests/conftest.py 파일을 만듭니다. 이 이름은 pytest가 정한 것으로, 여기에 적은 것들은 모든 테스트 파일에서 자동으로 사용할 수 있습니다.

import os
from collections.abc import Generator

import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool

# 앱을 import하기 전에 테스트 전용 설정을 적용합니다.
os.environ["SECRET_KEY"] = "test-only-secret-key-at-least-32-characters"
os.environ["DATABASE_URL"] = "sqlite://"

from app.database import Base, get_db
from app.main import app

TEST_DATABASE_URL = "sqlite://"


@pytest.fixture(name="db")
def db_fixture() -> Generator[Session, None, None]:
    engine = create_engine(
        TEST_DATABASE_URL,
        connect_args={"check_same_thread": False},
        poolclass=StaticPool,
    )
    Base.metadata.create_all(engine)

    TestingSessionLocal = sessionmaker(bind=engine)
    try:
        with TestingSessionLocal() as session:
            yield session
    finally:
        Base.metadata.drop_all(engine)
        engine.dispose()


@pytest.fixture(name="client")
def client_fixture(db: Session) -> Generator[TestClient, None, None]:
    def get_db_override():
        return db

    app.dependency_overrides[get_db] = get_db_override

    try:
        with TestClient(app) as client:
            yield client
    finally:
        app.dependency_overrides.clear()

새로 나온 것이 세 군데 있습니다.

TEST_DATABASE_URL = "sqlite://"는 파일이 아니라 메모리에 데이터베이스를 만들라는 뜻입니다. 파일 경로가 없는 것이 차이입니다. 테스트가 끝나면 흔적도 남지 않고, 파일을 쓰지 않으니 빠릅니다.

poolclass=StaticPool이 필요한 이유가 있습니다. 메모리 데이터베이스는 연결이 끊기는 순간 사라집니다. SQLAlchemy는 기본적으로 필요할 때마다 새 연결을 만드는데, 그러면 테이블을 만든 연결과 조회하는 연결이 달라져서 "테이블이 없다"는 에러가 납니다. StaticPool은 연결을 하나만 만들어 계속 재사용합니다.

app.dependency_overrides가 핵심입니다. FastAPI에게 "get_db를 만나면 원래 함수 대신 이것을 써라"라고 알려주는 딕셔너리입니다. 엔드포인트 코드는 한 글자도 고치지 않았는데 데이터베이스만 바뀝니다.

3.2 픽스처

@pytest.fixture를 붙인 함수를 픽스처라고 합니다. 테스트를 실행하기 전에 준비하고, 끝난 뒤에 정리하는 역할을 합니다.

def test_목록은_로그인_없이_볼_수_있다(client: TestClient):
    response = client.get("/blogs")
    assert response.status_code == 200

앞서 만든 tests/test_first.py의 내용을 위 코드로 교체하고, 파일 맨 위의 from fastapi.testclient import TestClient는 유지하세요. 직접 TestClient(app)를 만드는 기존 코드를 남기면 데이터베이스 교체가 적용되지 않습니다.

테스트 함수의 매개변수 이름이 client이면, pytest가 client라는 이름의 픽스처를 찾아 실행하고 그 결과를 넣어줍니다. 4장에서 배운 의존성 주입과 같은 방식입니다.

픽스처도 yield를 씁니다. yield 위는 준비, 아래는 정리입니다.

시점db 픽스처client 픽스처
준비메모리 DB 생성, 테이블 생성의존성 교체, 클라이언트 생성
테스트 실행
정리테이블 삭제의존성 원복

픽스처는 테스트 함수마다 새로 실행됩니다. 그래서 테스트 하나가 만든 데이터가 다음 테스트에 남지 않습니다. 앞서 말한 두 번째 문제가 해결됩니다.

4. 인증이 필요한 테스트

토큰이 필요한 테스트가 많으므로, 로그인까지 마친 상태를 픽스처로 만들어둡니다. tests/conftest.py 아래에 이어서 작성합니다.

def signup_and_login(client: TestClient, email: str) -> str:
    client.post("/signup", json={"email": email, "password": "test1234"})
    response = client.post("/token", data={"username": email, "password": "test1234"})
    return response.json()["access_token"]


@pytest.fixture(name="licat_token")
def licat_token_fixture(client: TestClient) -> str:
    return signup_and_login(client, "licat@weniv.co.kr")


@pytest.fixture(name="mura_token")
def mura_token_fixture(client: TestClient) -> str:
    return signup_and_login(client, "mura@weniv.co.kr")


def auth(token: str) -> dict[str, str]:
    return {"Authorization": f"Bearer {token}"}

사용자 두 명을 준비한 것이 중요합니다. 권한 테스트를 하려면 "남의 글"이 있어야 하는데, Swagger UI로는 만들기 어려웠던 상황입니다. 테스트 코드에서는 간단합니다.

5. 인증 테스트 작성하기

tests/test_auth.py 파일을 만듭니다.

from fastapi.testclient import TestClient

from tests.conftest import auth


def test_회원가입에_성공하면_비밀번호는_응답에_없다(client: TestClient):
    response = client.post(
        "/signup", json={"email": "new@weniv.co.kr", "password": "test1234"}
    )

    assert response.status_code == 201
    body = response.json()
    assert body["email"] == "new@weniv.co.kr"
    assert "password" not in body
    assert "hashed_password" not in body


def test_같은_이메일로_두_번_가입할_수_없다(client: TestClient):
    payload = {"email": "dup@weniv.co.kr", "password": "test1234"}
    client.post("/signup", json=payload)

    response = client.post("/signup", json=payload)

    assert response.status_code == 400


def test_잘못된_이메일_형식은_거부된다(client: TestClient):
    response = client.post(
        "/signup", json={"email": "notanemail", "password": "test1234"}
    )
    assert response.status_code == 422


def test_짧은_비밀번호는_거부된다(client: TestClient):
    response = client.post(
        "/signup", json={"email": "a@weniv.co.kr", "password": "123"}
    )
    assert response.status_code == 422


def test_비밀번호가_틀리면_로그인할_수_없다(client: TestClient, licat_token: str):
    response = client.post(
        "/token", data={"username": "licat@weniv.co.kr", "password": "wrong"}
    )
    assert response.status_code == 401


def test_토큰으로_내_정보를_조회할_수_있다(client: TestClient, licat_token: str):
    response = client.get("/me", headers=auth(licat_token))

    assert response.status_code == 200
    assert response.json()["email"] == "licat@weniv.co.kr"


def test_토큰_없이는_내_정보를_조회할_수_없다(client: TestClient):
    assert client.get("/me").status_code == 401


def test_가짜_토큰은_거부된다(client: TestClient):
    assert client.get("/me", headers=auth("fake.token.value")).status_code == 401

첫 번째 테스트를 눈여겨봐 주세요. 2장에서 배운 "응답 모델이 민감한 값을 걸러낸다"는 성질을 검증하고 있습니다. 나중에 누군가 실수로 응답 모델을 UserModel로 바꾸면 이 테스트가 바로 실패합니다.

5.1 테스트를 쓰는 순서

테스트 함수들의 모양이 비슷합니다. 대부분 아래 세 단계로 이루어집니다.

def test_같은_이메일로_두_번_가입할_수_없다(client: TestClient):
    # 준비: 이미 가입된 상태를 만듭니다
    payload = {"email": "dup@weniv.co.kr", "password": "test1234"}
    client.post("/signup", json=payload)

    # 실행: 확인하려는 동작 하나만 실행합니다
    response = client.post("/signup", json=payload)

    # 확인: 기대하는 결과를 적습니다
    assert response.status_code == 400

준비, 실행, 확인을 빈 줄로 나눠두면 읽기 좋습니다. 실행 단계는 되도록 한 줄이어야 합니다. 두 줄 이상이면 테스트 하나가 여러 가지를 확인하고 있다는 신호입니다.

6. 블로그 테스트 작성하기

tests/test_blogs.py 파일을 만듭니다.

from fastapi.testclient import TestClient

from tests.conftest import auth


def create_blog(client: TestClient, token: str, title: str = "제목") -> int:
    """글을 하나 만들고 ID를 반환하는 도우미 함수입니다."""
    response = client.post(
        "/blogs", json={"title": title, "content": "내용"}, headers=auth(token)
    )
    assert response.status_code == 201
    return response.json()["id"]


def test_목록은_로그인_없이_볼_수_있다(client: TestClient):
    response = client.get("/blogs")

    assert response.status_code == 200
    assert response.json() == []


def test_로그인하면_글을_쓸_수_있다(client: TestClient, licat_token: str):
    response = client.post(
        "/blogs", json={"title": "첫 글", "content": "안녕"}, headers=auth(licat_token)
    )

    assert response.status_code == 201
    body = response.json()
    assert body["title"] == "첫 글"
    assert body["author_email"] == "licat@weniv.co.kr"


def test_로그인하지_않으면_글을_쓸_수_없다(client: TestClient):
    response = client.post("/blogs", json={"title": "몰래", "content": "쓰기"})

    assert response.status_code == 401


def test_본인_글은_수정할_수_있다(client: TestClient, licat_token: str):
    blog_id = create_blog(client, licat_token)

    response = client.put(
        f"/blogs/{blog_id}",
        json={"title": "고침", "content": "고침"},
        headers=auth(licat_token),
    )

    assert response.status_code == 200
    assert response.json()["title"] == "고침"


def test_남의_글은_수정할_수_없다(
    client: TestClient, licat_token: str, mura_token: str
):
    blog_id = create_blog(client, licat_token)

    response = client.put(
        f"/blogs/{blog_id}",
        json={"title": "남의 글", "content": "고치기"},
        headers=auth(mura_token),
    )

    assert response.status_code == 403


def test_남의_글은_삭제할_수_없다(
    client: TestClient, licat_token: str, mura_token: str
):
    blog_id = create_blog(client, licat_token)

    response = client.delete(f"/blogs/{blog_id}", headers=auth(mura_token))

    assert response.status_code == 403


def test_본인_글은_삭제할_수_있다(client: TestClient, licat_token: str):
    blog_id = create_blog(client, licat_token)

    response = client.delete(f"/blogs/{blog_id}", headers=auth(licat_token))

    assert response.status_code == 204
    assert client.get(f"/blogs/{blog_id}").status_code == 404


def test_없는_글을_조회하면_404가_나온다(client: TestClient):
    assert client.get("/blogs/99999").status_code == 404


def test_빈_제목은_거부된다(client: TestClient, licat_token: str):
    response = client.post(
        "/blogs", json={"title": "", "content": "내용"}, headers=auth(licat_token)
    )

    assert response.status_code == 422


def test_테스트끼리_데이터가_섞이지_않는다(client: TestClient):
    assert client.get("/blogs").json() == []

마지막 테스트가 픽스처가 제대로 동작하는지 확인합니다. 앞선 테스트들이 글을 여러 개 만들었는데도 목록이 비어 있어야 합니다.

7. 실행하기

test_blogs.py에 첫 테스트와 같은 검사가 들어 있으므로 tests/test_first.py는 삭제하세요. 아래 결과는 인증 테스트 8개와 블로그 테스트 10개를 실행한 예시이며, 실행 시간은 컴퓨터에 따라 다릅니다.

python -m pytest
tests/test_auth.py ........                                        [ 44%]
tests/test_blogs.py ..........                                     [100%]

18 passed in 0.78s

5장에서 .http 파일로 하나씩 눌러 확인하던 것이 명령 하나로 끝납니다.

자주 쓰는 옵션은 아래와 같습니다.

명령하는 일
python -m pytest전체 실행
python -m pytest -v테스트 이름을 하나씩 출력
python -m pytest tests/test_blogs.py파일 하나만 실행
python -m pytest -k "남의_글"이름에 그 문자열이 든 것만 실행
python -m pytest -x처음 실패하는 곳에서 멈춤
python -m pytest --lf지난번에 실패한 것만 다시 실행

-v를 붙이면 아래처럼 나옵니다.

tests/test_blogs.py::test_로그인하지_않으면_글을_쓸_수_없다 PASSED
tests/test_blogs.py::test_남의_글은_수정할_수_없다 PASSED
tests/test_blogs.py::test_남의_글은_삭제할_수_없다 PASSED

이 목록 자체가 문서입니다. 이 서비스가 무엇을 보장하는지 이름만 읽어도 알 수 있습니다.

8. 테스트가 실제로 일하는지 확인하기

테스트를 작성했는데 사실은 아무것도 검사하지 않는 경우가 있습니다. 확인하는 방법은 일부러 코드를 깨뜨려보는 것입니다.

app/routers/blogs.py에서 check_owner 호출을 주석 처리해보세요.

@router.delete("/{blog_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_blog(blog_id: int, current_user: CurrentUser, db: SessionDep) -> None:
    blog = get_blog_or_404(db, blog_id)
    # check_owner(blog, current_user)
    db.delete(blog)
    db.commit()
python -m pytest
FAILED tests/test_blogs.py::test_남의_글은_삭제할_수_없다
E       assert 204 == 403
17 passed, 1 failed

권한 검사를 빼자마자 정확히 그 테스트가 실패했습니다. 이 테스트는 제 역할을 하고 있습니다.

확인이 끝나면 주석을 다시 해제하고 테스트가 모두 통과하는지 확인하세요.

테스트를 얼마나 만들어야 하나요

모든 줄을 검사하려고 하면 테스트를 만드는 데 시간이 다 갑니다. 우선순위를 두는 것이 좋습니다.

  1. 깨지면 사고가 나는 것: 권한 검사, 비밀번호 처리, 결제
  2. 자주 고치는 것: 핵심 비즈니스 로직
  3. 이미 한 번 버그가 났던 것: 같은 버그가 다시 나지 않게 막습니다

반대로 테스트를 만들 필요가 적은 것도 있습니다. 단순히 값을 그대로 반환하는 코드, Pydantic이 이미 검증해주는 부분, 외부 라이브러리의 동작 등입니다.

이번 절에서 만든 18개 중 절반 이상이 401과 403, 422를 확인하는 테스트입니다. 의도적으로 그렇게 만들었습니다. 잘 되는 것보다 막혀야 하는 것이 훨씬 중요합니다.

9. 커버리지 확인하기

어느 코드가 테스트되지 않았는지 확인하는 도구가 있습니다.

pip install pytest-cov
python -m pytest --cov=app --cov-report=term-missing
Name                        Stmts   Miss  Cover   Missing
---------------------------------------------------------
app/routers/blogs.py           45      3    93%   62-64
app/security.py                20      0   100%
---------------------------------------------------------
TOTAL                         142     11    92%

Missing 열의 줄 번호가 한 번도 실행되지 않은 코드입니다. 그 줄을 보고 테스트를 추가할지, 아니면 필요 없는 코드인지 판단하면 됩니다.

숫자 자체를 목표로 삼지는 마세요. 100%를 만들려고 의미 없는 테스트를 채우는 것보다, 중요한 부분이 빠져 있지 않은지 확인하는 용도로 쓰는 것이 좋습니다.

10. 손으로 확인할 때와의 차이

손으로 확인자동 테스트
실행 시간요청마다 몇 초씩전체 1초 미만
결과 확인눈으로기계가
반복 실행데이터가 쌓임매번 깨끗함
놓치는 경우자주 있음적어놓은 것은 항상 확인
코드 수정무엇이 깨질지 모름깨지면 바로 알려줌
남는 것없음문서이자 명세가 됨

6장에서 파일을 나눌 때를 떠올려보세요. 200줄짜리 파일을 아홉 개로 쪼개면서 "잘 옮겼을까"를 계속 걱정했습니다. 테스트가 있었다면 옮기고 나서 pytest 한 번으로 끝났을 것입니다.

연습문제

  1. 6장 구조에 이번 절의 테스트를 모두 적용하고 실행해보세요.
  2. app/routers/blogs.py에서 get_blog_or_404의 404 부분을 지우고 테스트를 실행해보세요. 어떤 테스트가 실패하는지 확인해보세요.
  3. test_수정하면_updated_at이_바뀐다라는 테스트를 추가해보세요.
  4. 만료된 토큰으로 요청했을 때 401이 나오는지 확인하는 테스트를 만들어보세요. 힌트로, 만료 시각을 과거로 둔 토큰을 직접 만들면 됩니다.
  5. pytest-cov를 설치하고 커버리지를 확인해보세요. 테스트되지 않은 코드가 어디인지 찾아보세요.
  6. 6장 연습문제에서 만든 검색 기능에 대한 테스트를 작성해보세요.
테스트 작성하기 - FastAPI 베이스캠프 | 위니버시티