Playwright의 Page Object Model 응용편|pytest Fixtures 연동과 컴포넌트 설계로 유지보수성을 한 단계 더 높이기

Page Object Model(POM)의 기본은 이해했지만, 실제 프로젝트에서 운용하다 보면 「매번 Page Object를 인스턴스화하는 게 번거롭다」 「헤더나 모달 등 공통 UI 코드가 각 페이지마다 중복된다」 「로그인 처리가 느려서 테스트 전체 실행 시간이 늘어난다」와 같은 벽에 부딪히는 경우가 있습니다. 이 글에서는 pytest의 Fixtures·Component Object 패턴·storage_state를 조합해, 기본 POM을 한 단계 더 끌어올리는 응용 테크닉을 소개합니다.

POM의 「페이지마다 클래스를 만든다」는 기본 원칙만으로는 테스트가 늘어날수록 또 다른 과제가 나타납니다. Fixtures와의 연동 설계가 실무 운용 효율을 크게 좌우합니다.

📌 이런 분께 추천합니다

  • Page Object Model의 기본(페이지별 클래스 설계)은 이미 실천하고 있는 분
  • 매 테스트마다 LoginPage(page) 같은 인스턴스화 코드를 작성하는 게 번거로운 분
  • 헤더·모달 등 여러 페이지에 공통되는 UI 코드가 중복되고 있는 분
  • 로그인 처리 실행 시간이 테스트 스위트 전체를 느리게 만든다고 느끼는 분

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

  • pytest Fixture로 Page Object를 자동 주입해 테스트 코드에서 보일러플레이트를 없애는 방법을 알 수 있다
  • Component Object 패턴으로 공통 UI의 중복을 해소하는 설계를 알 수 있다
  • storage_state를 사용해 인증된 상태를 재사용하고 테스트 실행 시간을 단축하는 방법을 알 수 있다

👤 이 글을 쓴 사람

QA 엔지니어·테스트 자동화 엔지니어로 15년 이상의 실무 경험을 가진 Yoshi가 작성했습니다. Playwright × pytest를 이용한 테스트 기반 구축을 여러 프로젝트에서 담당하며, Page Object Model의 운용 설계를 실무에서 지속적으로 개선해 왔습니다.

📌 결론

  • Page Object는 pytest Fixture로 자동 주입하면 테스트 코드에서 초기화 처리가 사라져 읽기 쉬워진다
  • 헤더처럼 여러 페이지에 공통되는 UI는 Component Object로 분리하면 중복과 비대화를 막을 수 있다
  • storage_state로 인증된 상태를 재사용하면 로그인 처리 반복으로 인한 실행 시간 증가를 억제할 수 있다

기본 Page Object Model을 도입한 프로젝트가 성장하면 다음과 같은 과제가 나타나기 시작합니다.

  • 모든 테스트에서 LoginPage(page)·DashboardPage(page) 같은 인스턴스화 코드가 반복된다
  • 헤더·사이드바·알림 모달 등 여러 페이지에 공통되는 UI 조작 코드가 각 Page Object에 중복된다
  • 「로그인 → 대시보드 확인」 같은 테스트가 늘어날 때마다 로그인 처리가 반복 실행되어 전체 실행 시간이 늘어난다
  • 여러 페이지를 넘나드는 업무 플로우(구매 절차 등)의 테스트 코드가, 여러 Page Object를 호출하는 긴 나열이 된다

이 글에서는 이러한 과제를 해결하는 4가지 응용 테크닉을 순서대로 소개합니다.

이 글의 위치

이 글은 Page Object Model의 기본(클래스 설계·POM 유무 비교·디렉터리 구성)을 이해하고 있는 분을 위한 응용편입니다. POM의 기초부터 복습하고 싶다면, 먼저 기초 글을 읽는 것을 추천합니다.

💡 전제 글:Page Object Model이란?Playwright×Python으로 코드의 유지보수성을 높이는 설계 패턴 입문에서는 POM의 기본 개념·POM 유무에 따른 코드 비교·디렉터리 구성·베스트 프랙티스를 다룹니다. 이 글은 그 후속편으로, 더 실전적인 설계 패턴에 초점을 맞춥니다.

① pytest Fixture로 Page Object를 자동 주입하는 방법

기본 POM에서는 각 테스트 안에서 LoginPage(page)처럼 Page Object를 수동으로 인스턴스화합니다. 테스트가 늘어나면 이 초기화 코드가 여러 곳에 흩어져, 테스트의 본질(무엇을 검증하고 싶은지)이 읽기 어려워집니다. pytest Fixture가 Page Object 생성을 대신하도록 하면, 테스트 코드는 Page Object를 받아서 사용하기만 하면 됩니다.

❌ Fixture화 전:매번 인스턴스화하는 코드

from pages.login_page import LoginPage
from pages.dashboard_page import DashboardPage

def test_login_success(page):
    # 테스트할 때마다 Page Object 인스턴스화가 필요
    login_page = LoginPage(page)
    login_page.navigate()
    login_page.login("user@example.com", "secret123")

    dashboard_page = DashboardPage(page)
    assert dashboard_page.is_visible()

def test_login_failure(page):
    # 같은 인스턴스화 코드가 또 등장한다
    login_page = LoginPage(page)
    login_page.navigate()
    login_page.login("wrong@example.com", "wrongpass")
    assert login_page.is_error_visible()

✅ Fixture화 후:conftest.py에 주입 처리를 모으기

# conftest.py
import pytest
from playwright.sync_api import Page
from pages.login_page import LoginPage
from pages.dashboard_page import DashboardPage

@pytest.fixture
def login_page(page: Page) -> LoginPage:
    """LoginPage를 생성하고 로그인 페이지로의 이동까지 마친 뒤 전달한다"""
    lp = LoginPage(page)
    lp.navigate()
    return lp

@pytest.fixture
def dashboard_page(page: Page) -> DashboardPage:
    return DashboardPage(page)
# test_login.py
def test_login_success(login_page, dashboard_page):
    # 인스턴스화·이동이 끝난 상태에서 시작할 수 있다
    login_page.login("user@example.com", "secret123")
    assert dashboard_page.is_visible()

def test_login_failure(login_page):
    login_page.login("wrong@example.com", "wrongpass")
    assert login_page.is_error_visible()
🔍 실무 경험담:Fixture화하면 「어느 Fixture가 어느 Page Object를 반환하는지」가 conftest.py에 집약되므로, 새로운 팀원이 테스트 코드를 읽을 때도 파악하기 쉬워집니다. Page Object 수가 10개를 넘어서면 Fixture화의 효과를 확실히 체감할 수 있습니다.

② Component Object 패턴으로 공통 UI 분리하기

헤더·사이드바·알림 모달 등 여러 페이지에 공통으로 등장하는 UI 요소를 Page Object마다 작성하면, 같은 로케이터나 조작 메서드가 여러 곳에 중복됩니다. 이런 공통 UI는 Component Object로 독립시켜 각 Page Object에서 합성(컴포지션)하는 설계로 만들면 중복을 방지할 수 있습니다.

Component Object 정의

# components/header_component.py
from playwright.sync_api import Page

class HeaderComponent:
    """여러 페이지에 공통으로 표시되는 헤더의 Component Object"""

    def __init__(self, page: Page):
        self.page = page
        self.logout_button      = page.get_by_role("button", name="로그아웃")
        self.notification_icon  = page.get_by_test_id("notification-icon")
        self.search_input       = page.get_by_placeholder("검색")

    def logout(self) -> None:
        self.logout_button.click()

    def open_notifications(self) -> None:
        self.notification_icon.click()

    def search(self, keyword: str) -> None:
        self.search_input.fill(keyword)
        self.search_input.press("Enter")

Page Object에서 Component를 합성하기

# pages/dashboard_page.py
from playwright.sync_api import Page
from components.header_component import HeaderComponent

class DashboardPage:
    """대시보드 페이지의 Page Object"""

    def __init__(self, page: Page):
        self.page = page
        self.header = HeaderComponent(page)  # ← Component를 합성한다
        self.summary_card = page.get_by_test_id("summary-card")

    def is_visible(self) -> bool:
        return self.summary_card.is_visible()
# 테스트 코드에서는 header를 통해 접근할 수 있다
def test_logout_from_dashboard(dashboard_page):
    dashboard_page.header.logout()
    # ... 로그아웃 후 확인 ...
Component화하지 않음Component화함
헤더 로케이터가 10개의 Page Object에 중복HeaderComponent 1곳만 수정하면 끝
헤더 사양 변경으로 10개 파일 수정HeaderComponent 1개 파일만 수정

③ storage_state로 인증 상태를 재사용해 고속화하기

로그인이 필요한 테스트가 많은 프로젝트에서는 모든 테스트에서 매번 로그인 처리를 실행하는 것이 실행 시간을 늘리는 큰 요인이 됩니다. Playwright의 storage_state를 사용하면, 한 번 로그인한 상태(쿠키·localStorage)를 저장해 이후 테스트에서는 로그인 처리를 건너뛰고 재사용할 수 있습니다.

세션 단위로 인증 상태를 한 번만 생성하기

# conftest.py
import pytest
from playwright.sync_api import Browser, Page

@pytest.fixture(scope="session")
def authenticated_state(browser: Browser, base_url: str) -> dict:
    """테스트 스위트 전체에서 1회만 로그인하고 인증 상태를 저장한다"""
    context = browser.new_context()
    page = context.new_page()
    page.goto(f"{base_url}/login")
    page.get_by_label("이메일").fill("test@example.com")
    page.get_by_label("비밀번호").fill("Password123")
    page.get_by_role("button", name="로그인").click()
    page.wait_for_url("**/dashboard")

    state = context.storage_state()
    context.close()
    return state

@pytest.fixture
def authenticated_page(browser: Browser, authenticated_state: dict) -> Page:
    """저장된 인증 상태로 새로운 페이지를 만든다(로그인 처리를 건너뜀)"""
    context = browser.new_context(storage_state=authenticated_state)
    page = context.new_page()
    yield page
    context.close()
# test_dashboard.py
from pages.dashboard_page import DashboardPage

def test_dashboard_shows_summary(authenticated_page):
    # 로그인 처리 없이 인증된 상태에서 테스트를 시작할 수 있다
    dashboard_page = DashboardPage(authenticated_page)
    authenticated_page.goto("/dashboard")
    assert dashboard_page.is_visible()
⚠️ 주의:session scope를 사용할 때 확인해야 할 것

  • 테스트가 계정 정보나 비밀번호 등 인증 상태에 영향을 주는 데이터를 변경하는 경우, 이후 테스트에 영향이 미칠 수 있습니다
  • 각 테스트가 독립된 데이터(다른 사용자·다른 레코드)를 다루는 설계로 만들어두면 안전합니다
  • 인증 상태를 변경하는 테스트(비밀번호 변경·로그아웃 등)는 authenticated_state를 사용하지 않고 개별로 로그인하는 설계가 더 안전한 경우가 있습니다

④ Component / Page / Flow 3계층으로 책임을 분리하기

「상품을 장바구니에 담고 구매를 완료한다」와 같이 여러 페이지를 넘나드는 업무 플로우를 테스트할 때, 테스트 코드 안에서 여러 Page Object를 직접 호출하면 테스트의 의도를 파악하기 어려워집니다. 이런 경우에는 업무 플로우 자체를 Flow Object로 캡슐화하는 설계가 효과적입니다.

Flow Object는 「업무 플로우의 재사용」이 목적이며, Page Object를 대체하는 것이 아닙니다. 내부적으로는 기존 Page Object를 조합해서 사용할 뿐이며, Page Object 자체의 역할(하나의 화면에 대한 요소와 조작)은 그대로 유지됩니다.

계층책임예시
Component여러 페이지에 공통되는 UI 부품헤더·모달·페이지네이션
Page한 화면의 요소와 조작장바구니 페이지·배송지 입력 페이지
Flow여러 페이지를 넘나드는 업무 절차구매 절차·회원가입 플로우

Flow Object 구현 예시

# flows/checkout_flow.py
from playwright.sync_api import Page
from pages.cart_page import CartPage
from pages.shipping_page import ShippingPage
from pages.payment_page import PaymentPage

class CheckoutFlow:
    """「구매 절차」라는 여러 페이지에 걸친 업무 플로우를 정리한다"""

    def __init__(self, page: Page):
        self.cart_page = CartPage(page)
        self.shipping_page = ShippingPage(page)
        self.payment_page = PaymentPage(page)

    def complete_purchase(self, address: dict, card: dict) -> None:
        """장바구니 확인부터 결제 완료까지를 하나의 메서드로 실행한다"""
        self.cart_page.proceed_to_checkout()
        self.shipping_page.fill_address(address)
        self.shipping_page.proceed()
        self.payment_page.fill_card(card)
        self.payment_page.submit()
# test_checkout.py
def test_purchase_completes_successfully(checkout_flow):
    # 여러 페이지의 조작이 한 줄로 표현되어 테스트의 의도가 명확해진다
    checkout_flow.complete_purchase(
        address={"postal_code": "150-0001", "city": "서울시"},
        card={"number": "4242424242424242", "cvc": "123"},
    )
    assert checkout_flow.payment_page.is_confirmation_visible()
💡 실무 팁:Flow Object는 「여러 페이지를 넘나드는 조작」이 테스트에서 반복적으로 등장하게 된 시점에 도입하면 효과적입니다. 처음부터 모든 업무 플로우를 Flow화하려고 하면 과도한 설계가 되기 쉬우므로, 실제로 중복이 눈에 보인 뒤에 분리하는 방식을 추천합니다.

응용편에서 자주 발생하는 실수

① session scope의 인증 상태를 테스트가 변경해버린다

authenticated_state를 session scope로 설정한 상태에서, 어떤 테스트가 프로필 정보나 비밀번호를 변경하면 이후 테스트가 예상과 다른 상태에서 시작하게 됩니다. 인증 상태를 변경하는 테스트는 전용 function scope fixture로 분리하세요.

② Component Object를 너무 많이 만들어 관리 비용이 늘어난다

「버튼 하나까지도 Component화한다」처럼 세밀하게 분할하면 오히려 파일 수가 늘어나 추적하기 어려워집니다. 여러 페이지에서 실제로 중복되는 UI만 Component화하는 것이 기본입니다.

③ Flow Object 안에 assert를 작성해버린다

Flow Object는 「조작을 정리하는」 역할에 전념시키고, 판정(assert)은 테스트 코드 쪽에 작성하는 것이 원칙입니다. Flow 안에 assert가 섞이면 무엇을 검증하는 테스트인지 읽기 어려워집니다.

④ storage_state의 유효기간 만료를 알아차리지 못한다

세션 유효기간이 짧은 애플리케이션에서는 저장한 storage_state가 도중에 무효화되어, 원인을 알 수 없는 인증 에러로 테스트가 실패하는 경우가 있습니다. 테스트 대상의 세션 유효기간을 파악한 뒤, 필요에 따라 재로그인 타이밍을 조정하세요.

FAQ

Q. 기본 POM 글을 읽지 않아도 이 글을 이해할 수 있나요?

Page Object Model의 기본 개념(페이지마다 클래스를 만든다)을 알고 있다는 전제로 작성했습니다. POM을 처음 배우는 경우, 먼저 기초 글을 읽으면 수월하게 이해할 수 있습니다.

Q. Component Object와 Page Object의 경계는 어떻게 정해야 하나요?

기준은 「그 UI가 2개 이상의 페이지에 등장하는가」입니다. 하나의 페이지에만 나오는 요소는 해당 Page Object에 직접 작성하고, 여러 페이지에 공통되는 것만 Component Object로 분리하면 과도한 분할을 피할 수 있습니다.

Q. storage_state는 얼마나 자주 다시 만들어야 하나요?

프로젝트의 세션 유효기간에 따라 다릅니다. session scope로 테스트 스위트 전체에서 1회만 만드는 운용이 기본이지만, 유효기간이 짧은 애플리케이션에서는 테스트 실행 시간에 맞춰 재로그인 타이밍을 조정해야 합니다.

Q. Flow Object는 반드시 만들어야 하나요?

필수는 아닙니다. 여러 페이지를 넘나드는 조작이 테스트에서 반복적으로 등장하게 된 뒤에 도입하는 것이 실무적입니다. 단일 페이지의 조작만으로 완결되는 테스트가 중심인 프로젝트에서는 Flow Object 없이 Page Object만으로 충분한 경우도 많습니다.

Q. Component Object와 Flow Object는 둘 다 필요한가요?

세트로 도입할 필요는 없습니다. Component Object는 「공통 UI의 중복」이, Flow Object는 「여러 페이지를 넘나드는 조작의 중복」이 각각 문제가 되었을 때 개별적으로 도입하는 것입니다. 프로젝트에 따라서는 Component Object만 사용하고 Flow Object는 사용하지 않는 조합도 충분히 있을 수 있습니다.

Q. pytest-playwright 플러그인을 사용하지 않고 sync_playwright()를 직접 사용하는 설계에서도 이 글의 내용을 적용할 수 있나요?

적용할 수 있습니다. Fixture화·Component Object·Flow Object의 설계 사상은 pytest-playwright 사용 여부와 관계없이 적용 가능합니다. 다만 page나 browser 같은 내장 Fixture는 pytest-playwright가 제공하는 것이므로, 사용하지 않는 경우 conftest.py에서 직접 Fixture를 준비해야 합니다.

📋 정리

  • pytest Fixture로 Page Object를 자동 주입하면 테스트 코드에서 초기화 보일러플레이트가 사라진다
  • Component Object로 공통 UI를 분리하면 여러 페이지에 흩어진 로케이터의 중복을 막을 수 있다
  • storage_state로 인증된 상태를 재사용하면 로그인 처리 반복으로 인한 실행 시간 증가를 억제할 수 있다
  • Flow Object는 여러 페이지를 넘나드는 업무 절차를 캡슐화해 테스트의 의도를 읽기 쉽게 만든다

기본 POM이 「페이지마다 클래스를 나누는」 설계라면, 이 글에서 소개한 Playwright × pytest fixture를 활용한 응용 테크닉은 「테스트 스위트 전체를 어떻게 구조화할 것인가」라는 한 단계 위의 설계입니다. 모든 것을 한 번에 도입할 필요는 없습니다. 먼저 Fixture화부터 시작해서, 프로젝트 규모에 맞춰 조금씩 도입해보세요.

제목과 URL을 복사했습니다