Playwright + pytest 테스트 구성 베스트 프랙티스|폴더 설계·fixture·mark 운용을 실무 수준으로 해설

테스트 자동화

Playwright와 pytest를 단순히 연결하는 것에 그치지 않고, 폴더 구성·fixture 설계·mark 운용을 처음부터 올바르게 설계하면 장기적으로 유지보수할 수 있는 테스트 자동화 기반을 구축할 수 있습니다.

📌 이런 분께 추천합니다

  • Playwright와 pytest로 E2E 테스트를 작성하기 시작했지만 구성이 지저분해진 분
  • conftest.py·fixture·mark를 올바르게 활용하고 싶은 QA 엔지니어
  • 팀에서 운용할 수 있는 테스트 구성의 「정석」을 익히고 싶은 분
  • 기존 테스트 코드를 리팩터링하고 싶은 분

✅ 이 글을 읽으면 얻을 수 있는 것

  • 실무에서 사용할 수 있는 폴더 구성·conftest.py·pytest.ini의 완성형을 알 수 있다
  • fixture 계층 설계와 scope 사용 구분을 실제 예시로 이해할 수 있다
  • smoke·regression·e2e의 mark 운용으로 테스트를 스마트하게 관리할 수 있다

👤 이 글을 쓴 사람

QA 엔지니어로서 실무에서 Selenium·Playwright·pytest를 활용한 테스트 자동화에 종사 중입니다. 실제 프로젝트에서 시행착오를 거쳐 도달한 구성을 바탕으로, 현장에서 진짜 사용할 수 있는 설계 패턴만을 해설합니다. 구현 코드는 GitHub에 공개 중입니다: github.com/YOSHITSUGU728/automated-testing-portfolio

📌 결론:베스트 프랙티스의 3가지 기둥

  • 폴더 구성을 정형화한다:pages/·tests/·utils/의 3계층으로 나누어 코드의 역할을 명확히 한다
  • fixture를 계층 설계한다:session → module → function의 scope를 목적에 맞게 사용 구분한다
  • mark로 실행을 제어한다:smoke·regression·e2e의 그룹화로 CI/CD와 스마트하게 연계한다

Playwright와 Python을 조합하면 간단하게 E2E 테스트를 작성할 수 있습니다. 하지만 테스트가 늘어나면 다음과 같은 문제에 부딪히는 경우가 많습니다.

  • fixture가 늘어나서 어디에 작성해야 할지 모르게 된다
  • 셀렉터가 테스트 코드 곳곳에 흩어진다
  • CI/CD에서 어떤 테스트를 언제 실행할지 관리할 수 없게 된다

이 글에서는 이러한 문제를 방지하기 위한 Playwright × pytest 베스트 프랙티스 구성을 폴더 설계부터 fixture·conftest.py·pytest.ini까지 한 번에 해설합니다.

① 권장 폴더 구성

먼저 전체 폴더 구성을 확인합니다. 소규모 프로젝트용 심플 버전과 중~대규모용 확장 버전의 2가지 패턴을 소개합니다.

심플 버전(소규모·학습용)

my_project/
├── pages/                    # Page Object Model(페이지 조작 클래스)
│   ├── __init__.py
│   ├── login_page.py
│   └── inventory_page.py
├── tests/                    # 테스트 파일
│   ├── conftest.py           # 공통 fixture
│   ├── test_login.py
│   └── test_inventory.py
├── pytest.ini                # pytest 설정
└── requirements.txt

확장 버전(중~대규모·팀 운용용)

my_project/
├── pages/                    # Page Object Model
│   ├── __init__.py
│   ├── base_page.py          # 전체 페이지 공통 기저 클래스
│   ├── login_page.py
│   └── inventory_page.py
├── tests/                    # 테스트 파일
│   ├── conftest.py           # 세션·브라우저 계 fixture
│   ├── e2e/                  # E2E 테스트(플로우 전체)
│   │   ├── conftest.py       # E2E 전용 fixture
│   │   └── test_purchase_flow.py
│   ├── smoke/                # 스모크 테스트(최소한의 동작 확인)
│   │   └── test_login_smoke.py
│   └── regression/           # 회귀 테스트
│       └── test_cart.py
├── utils/                    # 헬퍼 함수·상수
│   ├── __init__.py
│   └── constants.py          # URL·테스트 데이터 상수
├── reports/                  # 테스트 리포트 출력처
├── pytest.ini
└── requirements.txt
💡 설계의 원칙:「테스트 코드는 무엇을 테스트하는가」(tests/),「pages 코드는 어떻게 조작하는가」(pages/),「utils는 어떤 공통 처리인가」(utils/)를 명확히 분리하면 어떤 파일에 무엇을 작성해야 할지 헷갈리지 않게 됩니다.

② pytest.ini 완성형

프로젝트 루트에 두는 pytest.ini입니다. 이것만 있으면 팀 전원이 같은 설정으로 테스트를 실행할 수 있습니다.

# pytest.ini
[pytest]
# 테스트 검색 경로
testpaths = tests

# 기본 옵션
addopts =
    -v
    --tb=short
    --html=reports/report.html
    --self-contained-html

# 커스텀 마크 등록(미등록 시 경고가 출력됨)
markers =
    smoke: 스모크 테스트(최소한의 동작 확인·매번 실행)
    regression: 회귀 테스트(릴리스 전에 실행)
    e2e: E2E 테스트(플로우 전체·시간이 걸림)
    slow: 실행 시간이 긴 테스트

# 로그 설정
log_cli = true
log_cli_level = INFO
log_format = %(asctime)s %(levelname)s %(message)s
log_date_format = %Y-%m-%d %H:%M:%S

③ conftest.py 계층 설계

conftest.py는 「어떤 스코프의 fixture인가」에 따라 배치 장소를 나누는 것이 베스트 프랙티스입니다.

tests/conftest.py(프로젝트 전체에서 공유)

# tests/conftest.py
import pytest
from playwright.sync_api import sync_playwright

# ============================================================
# 브라우저·페이지 계 fixture(세션 스코프)
# ============================================================

@pytest.fixture(scope="session")
def browser_instance():
    """테스트 전체에서 하나의 브라우저 인스턴스를 공유한다"""
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        yield browser
        browser.close()

@pytest.fixture(scope="function")
def page(browser_instance):
    """테스트마다 새로운 페이지(탭)를 생성한다"""
    context = browser_instance.new_context(
        viewport={"width": 1280, "height": 720}
    )
    page = context.new_page()
    yield page
    context.close()  # 테스트 종료 후 컨텍스트를 닫는다

# ============================================================
# 인증 계 fixture
# ============================================================

@pytest.fixture(scope="session")
def base_url():
    """테스트 대상의 베이스 URL을 반환한다"""
    # 실제 코드에서는 https:// 를 붙여주세요(예:"https://www.saucedemo.com")
    return "www.saucedemo.com"

@pytest.fixture
def valid_credentials():
    """정상계 테스트용 로그인 정보"""
    return {
        "username": "standard_user",
        "password": "secret_sauce"
    }

@pytest.fixture
def locked_out_credentials():
    """잠긴 사용자의 로그인 정보(비정상계 테스트용)"""
    return {
        "username": "locked_out_user",
        "password": "secret_sauce"
    }

# ============================================================
# 로그인 완료 페이지 fixture(자주 사용하므로 공통화)
# ============================================================

@pytest.fixture
def logged_in_page(page, base_url, valid_credentials):
    """로그인 완료 상태에서 page를 반환하는 fixture"""
    page.goto(f"https://{base_url}/")
    page.fill("#user-name", valid_credentials["username"])
    page.fill("#password", valid_credentials["password"])
    page.click("#login-button")
    page.wait_for_url("**/inventory.html")
    return page

tests/e2e/conftest.py(E2E 테스트 전용)

# tests/e2e/conftest.py
import pytest

@pytest.fixture
def cart_items():
    """E2E 테스트용 카트 아이템 테스트 데이터"""
    return ["Sauce Labs Backpack", "Sauce Labs Bike Light"]

@pytest.fixture
def shipping_info():
    """E2E 테스트용 배송 정보"""
    return {
        "first_name": "테스트",
        "last_name": "사용자",
        "postal_code": "06000"
    }
💡 fixture 계층 사용 구분tests/conftest.py에는 프로젝트 전체에서 사용하는 브라우저·인증 계 fixture를 둡니다. 특정 테스트 카테고리에만 사용하는 fixture는 해당 카테고리의 conftest.py에 두면 불필요한 fixture가 혼재하지 않습니다.

④ Page Object Model 구현

pytest와 조합하는 Page Object Model의 구현 예시입니다. 기저 클래스에 공통 처리를 모으는 것이 포인트입니다.

pages/base_page.py(기저 클래스)

# pages/base_page.py
from playwright.sync_api import Page

class BasePage:
    """전체 페이지 공통 조작을 모은 기저 클래스"""

    def __init__(self, page: Page):
        self.page = page

    def navigate(self, url: str):
        """지정한 URL로 이동한다"""
        self.page.goto(url)

    def get_title(self) -> str:
        """페이지 타이틀을 반환한다"""
        return self.page.title()

    def take_screenshot(self, name: str):
        """스크린샷을 저장한다"""
        self.page.screenshot(path=f"reports/{name}.png")

pages/login_page.py

# pages/login_page.py
from pages.base_page import BasePage

class LoginPage(BasePage):
    """로그인 페이지의 조작 클래스"""

    # 로케이터 상수(셀렉터를 한 곳에서 관리)
    USERNAME_INPUT = "#user-name"
    PASSWORD_INPUT = "#password"
    LOGIN_BUTTON   = "#login-button"
    ERROR_MESSAGE  = ".error-message-container h3"

    def login(self, username: str, password: str):
        """로그인을 실행한다"""
        self.page.fill(self.USERNAME_INPUT, username)
        self.page.fill(self.PASSWORD_INPUT, password)
        self.page.click(self.LOGIN_BUTTON)

    def get_error_message(self) -> str:
        """에러 메시지 텍스트를 반환한다"""
        return self.page.locator(self.ERROR_MESSAGE).text_content()

⑤ 테스트 파일 구현

fixture와 Page Object를 조합한 테스트 파일의 완성형입니다.

tests/smoke/test_login_smoke.py

# tests/smoke/test_login_smoke.py
import pytest
from pages.login_page import LoginPage

@pytest.mark.smoke
def test_로그인_정상(page, base_url, valid_credentials):
    """스모크:정상 로그인이 성공하는 것을 확인"""
    login_page = LoginPage(page)
    login_page.navigate(f"https://{base_url}/")
    login_page.login(
        valid_credentials["username"],
        valid_credentials["password"]
    )
    assert page.url.endswith("/inventory.html"), \
        f"로그인 후 URL이 기대값과 다릅니다: {page.url}"

@pytest.mark.smoke
def test_로그인_비밀번호_오류(page, base_url):
    """스모크:잘못된 비밀번호로 에러 메시지가 표시되는 것을 확인"""
    login_page = LoginPage(page)
    login_page.navigate(f"https://{base_url}/")
    login_page.login("standard_user", "wrong_password")
    error = login_page.get_error_message()
    assert "Epic sadface" in error

@pytest.mark.smoke
def test_로그인_잠긴_사용자(page, base_url, locked_out_credentials):
    """스모크:잠긴 사용자에게 에러가 표시되는 것을 확인"""
    login_page = LoginPage(page)
    login_page.navigate(f"https://{base_url}/")
    login_page.login(
        locked_out_credentials["username"],
        locked_out_credentials["password"]
    )
    error = login_page.get_error_message()
    assert "locked out" in error

tests/e2e/test_purchase_flow.py

# tests/e2e/test_purchase_flow.py
import pytest

@pytest.mark.e2e
@pytest.mark.slow
def test_구매_플로우_정상(logged_in_page, cart_items, shipping_info):
    """E2E:로그인~구매 완료까지의 플로우를 테스트"""
    page = logged_in_page

    # 상품을 카트에 추가
    page.click("[data-test='add-to-cart-sauce-labs-backpack']")
    page.click("[data-test='add-to-cart-sauce-labs-bike-light']")

    # 카트로 이동
    page.click(".shopping_cart_link")
    assert page.url.endswith("/cart.html")

    # 체크아웃 시작
    page.click("[data-test='checkout']")
    page.fill("[data-test='firstName']", shipping_info["first_name"])
    page.fill("[data-test='lastName']", shipping_info["last_name"])
    page.fill("[data-test='postalCode']", shipping_info["postal_code"])
    page.click("[data-test='continue']")

    # 주문 확인·완료
    page.click("[data-test='finish']")
    success_msg = page.locator(".complete-header").text_content()
    assert "Thank you" in success_msg

⑥ mark를 사용한 테스트 실행 제어

mark를 설정해 두면 CI/CD의 페이즈에 맞게 실행할 테스트 세트를 유연하게 전환할 수 있습니다.

mark용도실행 타이밍실행 시간 목표
smoke최소한의 동작 확인PR·push마다 실행~2분
regression기존 기능의 회귀 확인main 머지 전~10분
e2e플로우 전체 확인릴리스 전·야간 스케줄~30분
slow시간이 걸리는 테스트야간 스케줄만30분~
# 스모크 테스트만 실행(PR·push마다)
pytest -m smoke

# 스모크·회귀 테스트 실행(main 머지 전)
pytest -m "smoke or regression"

# E2E를 제외한 전체 테스트(빠르게 확인하고 싶을 때)
pytest -m "not e2e"

# slow를 제외한 전체 테스트
pytest -m "not slow"

# 전체 테스트 실행(야간 스케줄)
pytest

⑦ GitHub Actions 연계

mark와 GitHub Actions를 조합하면 페이즈에 따라 실행할 테스트를 자동으로 전환할 수 있습니다.

# .github/workflows/playwright.yml(발췌)

jobs:
  smoke:
    name: 스모크 테스트(PR·push)
    runs-on: ubuntu-latest
    steps:
      # ... 설정 생략 ...
      - name: 스모크 테스트 실행
        run: pytest -m smoke

  regression:
    name: 회귀 테스트(main으로 머지 후)
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      # ... 설정 생략 ...
      - name: 회귀 테스트 실행
        run: pytest -m "smoke or regression"

  nightly:
    name: 야간 전체 테스트
    runs-on: ubuntu-latest
    if: github.event_name == 'schedule'
    steps:
      # ... 설정 생략 ...
      - name: 전체 테스트 실행
        run: pytest
💡 실무 팁:PR마다 전체 테스트를 실행하면 CI가 느려져 리뷰 리듬이 흐트러집니다. 스모크 테스트만 PR에 연결하고 E2E·회귀 테스트는 main 머지 후나 스케줄 실행으로 돌리면 속도와 품질의 균형을 맞출 수 있습니다.

⑧ 실행 결과 샘플

$ pytest -m smoke -v

========================= test session starts ==========================
platform linux -- Python 3.11.0, pytest-7.4.0
collected 8 items / 5 deselected / 3 selected

tests/smoke/test_login_smoke.py::test_로그인_정상          PASSED  [ 33%]
tests/smoke/test_login_smoke.py::test_로그인_비밀번호_오류   PASSED  [ 66%]
tests/smoke/test_login_smoke.py::test_로그인_잠긴_사용자    PASSED  [100%]

========================== 3 passed in 5.21s ===========================

FAQ

Q. page fixture는 pytest-playwright 내장과 자작 중 어느 것을 사용해야 하나요?

소규모 프로젝트나 학습 목적이라면 pytest-playwright의 내장 page fixture를 사용하는 것이 간편합니다. 다만 실무에서는 뷰포트 크기·네트워크 설정·인증 정보 주입 등 세밀한 제어가 필요하기 때문에 자작 fixture를 사용하는 케이스가 많아집니다. 이 글의 코드는 자작 fixture를 채용하고 있습니다.

Q. conftest.py는 하나로 통합해야 하나요, 분리해야 하나요?

테스트가 20건 이하의 소규모라면 tests/conftest.py에 전부 통합해도 문제없습니다. 테스트가 늘어나면 「세션·브라우저 계는 tests/conftest.py」「카테고리 전용은 각 서브디렉토리의 conftest.py」로 분리하는 것이 베스트 프랙티스입니다. 분리하면 「이 fixture는 어떤 테스트에서 사용할 수 있는가」가 한눈에 알 수 있게 됩니다.

Q. Page Object Model은 처음부터 사용해야 하나요?

테스트가 5~10건 이하의 초기에는 POM 없이도 관리할 수 있습니다. 「같은 셀렉터를 여러 테스트 파일에 작성하기 시작했다」고 느낀 타이밍이 도입의 신호입니다. 빨리 도입할수록 리팩터링 비용이 낮아지므로 처음부터 도입하는 것을 추천합니다.

Q. browser scope를 session으로 하면 테스트 간에 상태가 오염되지 않나요?

브라우저 인스턴스는 세션 스코프로 공유해도 문제없습니다. 중요한 것은 페이지(탭)는 반드시 function 스코프로 하여 테스트마다 새로 생성하는 것입니다. 이 글의 코드는 브라우저는 session·페이지는 function 스코프로 설계되어 있어 테스트 간 상태 오염을 방지하고 있습니다.

⚠️ Playwright + pytest 구성에서 자주 발생하는 함정 7선

올바른 구성을 갖추고 있어도 빠지기 쉬운 함정이 있습니다. 미리 파악해 두면 불필요한 디버깅 시간을 줄일 수 있습니다.

① browser를 function 스코프로 하면 테스트가 너무 느려진다

브라우저 기동은 무거운 처리입니다. scope="function"으로 하면 테스트마다 브라우저가 기동·종료되기 때문에 테스트가 50건이면 기동만으로 수분이 걸립니다. 브라우저는 scope="session"·페이지는 scope="function"의 조합이 가장 효율적입니다.

② context를 닫는 것을 잊어 메모리 누수가 발생한다

browser.new_context()로 생성한 컨텍스트는 테스트 종료 후에 반드시 context.close()를 해야 합니다. fixture의 yield 뒤에 context.close()를 작성하는 것을 잊으면 테스트를 거듭할수록 메모리 사용량이 계속 증가합니다.

③ logged_in_page fixture를 session 스코프로 하면 테스트가 서로 영향을 준다

「테스트마다 로그인하는 것이 느리다」는 이유로 logged_in_pagescope="session"으로 하면 어떤 테스트가 수행한 카트 조작·페이지 이동이 다음 테스트로 인계되어 예기치 않은 실패가 발생합니다. 인증 상태를 공유하고 싶은 경우에는 storage_state를 사용하여 세션 정보를 파일로 저장하는 접근법이 안전합니다.

④ 셀렉터를 테스트 코드에 직접 작성하면 POM의 의미가 없어진다

Page Object를 만들어도 테스트 코드 내에서 page.click("#login-button")처럼 셀렉터를 직접 작성하면 셀렉터 변경 시에 테스트 코드 전체를 수정해야 하는 상황이 됩니다. 셀렉터는 Page Object 클래스의 상수로 정의하고, 테스트 코드에서는 반드시 Page Object의 메서드를 통해 조작하는 것이 원칙입니다.

⑤ mark를 pytest.ini에 등록하지 않고 -m smoke를 실행해도 필터링되지 않는다

커스텀 mark는 @pytest.mark.smoke라고 데코레이터를 붙이는 것만으로는 불충분하며, pytest.ini[pytest] markers =에 등록하지 않으면 「PytestUnknownMarkWarning」이 출력됩니다. 더욱이 미등록 mark로 pytest -m smoke를 실행하면 마크가 인식되지 않아 전체 테스트가 실행되어 버리는 경우가 있습니다.

⑥ pages/ 폴더에 __init__.py가 없으면 import 에러가 발생한다

Python 패키지로 인식시키기 위해 pages/ 폴더에는 빈 __init__.py 파일이 필요합니다. 이것이 없으면 from pages.login_page import LoginPageModuleNotFoundError가 됩니다. utils/ 폴더도 동일합니다.

⑦ 프로젝트 루트 이외에서 실행하면 ModuleNotFoundError가 발생한다

pytest는 항상 프로젝트 루트(pytest.ini가 있는 디렉토리)에서 실행하세요. tests/ 폴더 안에서 실행하면 pages 모듈을 찾을 수 없게 됩니다.

# ❌ NG
cd tests
pytest

# ✅ 권장
cd my_project    # pytest.ini가 있는 곳
pytest

📋 이 글의 정리

  • 폴더 구성은 pages/·tests/·utils/의 3계층으로 나누면 역할이 명확해지고 유지보수하기 쉬워진다
  • conftest.py는 스코프별로 계층화하고 브라우저는 session·페이지는 function 스코프가 기본
  • Page Object Model의 셀렉터는 상수화하여 테스트 코드에서 직접 작성하지 않는다
  • mark로 smoke·regression·e2e를 분류하면 CI/CD의 페이즈에 맞는 실행 제어가 가능하다
  • pytest.ini에서 markers·addopts·testpaths를 정의하면 팀 전원이 같은 설정으로 실행할 수 있다

처음부터 완벽한 구성을 목표로 할 필요는 없습니다. 먼저 심플 버전의 폴더 구성에서 시작하여 테스트가 늘어난 타이밍에 조금씩 확장 버전에 가까워져 가는 것이 실무적인 접근법입니다.

제목과 URL을 복사했습니다