학습 기록

Python 공부 기록 #27 - pytest로 검사 한 번에 돌리고 실패 화면 읽기

0. 목차

  • 인트로: 함수 이름 적는 게 슬슬 귀찮아졌습니다
  • 손으로 부르는 방식의 한계
  • pytest 설치하고 한 단어로 돌려보기
  • 1단계: 파일 이름과 함수 이름 규칙 맞추기
  • 2단계: 실패 화면 읽는 법
  • 3단계: 같은 준비 작업을 fixture로 빼기
  • 삽질했던 부분들
  • 아웃트로: 검사가 늘어나도 무섭지 않게

1. 인트로: 함수 이름 적는 게 슬슬 귀찮아졌습니다

지난 글에서 assert로 파싱 함수만 따로 시험하는 방법을 정리했습니다. 그 뒤로 검사가 계속 늘었습니다.

문제는 파일 맨 아래였습니다.

if __name__ == "__main__":
    test_항목_개수()
    test_제목_안비었는지()
    test_빈_HTML도_안죽는지()

검사 함수를 새로 만들 때마다 여기에 이름을 한 줄씩 또 적어야 했습니다. 8월 11일에는 검사를 만들어놓고 아래에 적는 걸 깜빡했습니다. “전부 통과”가 떴는데 알고 보니 그 검사는 실행조차 안 된 상태였습니다.

통과했다는 말을 못 믿게 되는 순간, 검사를 만든 의미가 없어집니다. 찾아보니까 이런 걸 대신 해주는 도구가 pytest였습니다.


2. 손으로 부르는 방식의 한계

가장 큰 문제는 방금 겪은 그거였습니다. 사람이 적는 목록이라 빠뜨릴 수 있다는 것.

두 번째는 첫 실패에서 멈춘다는 점이었습니다. 검사 세 개 중 첫 번째가 걸리면 거기서 프로그램이 끝나버립니다. 나머지 두 개도 문제가 있는지는 첫 번째를 고친 뒤에야 알 수 있었습니다.

세 번째는 실패 메시지가 불친절했습니다. AssertionError와 줄 번호만 나오니까, 어떤 값이 들어와서 걸렸는지 보려면 print()를 다시 심어야 했습니다.

세 가지 다 pytest가 알아서 해결해주는 부분이었습니다.


3. pytest 설치하고 한 단어로 돌려보기

설치는 한 줄입니다.

pip install pytest

그리고 test_parser.py가 있는 폴더에서 이렇게만 칩니다.

pytest

if __name__ == "__main__": 아래 부분은 지웠습니다. 이제 필요 없습니다. pytest가 폴더를 뒤져서 검사 함수를 알아서 찾아 전부 돌려줍니다.

collected 3 items

test_parser.py ...                    [100%]

3 passed in 0.14s

점 세 개가 검사 세 개입니다. 이거 되겠는데? 싶었던 게 이 화면이었습니다. 실패하면 점 대신 F가 찍힙니다.


4. 1단계: 파일 이름과 함수 이름 규칙 맞추기

pytest가 검사를 어떻게 찾는지 궁금해서 찾아봤습니다. 규칙은 이름뿐이었습니다.

  • 파일 이름이 test_로 시작하거나 _test.py로 끝날 것
  • 함수 이름이 test_로 시작할 것

test_parser.py 안의 test_항목_개수()는 두 조건을 다 만족하니까 그냥 잡혔습니다. 지난 글에서 우연히 맞춰놨던 셈입니다.

반대로 load_sample()은 test_로 시작하지 않아서 검사로 취급되지 않습니다. 도우미 함수는 이름만 다르게 두면 알아서 빠집니다.


5. 2단계: 실패 화면 읽는 법

일부러 틀린 검사를 하나 넣고 돌려봤습니다.

def test_항목_개수():
    items = parse_items(load_sample())
    assert len(items) == 20

사이트가 25건을 주도록 바뀐 상황이라 이건 실패합니다.

E       assert 25 == 20
E        +  where 25 = len([{'title': '...'}, ...])

test_parser.py:9: AssertionError

assert 25 == 20. 메시지를 따로 안 붙였는데도 실제 값 25가 그대로 보입니다. pytest가 assert 문을 뜯어서 양쪽 값을 대신 찍어주는 겁니다.

여기서 좀 놀랐습니다. 지난 글에서 실패 메시지에 값을 넣으려고 f-string을 꼬박꼬박 붙였는데, 그 수고를 상당 부분 덜어줍니다. 물론 왜 20이어야 하는지 같은 설명은 여전히 직접 적는 게 낫습니다.

그리고 검사 하나가 실패해도 나머지는 끝까지 돕니다. 마지막에 1 failed, 2 passed처럼 한 줄로 정리해줍니다.


6. 3단계: 같은 준비 작업을 fixture로 빼기

검사마다 load_sample()을 부르는 줄이 계속 반복됐습니다. 이걸 정리하는 방법이 fixture였습니다.

import pytest
from crawler import parse_items


@pytest.fixture
def html():
    with open("sample.html", encoding="utf-8") as f:
        return f.read()


def test_항목_개수(html):
    assert len(parse_items(html)) > 0


def test_제목_안비었는지(html):
    for item in parse_items(html):
        assert item["title"] != "", f"제목이 빈 항목: {item}"

@pytest.fixture를 붙인 함수는 준비 담당이 됩니다. 검사 함수 괄호 안에 그 이름을 적어두면, pytest가 먼저 실행해서 결과를 넣어줍니다.

처음엔 html이라는 값이 어디서 튀어나오는 건지 이해가 안 됐습니다. 이름이 곧 연결 고리라는 걸 알고 나서야 감이 왔습니다. 함수 이름과 매개변수 이름이 같으면 붙는다, 이게 전부였습니다.


7. 삽질했던 부분들

pip install pytest를 했는데 pytest 명령이 없다고 나왔습니다. 가상환경을 켜지 않은 채로 설치했던 게 원인이었습니다. python -m pytest로 부르면 지금 쓰는 Python 기준으로 실행되니까 이 방식이 더 안전하더군요.

검사 함수에 return을 쓰다가 경고를 봤습니다. 조건이 맞으면 True를 돌려주게 짰는데, pytest는 반환값을 보지 않습니다. assert가 걸리는지 아닌지만 봅니다. 반환값으로 통과 여부를 알린다고 착각하면 검사가 항상 통과하는 상태가 됩니다.

sample.html을 못 찾는 문제는 여전했습니다. 지난 글에서 넘어갔던 그 문제인데, 폴더 밖에서 pytest를 치면 또 걸립니다. 파일 경로를 실행 위치가 아니라 파일 위치 기준으로 잡는 방법이 있다는 것까지는 봤는데, 아직 제대로 이해 못 했습니다.

ChatGPT한테 fixture 예제를 달라고 했더니 데이터베이스 연결을 만드는 코드를 줬습니다. 제 상황과 너무 멀어서 “HTML 파일 읽는 경우로 바꿔서”라고 다시 물어봤습니다. 예제가 내 상황이랑 비슷해야 이해가 붙습니다.


8. 아웃트로: 검사가 늘어나도 무섭지 않게

오늘은 손으로 부르던 검사들을 pytest로 옮겨봤습니다.

핵심 정리

  1. pip install pytest 후 폴더에서 pytest 한 단어면 전부 실행된다
  2. 파일 이름과 함수 이름이 test_로 시작하면 자동으로 찾아준다
  3. 실패해도 멈추지 않고 끝까지 돌린 뒤 통과와 실패 개수를 정리해준다
  4. assert 양쪽 값을 알아서 보여주니 f-string을 매번 붙이지 않아도 된다
  5. 반복되는 준비 작업은 @pytest.fixture로 빼고 이름으로 연결한다

이제 할 수 있는 것들

  • 검사 함수를 만들기만 하면 실행 목록에 적지 않아도 돌아가게 하기
  • 여러 검사의 실패를 한 번에 확인하고 우선순위 정하기
  • 실패 화면에서 실제 값을 바로 읽고 원인 좁히기
  • 샘플 파일 읽기 같은 준비 작업을 한 곳에 모으기

비전공자 Python 공부를 시작한 지 스물일곱 번째 글입니다. 업무 자동화 스크립트를 만들다 보면 코드보다 검사가 더 늘어나는 시점이 오는데, 그때부터는 검사를 관리하는 도구가 따로 필요하다는 걸 이번에 알았습니다.

다음 포스팅에서는 실제 사이트 대신 가짜 응답을 돌려주는 방법을 정리해보겠습니다. 지금은 파싱 함수만 시험하고 있는데, requests.get()을 부르는 부분은 여전히 손도 못 대고 있거든요. unittest.mock으로 요청 함수를 바꿔치기해서 200 응답과 500 에러를 마음대로 만들어보는 것까지 해볼 생각입니다.