pytest mark 사용법, 마커와 디렉터리 분리 비교

pytest 마커 vs 디렉터리 분리, -m 오타 주의점

3줄 요약

  • 먼저 확인할 것은 --strict-markers가 막아 주는 범위입니다. 등록되지 않은 데코레이터는 수집 에러(exit 2)로 잡지만, -m 명령줄 표현식은 아예 보지 않습니다.
  • 그다음 할 일은 -m 표현식을 검사하는 conftest.py 가드입니다. 20줄짜리 훅 하나로 실측한 오타 3종이 전부 exit 4에서 멈췄습니다.
  • 여기서 멈추십시오. 수집 비용 차이는 2,400개 기준 0.54초뿐이고, 그 숫자 때문에 스위트를 디렉터리로 갈아엎을 이유는 없습니다.

야간 회귀가 40분씩 돌던 걸 스모크만 떼어 5분으로 줄이겠다고 -m smoke를 붙였습니다. 이틀은 잘 돌았습니다. 사흘째 파이프라인이 초록불인데 실행 시간이 12초로 찍혔고, 로그를 열어 보니 20 passed, 220 deselected가 아니라 2 passed, 238 deselected였습니다.

누군가 새로 넣은 테스트 파일에 @pytest.mark.smoke를 안 붙였던 겁니다. 그건 리뷰에서 잡으면 되는 일이라 대수롭지 않게 넘겼는데요, 그 김에 반대 방향을 한번 재 봤습니다. 마커 이름을 틀리면 pytest는 무엇을 알려 주는가. 결과가 예상과 달라서 실험을 제대로 짜고 다시 돌렸습니다.

Ubuntu 22.04.5 aarch64 · Python 3.10.12 · pytest 9.1.1(PyPI upload 2026-06-19T10:58:31.347074Z = KST 06-19 19:58) 환경에서 2026-09-16에 잰 값입니다.

1. 두 방식이 갈리는 곳은 실행 시간이 아니라 오타 처리입니다

먼저 결과 표부터 놓겠습니다. 아래 표의 각 행이 이 글의 나머지 섹션입니다.

판단 항목 마커 선택 (-m smoke) 디렉터리 분리 (pytest tests/smoke)
선택 이름을 틀렸을 때 케이스에 따라 exit 0 · 5로 갈림 항상 exit 4 (usage error)
한 테스트가 두 범주에 속할 때 마커를 겹쳐 붙이면 됨 파일을 한쪽에만 둘 수 있음
2,400개 중 200개 선택 수집 0.59초 0.05초
새 테스트가 규약을 빠뜨렸을 때 조용히 제외됨 디렉터리를 정해야 하므로 드러남
도입 난이도 pytest.ini 한 블록 파일 이동이 필요
유지보수 비용 마커 등록·가드 훅 관리 교차 속성이 생기면 구조 재설계
팀 학습곡선 -m 표현식 문법을 알아야 함 경로만 알면 됨

세 번째 행의 0.54초 차이는 이 글에서 가장 덜 중요한 숫자입니다. 그런데 도구 비교 글에서 보통 앞에 놓이는 숫자이기도 해서, 먼저 재고 먼저 치워 두려 합니다.

실행 단위를 무엇으로 자를 것인가

QA 4~6명 팀이 테스트를 200개쯤 쌓으면 반드시 마주치는 결정입니다. 배포 직후 5분 안에 도는 최소 확인, 매일 밤 도는 전체 회귀, 외부 연동 때문에 느린 것 — 이 셋을 나누는 방법이 두 가지 있습니다.

하나는 @pytest.mark.smoke 같은 마커를 붙이고 -m으로 고르는 방식이고, 다른 하나는 tests/smoke/처럼 디렉터리로 나누고 경로를 넘기는 방식입니다. 둘 다 흔하고 둘 다 공식 문서에 나옵니다.

둘 중 무엇을 고르든 파이프라인에는 명령 한 줄로 들어갑니다. 그래서 초기에는 차이가 없어 보이고, 실제로 스위트가 50개쯤일 때는 차이가 없습니다. 차이는 규약을 지키지 않는 일이 생길 때 드러납니다. 자동화에서 규약은 언제나 사람이 지키는 쪽과 도구가 지켜 주는 쪽으로 나뉘는데, 이 두 방식은 그 경계가 다른 자리에 있습니다.

이 글이 비교하지 않는 것

-k로 이름을 매칭하는 방식은 비교 대상에서 뺐습니다. 실행 단위를 이름 규약에 거는 건 리네임 한 번에 무너지는 구조라 팀 규약으로 쓰기 어렵습니다. 다만 4절에서 오타 동작만 함께 재겠습니다.

플러그인으로 범주를 나누는 방식도 다루지 않습니다. 플러그인을 하나 더 얹는 결정은 의존성 관리와 버전 고정까지 딸려오므로, 자동화 초기 단계 팀에는 표준 기능만으로 되는 선택지를 먼저 보여 주는 편이 낫다고 봤습니다.

2. 2,400개 스위트에서 잰 수집 비용

실험 스위트는 두 벌을 같은 내용으로 만들었습니다. A안은 파일 2,400개를 한 디렉터리에 두고 마커를 붙였고, B안은 같은 테스트를 smoke 200 · regression 2,000 · slow 200으로 디렉터리를 나눴습니다.

# 실험 스위트 생성 — A안(마커)과 B안(디렉터리)을 같은 내용으로 만듭니다
import os

A = os.path.expanduser("~/mk2/A/tests")
B = os.path.expanduser("~/mk2/B/tests")
plan = [("smoke", 200), ("regression", 2000), ("slow", 200)]

for mark, n in plan:
    for i in range(n):
        # A안: 한 디렉터리에 모아 두고 마커로 구분합니다
        with open(os.path.join(A, f"test_{mark}_{i:04d}.py"), "w") as f:
            f.write(f"import pytest\n\n@pytest.mark.{mark}\n"
                    f"def test_{mark}_{i:04d}():\n    assert True\n")
        # B안: 디렉터리로 구분하고 마커는 붙이지 않습니다
        with open(os.path.join(B, mark, f"test_{mark}_{i:04d}.py"), "w") as f:
            f.write(f"def test_{mark}_{i:04d}():\n    assert True\n")

각각 3회씩 돌린 값입니다. 캐시 영향을 빼려고 -p no:cacheprovider를 붙였습니다.

측정 A안 (마커) B안 (디렉터리) 차이
수집만 (--collect-only) 1회차 0.92초 0.07초 —
수집만 2·3회차 0.59초 0.05초 0.54초
스모크 200개 실행 0.64초 0.09초 0.55초
출력 200/2400 collected (2200 deselected) 200 tests collected —

마커 방식이 느린 이유는 구조 그대로입니다. pytest는 2,400개를 전부 수집한 뒤 2,200개를 버립니다. 디렉터리 방식은 애초에 200개만 읽습니다. 그래서 차이는 스위트 전체 크기에 비례해 벌어집니다.

출력 문자열도 같이 봐 두면 좋습니다. 마커 방식은 200/2400처럼 분모가 찍히므로 몇 개 중 몇 개를 골랐는지가 로그에 남습니다. 디렉터리 방식은 200 tests collected뿐이라 전체가 몇 개인지는 알 수 없습니다. 뒤에서 다룰 오타 검출은 결국 이 분모를 누가 세느냐의 문제로 돌아옵니다.

1회차만 0.92초로 튄 것은 파일 시스템 캐시 때문으로 보입니다. 2·3회차가 0.59초로 안정됐으므로 이후 계산에는 그 값을 씁니다.

이 0.54초를 근거로 쓰지 마십시오

배율로 보면 12배지만 절대값은 0.5초대입니다. GitHub Actions 잡 하나가 러너를 띄우고 의존성을 설치하는 데 쓰는 시간에 비하면 반올림 오차입니다. 파이프라인 시간을 실제로 줄이는 건 잡을 몇 개로 쪼갤 것인가 쪽이지 수집 방식이 아닙니다.

I/O가 없는 빈 테스트로 잰 값이라는 점도 감안해야 합니다. 실제 테스트에는 임포트 비용과 픽스처 준비가 붙으므로 수집 비중은 더 작아집니다.

3. --strict-markers가 막는 것과 보지 않는 것

여기서부터가 실제로 결정을 바꾸는 부분입니다. pytest 공식 문서는 마커를 등록하라고 권하면서 그 이유로 오타 검출을 듭니다. 그래서 pytest.ini에 마커를 등록하고 --strict-markers를 켜면 안전하다고 이해하기 쉽습니다.

절반만 맞습니다.

[pytest]
markers =
    smoke: 배포 직후 5분 안에 도는 최소 확인
    regression: 매일 밤 도는 전체 회귀
    slow: 외부 의존이나 대기가 있는 느린 테스트
addopts = --strict-markers

등록되지 않은 데코레이터는 확실히 막힙니다

@pytest.mark.sanity처럼 등록하지 않은 마커를 붙이면 수집 단계에서 끊깁니다.

$ pytest -q                      # addopts에 --strict-markers 가 들어 있음
==================================== ERRORS ====================================
ERROR tests/test_cross.py - Failed: 'sanity' not found in `markers` configura...
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.04s
  exit code = 2

옵션을 빼면 같은 코드가 PytestUnknownMarkWarning만 남기고 exit 0으로 통과합니다. 여기까지는 문서대로입니다.

-m 표현식은 검사 대상이 아닙니다

같은 설정 그대로 명령줄에서 마커 이름을 틀려 봤습니다.

$ pytest -q -m "smok"            # smoke 오타. addopts에 --strict-markers 있음
3 deselected in 0.00s
  exit code = 5

--strict-markers를 명시적으로 한 번 더 붙여도 결과는 같았습니다. 이 옵션은 테스트에 붙은 마커가 등록됐는지만 보고, 명령줄 표현식에 쓰인 이름은 대조하지 않습니다.

exit 5는 그나마 낫습니다. "No tests were collected"라는 뜻이고 0이 아니므로 CI 스텝은 실패합니다. 문제는 exit 5조차 나오지 않는 두 경우입니다.

4. 아무 신호도 남기지 않고 exit 0으로 끝나는 오타

이름을 붙여 두겠습니다. 팀에서 부를 이름이 있어야 리뷰에서 지적할 수 있습니다.

배제가 사라지는 오타

느린 테스트를 빼고 돌리려고 -m "not slow"를 쓰는 건 흔한 패턴입니다. 여기서 slow를 틀리면 배제 조건이 통째로 사라집니다.

$ pytest -q -m "not slow"        # 의도대로
220 passed, 20 deselected in 0.11s     exit=0

$ pytest -q -m "not sloww"       # 오타 한 글자
240 passed in 1.25s                    exit=0

240개가 전부 돌았고 exit 0입니다. 초록불이고, 스텝도 성공이고, 달라진 건 실행 시간뿐입니다. 느린 테스트가 20개가 아니라 200개짜리 외부 연동이었다면 파이프라인이 타임아웃까지 갔을 겁니다. 타임아웃이 걸렸을 때 어디까지 알려 주는지는 또 다른 이야기고요.

반쪽만 도는 선택

더 비싼 쪽입니다. 범주를 늘리면서 -m "smoke or sanity"처럼 이어 붙였는데 sanity를 등록하지 않았거나 이름을 틀린 경우입니다.

$ pytest -q -m "smoke or sanity"
20 passed, 220 deselected in 0.07s     exit=0

$ pytest -q -m "smoke or slow"         # 둘 다 등록된 이름
40 passed, 200 deselected in 1.21s     exit=0

앞은 20개, 뒤는 40개가 돌았습니다. 앞 명령은 sanity 20개를 돌린다고 믿고 쓴 것인데 실제로는 smoke만 돌았고, 경고 한 줄 없이 exit 0으로 끝났습니다. 로그에 찍힌 20 passed를 누가 세어 보기 전에는 아무도 모릅니다.

배제 오타는 "더 돌아서" 시간으로 드러날 가능성이라도 있는데, 이쪽은 "덜 돌아서" 더 빨라집니다. 파이프라인이 빨라졌다고 좋아할 여지까지 있습니다.

디렉터리 경로 오타가 즉시 걸리는 이유

같은 오타를 디렉터리 방식에서 내 봤습니다.

명령 출력 exit
pytest tests/smoke 20 passed 0
pytest tests/smok no tests ran 4
pytest -m smoke 20 passed, 220 deselected 0
pytest -m smok 240 deselected 5
pytest -m "not sloww" 240 passed 0
pytest -m "smoke or sanity" 20 passed, 220 deselected 0
pytest -k 결재 (이름 오타) no tests collected 5

경로는 파일 시스템에 있거나 없거나 둘 중 하나여서 pytest가 usage error로 끊습니다. 마커 표현식은 문법상 올바른 이름이면 무엇이든 유효한 질의로 취급됩니다. "그런 마커를 가진 테스트가 0개"와 "그런 마커가 존재하지 않음"을 pytest가 구분하지 않는 것이 이 차이의 전부입니다.

이게 버그라는 뜻은 아닙니다. 마커는 런타임에 훅으로도 붙일 수 있고 플러그인이 동적으로 추가하기도 하므로, 실행 전에 이름 집합을 확정할 수 없는 상황이 실제로 존재합니다. 그래서 pytest는 판단을 사용자에게 넘긴 것이고, 넘겨받은 쪽이 아무것도 하지 않으면 위 표의 굵은 두 줄이 남습니다.

-k 오타가 exit 5로 끝나는 것도 같은 성질입니다. 이름 부분 매칭이라 후보 집합이라는 게 없고, 0개 매칭과 잘못된 질의가 구분되지 않습니다. 다만 -k는 배제 표현을 파이프라인 상시 명령에 쓰는 일이 드물어서 실제 사고로는 덜 이어집니다.

exit code의 의미는 pytest 공식 문서의 Exit codes에 0부터 6까지 일곱 가지로 정리돼 있습니다. 4는 usage error, 5는 no tests collected입니다.

5. -m 표현식을 검사하는 conftest 20줄

pytest에 이 검사가 없으니 직접 붙였습니다. pytest_configure 훅에서 명령줄 표현식과 등록 마커 목록을 대조하면 됩니다.

# conftest.py — -m 표현식에 쓰인 이름이 markers 에 등록돼 있는지 확인합니다.
# pytest 9.1.1 에는 이 검사가 없어서 오타가 조용히 통과합니다.
import re

import pytest

_KEYWORDS = {"not", "and", "or", "True", "False", "None"}


def pytest_configure(config):
    expr = config.option.markexpr
    if not expr:
        return
    # markers = 항목은 "이름: 설명" 또는 "이름(인자): 설명" 형태입니다
    registered = {ln.split(":")[0].split("(")[0].strip()
                  for ln in config.getini("markers") if ln.strip()}
    used = set(re.findall(r"[A-Za-z_]\w*", expr)) - _KEYWORDS
    unknown = sorted(used - registered)
    if unknown:
        raise pytest.UsageError(
            f"-m 표현식에 등록되지 않은 마커가 있습니다: {unknown} / "
            f"등록된 마커: {sorted(registered)}"
        )

적용 후 실측

같은 스위트에서 앞의 오타 3종과 정상 표현식 2종을 다시 돌렸습니다. 가드를 넣기 전 값은 3절과 4절에서 잰 것입니다.

명령 가드 전 가드 후
-m "smoke" 20 passed, 220 deselected · exit 0 동일 · exit 0
-m "not slow" 220 passed, 20 deselected · exit 0 동일 · exit 0
-m "smok" 240 deselected · exit 5 등록되지 않은 마커: ['smok'] · exit 4
-m "not sloww" 240 passed · exit 0 등록되지 않은 마커: ['sloww'] · exit 4
-m "smoke or sanity" 20 passed · exit 0 등록되지 않은 마커: ['sanity'] · exit 4

정상 표현식 둘은 그대로 통과했고 오타 셋은 전부 exit 4에서 멈췄습니다. 디렉터리 방식의 경로 오타와 같은 exit code로 맞춰진 셈입니다.

에러 메시지에 등록된 마커 목록을 같이 찍는 건 일부러 넣었습니다. 오타를 낸 사람이 로그만 보고 올바른 이름을 찾을 수 있어야 파이프라인을 다시 돌리기 전에 고칩니다. 이름만 알려 주고 끝내면 결국 저장소를 열어 pytest.ini를 찾게 되는데, CI 로그에서 그 왕복이 한 번 생기면 다음부터는 아무도 안 읽습니다.

이 가드가 놓치는 자리

마커에 인자를 쓰는 -m "device(serial='123')" 같은 표현식에서는 정규식이 serial까지 이름으로 잡습니다. 그런 표현식을 쓰는 팀은 _KEYWORDS에 키워드 인자 이름을 추가하거나 괄호 안쪽을 먼저 제거해야 합니다.

그리고 이 훅은 이름이 등록됐는지만 봅니다. 등록은 돼 있는데 그 마커가 붙은 테스트가 0개인 경우는 여전히 exit 5로 나갑니다. 새 범주를 만들고 데코레이터를 아직 안 붙인 상황이 여기에 해당합니다.

CI에서 개수 하한으로 한 겹 더

그 빈틈은 개수로 막는 게 쌉니다. 스모크가 최소 몇 개는 잡혀야 한다는 값을 파이프라인에 적어 둡니다.

#!/usr/bin/env bash
# 마커 선택이 최소 몇 개는 잡히는지 CI 에서 확인합니다.
# 사용법: ./guard.sh "smoke" 2
set -u
EXPR="$1"; MIN="$2"
N=$(pytest -q --collect-only -m "$EXPR" -p no:cacheprovider 2>/dev/null \
    | grep -oE '^[0-9]+' | head -1)
N=${N:-0}
if [ "$N" -lt "$MIN" ]; then
  echo "선택된 테스트 ${N}개 < 하한 ${MIN}개 — '-m ${EXPR}' 를 확인하십시오" >&2
  exit 1
fi
echo "선택 ${N}개 (하한 ${MIN}) OK"

실행 결과는 ./guard.sh "smoke" 2가 선택 2개 (하한 2) OK로 exit 0, ./guard.sh "smok" 2가 선택된 테스트 0개 < 하한 2개로 exit 1이었습니다. 수집만 하므로 비용은 앞 절에서 잰 0.59초입니다.

6. 테스트 200개를 넘기기 전에 정해 둘 것

제가 택한 건 둘 중 하나가 아니라 역할을 나누는 쪽이었습니다. 실행 단위는 디렉터리로, 교차 속성은 마커로 둡니다. 스모크냐 회귀냐는 파일이 어디 있느냐로 정해지고, 결제 도메인인지 외부 연동에 의존하는지는 마커로 붙입니다.

tests/
  smoke/        ← 배포 직후 도는 것. pytest tests/smoke
  regression/   ← 야간 전체.       pytest tests/regression
마커: payment, external  ← 도메인·의존성 같은 교차 속성만

이렇게 두면 매일 도는 명령에는 -m이 안 들어가므로 오타 노출면이 줄고, 마커는 pytest tests/smoke -m "not external"처럼 필요할 때만 얹힙니다. 실제로 이 구성에서 세 명령을 돌려 보니 각각 2개 · 2개 · 2개가 의도대로 잡혔습니다.

다만 스위트가 이미 한 디렉터리에 2,000개 쌓여 있다면 이야기가 다릅니다. 파일을 옮기는 순간 진행 중인 브랜치가 전부 충돌하고 git blame이 끊깁니다. 그 비용이 0.54초보다 훨씬 큽니다. 그럴 땐 마커를 유지하고 가드만 붙이는 게 맞습니다.

정리하면 이번 주에 할 일은 이 정도입니다.

□ pytest.ini 의 markers 에 쓰는 마커가 전부 등록돼 있는가
□ addopts 에 --strict-markers 가 들어 있는가 (데코레이터 오타 차단)
□ conftest.py 에 -m 표현식 가드가 있는가 (명령줄 오타 차단)
□ 파이프라인에 -m 이 들어간 명령이 몇 개인지 세어 봤는가
□ 그 명령마다 선택 개수 하한이 정해져 있는가
□ 새 범주를 만들 때 등록 → 데코레이터 → 하한 순서로 넣기로 합의했는가

마지막 줄이 제일 중요한데요, 순서를 뒤집어 데코레이터부터 붙이면 그 사이 기간에 -m 명령이 exit 5로 죽습니다. 등록을 먼저 하면 가드는 통과하고 개수 하한에서만 걸리므로 무엇이 빠졌는지가 바로 보입니다.


테스트 파일 이름이 겹쳐 수집 자체가 실패하는 경우는 pytest import file mismatch 오류를 재현해 본 글에서 따로 다뤘습니다. 마커 대신 메타데이터로 테스트를 분류하는 방식은 Robot Framework 7.5의 테스트 메타데이터를 태그와 비교한 글에 정리했습니다.

댓글

이 블로그의 인기 게시물

옵시디언 3기기 동기화, 유료 결제 없이 구글 드라이브로 끝내기

최저가 주유소, 얼마나 돌아가면 손해일까?

회의시간 줄이는 법, 개수보다 배치 먼저

유류세 인하 2개월 연장, 주유소 가격 실제 인하폭 계산

국민연금 추납 조회, 미루면 얼마나 더 낼까?