Playwright + pytest-bdd์์ POM ์ ์ฉ๊ธฐ
- #qa
- #์๋ํ
- #bdd
- #playwright
- #pom
๋ค์ด๊ฐ๋ฉฐ
์๋
ํ์ธ์, ๋
ธ๋จธ์ค QAํ์ ๋ฐ์จ์ ์
๋๋ค.
์ ํฌ ํ์ Playwright + Python + pytest-bdd ํ๊ฒฝ์์ E2E ํ
์คํธ ์๋ํ๋ฅผ ์งํํ๊ณ ์์ต๋๋ค.
ํ
์คํธ ์ฝ๋๊ฐ ์ ์ ๋์ด๋๋ฉด์ ๋งค๋ฒ UI๊ฐ ์์ ๋ ๋๋ง๋ค ํ
์คํธ๊ฐ ๊นจ์ก๊ณ , ๋ก์ผ์ดํฐ๊ฐ ์ฌ๊ธฐ์ ๊ธฐ ํฉ์ด์ ธ ์์ด์ ์์ ํ๋ ๊ฒ๋ ์ผ์ด์์ต๋๋ค.
์ด ๋ฌธ์ ๋ฅผ ํด๊ฒฐํ๊ธฐ ์ํด POM ํจํด์ ๋์ ํ๊ณ , ์ด ๊ธ์์๋ POM ํจํด์ด ๋ฌด์์ธ์ง, ์ฅ๋จ์ ์ ๋ฌด์์ธ์ง, ๊ทธ๋ฆฌ๊ณ ์ ํฌ ํ์ด ์ด๋ป๊ฒ ์ ํํ๋์ง ๊ณต์ ํ๊ณ ์ ํฉ๋๋ค.
1. Page Object Model(POM)์ด๋?
Page Object Model์ ์น ํ์ด์ง๋ฅผ ๊ฐ์ฒด๋ก ์ถ์ํํ๋ ํจํด์
๋๋ค.
๊ฐ ํ์ด์ง(๋๋ ์ปดํฌ๋ํธ)๋ฅผ ํด๋์ค๋ก ๋ง๋ค๊ณ , ํด๋น ํ์ด์ง์ ์์(๋ก์ผ์ดํฐ)์ ๋์(๋ฉ์๋)์ ํ๊ณณ์ ๋ชจ์ ๊ด๋ฆฌํฉ๋๋ค.

์์น์ ๋ค์๊ณผ ๊ฐ์ต๋๋ค:
- ๋จ์ผ ์ฑ ์: ๊ฐ Page ํด๋์ค๋ ํด๋น ํ์ด์ง์ ๋์๋ง ๋ด๋น
- ๊ด์ฌ์ฌ ๋ถ๋ฆฌ: ๋ก์ผ์ดํฐ์ ์ํธ์์ฉ ๋ก์ง์ Page ํด๋์ค์์ ๊ด๋ฆฌํ์ฌ ํ ์คํธ ์ฝ๋์ ๋ถ๋ฆฌ
- ์ฌ์ฌ์ฉ์ฑ: ๋์ผํ ํ์ด์ง ๋์์ ์ฌ๋ฌ ํ ์คํธ์์ ์ฌ์ฌ์ฉ
2. POM์ ์ฅ๋จ์
์ฅ์
| ํญ๋ชฉ | ์ค๋ช |
|---|---|
| ์ ์ง๋ณด์ ์ฉ์ด | UI ๋ณ๊ฒฝ ์ ํด๋น Page ํด๋์ค๋ง ์์ ํ๋ฉด ๋จ |
| ์ฝ๋ ์ฌ์ฌ์ฉ | ๋์ผํ ํ์ด์ง ๋์์ ์ฌ๋ฌ ํ ์คํธ์์ ์ฌ์ฌ์ฉ ๊ฐ๋ฅ |
| ๊ฐ๋ ์ฑ ํฅ์ | ํ ์คํธ ์ฝ๋์์ ๋ก์ผ์ดํฐ๊ฐ ์ฌ๋ผ์ง๊ณ ์๋๊ฐ ๋ช ํํด์ง |
| ๋๋ฒ๊น ์ฉ์ด | ์คํจ ์ง์ ์ Page ๋จ์๋ก ๋น ๋ฅด๊ฒ ํ์ ๊ฐ๋ฅ |
๋จ์
| ํญ๋ชฉ | ์ค๋ช |
|---|---|
| ์ด๊ธฐ ๊ตฌ์ถ ๋น์ฉ | ํด๋์ค ๊ตฌ์กฐ๋ฅผ ์ค๊ณํ๊ณ ๋ง๋๋ ๋ฐ ์๊ฐ์ด ํ์ |
| ๊ณผ๋ํ ์ถ์ํ ์ํ | ๋จ์ํ ํ ์คํธ์๋ ๋ถํ์ํ ํด๋์ค๋ฅผ ๋ง๋ค ์ ์์ |
POM์ด ์ ํฉํ ๊ฒฝ์ฐ
- ํ ์คํธ ์ผ์ด์ค๊ฐ ๊ณ์ ๋์ด๋๋ ํ๋ก์ ํธ
- ์ฌ๋ฌ ํ ์คํธ์์ ๋์ผํ ํ์ด์ง/์ปดํฌ๋ํธ ์ฌ์ฉ
- UI ๋ณ๊ฒฝ์ด ์ฆ์ ์๋น์ค
- ํ ๋จ์๋ก ํ ์คํธ ์ฝ๋ ์์ฑ
POM์ด ๊ณผํ ๊ฒฝ์ฐ
- ์ผํ์ฑ ํ ์คํธ
- ๋ก์ผ์ดํฐ๊ฐ ์ ์ ๋จ์ํ ํ ์คํธ
- ํ๋กํ ํ์ /PoC ๋จ๊ณ
3. ๊ธฐ์กด ๋ฐฉ์์์ POM์ผ๋ก ์ ํ
3-1. ๊ธฐ์กด ๋ฐฉ์์ ๋ฌธ์ ์
Fromm/web/store/steps/
โโโ login.py # ๋ก๊ทธ์ธ ๊ด๋ จ ์คํ
โโโ ...
@when('Store [๋ก๊ทธ์ธ] ๋ฒํผ ํด๋ฆญ')
def click_login_btn(store_page: Page):
store_page.get_by_text('๋ก๊ทธ์ธ', exact=True).click()
@then('Store [๋ก๊ทธ์ธ] ๋ฒํผ ํ์ฑํ')
def expect_login_btn_enabled(store_page: Page):
expect(store_page.get_by_text('๋ก๊ทธ์ธ', exact=True)).to_be_enabled()
๋ฌธ์ ์ :
- ๋ก์ผ์ดํฐ๊ฐ ์ฌ๋ฌ ํ์ผ์ ํ๋์ฝ๋ฉ๋์ด ์ค๋ณต ๋ฐ์
- UI ๋ณ๊ฒฝ ์ ๋ชจ๋ ํ์ผ์ ์์ ํด์ผ ํจ
3-2. POM ์ ์ฉ ํ ๊ตฌ์กฐ
Fromm/web/
โโโ common/
โ โโโ base.py # ActionBase, ExpectBase๋ฅผ ์กฐํฉํ๋ ๊ธฐ๋ฐ ํด๋์ค
โ โโโ action_base.py # ์ก์
๋ฉ์๋ (click, fill ๋ฑ)
โ โโโ expect_base.py # ๊ฒ์ฆ ๋ฉ์๋ (expect ๋ฑ)
โโโ store/
โโโ pages/
โ โโโ login_page.py
โ โโโ goods_page.py
โ โโโ ...
โโโ steps/
โ โโโ login_steps.py
โ โโโ ...
โโโ components/
โโโ header.py
โโโ menu.py
3-3. Base ํด๋์ค ์ค๊ณ
์ก์
๊ณผ ๊ฒ์ฆ ์ฑ
์์ ๋ถ๋ฆฌํ์ฌ Base ํด๋์ค๋ก ์กฐํฉํ์ต๋๋ค.
๋ชจ๋ Page ํด๋์ค๋ ์ด Base๋ฅผ ์์๋ฐ์ ๊ณตํต ๋ฉ์๋๋ฅผ ์ฌ์ฉํฉ๋๋ค.
class Base(ActionBase, ExpectBase, LogMixin):
def __init__(self, page: Page):
self.page = page
...
์ด๋ ๊ฒ ๋ถ๋ฆฌํ ์ด์ ๋, Page ํด๋์ค์์ Playwright API๋ฅผ ์ง์ ํธ์ถํ์ง ์๊ธฐ ์ํด์์
๋๋ค.
๋ง์ฝ Base ์์ด Page ํด๋์ค๋ง๋ค self.page.locator(selector).click() ๊ฐ์ ์ฝ๋๋ฅผ ์ง์ ์์ฑํ๋ฉด,
Playwright API๊ฐ ๋ณ๊ฒฝ๋ ๋ ๋ชจ๋ Page ํด๋์ค๋ฅผ ์์ ํด์ผ ํฉ๋๋ค.
Base์์ ํ ๋ฒ๋ง ๊ฐ์ธ๋๋ฉด ์์ ํฌ์ธํธ๊ฐ ํ ๊ณณ์ผ๋ก ์ค์ด๋ญ๋๋ค.
ActionBase๋ click, fill ๋ฑ ํ๋ฉด ์กฐ์ ๋ฉ์๋๋ฅผ ์ ๊ณตํฉ๋๋ค.
class ActionBase:
def click(self, target: str | Locator, **kwargs) -> None:
if isinstance(target, Locator):
target.click()
else:
self.page.locator(target, **kwargs).click()
def fill(self, target: str | Locator, value: str, **kwargs) -> None:
if isinstance(target, Locator):
target.fill(value)
else:
self.page.locator(target, **kwargs).fill(value)
๋ํ ์ด๋ ๊ฒ ๋ํํด๋๋ฉด ๊ณตํต ๋์์ ํ ๊ณณ์์ ์ ์ดํ ์ ์์ต๋๋ค.
์๋ฅผ ๋ค์ด, ์ฌ์ฉ์ ์ธํฐ๋์
์ ๋ถ๊ท์นํ๊ฒ ๋ํ๋๋ ํ์
(๊ณต์ง, ์๋ฆผ ๋ฑ)์ด ์๋ค๋ฉด,
ActionBase์ click ๋ฉ์๋์ โํ์
์ด ๋ ์์ผ๋ฉด ๋ซ๊ธฐโ ๋ก์ง์ ์ถ๊ฐํ๋ ๊ฒ๋ง์ผ๋ก ๋ชจ๋ Page ํด๋์ค์ ์ผ๊ด ์ ์ฉํ ์ ์์ต๋๋ค.
ExpectBase๋ expect_visible, expect_enabled ๋ฑ ๊ฒ์ฆ ๋ฉ์๋๋ฅผ ์ ๊ณตํฉ๋๋ค.
class ExpectBase:
def expect_visible(self, selector: str, **kwargs) -> None:
expect(self.page.locator(selector, **kwargs)).to_be_visible()
def expect_enabled(self, selector: str, **kwargs) -> None:
expect(self.page.locator(selector, **kwargs)).to_be_enabled()
์ด๋ ๊ฒ ๋ํํด๋๋ฉด ๊ฒ์ฆ ๋ฐฉ์์ ์ผ๊ด์ฑ๋ ํ๋ณดํ ์ ์์ต๋๋ค.
์๋ฅผ ๋ค์ด, ๋ฒํผ์ด ํ์ฑํ ์ํ์ธ์ง ๊ฒ์ฆํ ๋ ๋๊ตฐ๊ฐ๋ to_be_enabled()๋ฅผ ์ฐ๊ณ , ๋๊ตฐ๊ฐ๋ not_to_be_disabled()๋ฅผ ์ธ ์ ์๋๋ฐ,
expect_enabled๋ก ํต์ผํด๋๋ฉด ์ด๋ฐ ๊ฑฑ์ ์์ด ์ฝ๋๊ฐ ์ผ๊ด์ฑ ์๊ฒ ์์ฑ๋ฉ๋๋ค.
๋๋ถ์ Page ํด๋์ค์์๋ self.click(selector), self.expect_visible(selector) ์ฒ๋ผ ๊ฐ๊ฒฐํ๊ฒ ํธ์ถํ ์ ์๊ณ ,
Playwright์ ์ธ๋ถ ๊ตฌํ์ ์ ํ์๊ฐ ์์ต๋๋ค.
3-4. Page ํด๋์ค ์์ฑ
class LoginPage(Base):
@property
def LOGIN_BTN(self):
return self.page.get_by_text('๋ก๊ทธ์ธ', exact=True)
def click_login_btn(self) -> None:
self.click(self.LOGIN_BTN)
def expect_login_btn_enabled(self) -> None:
self.expect_enabled(self.LOGIN_BTN)
3-5. Step ์ ์
Step ํจ์๋ โ๋ฌด์์ ํ๋์งโ๋ง ํํํ๊ณ , โ์ด๋ป๊ฒ ํ๋์งโ๋ Page ํด๋์ค๊ฐ ๋ด๋นํฉ๋๋ค.
@when('Store [๋ก๊ทธ์ธ] ๋ฒํผ ํด๋ฆญ')
def click_login_btn(store_page: Page):
LoginPage(store_page).click_login_btn()
@then('Store [๋ก๊ทธ์ธ] ๋ฒํผ ํ์ฑํ')
def expect_login_btn_enabled(store_page: Page):
LoginPage(store_page).expect_login_btn_enabled()
4. pytest-bdd ํ๊ฒฝ์์์ POM ์ ์ฉ ํฌ์ธํธ
์ผ๋ฐ์ ์ธ POM ์์ ๋ ํ๋์ ํ
์คํธ ํจ์ ์์์ Page ๊ฐ์ฒด๋ฅผ ์์ฑํ๊ณ ์ฌ์ฉํฉ๋๋ค.
ํ์ง๋ง pytest-bdd์์๋ Step ํจ์๊ฐ ๊ฐ๊ฐ ๋ถ๋ฆฌ๋์ด ์๊ธฐ ๋๋ฌธ์, POM์ ์ ์ฉํ ๋ ์ถ๊ฐ์ ์ธ ๊ณ ๋ ค๊ฐ ํ์ํฉ๋๋ค.
4-1. Feature โ Step โ Page Object ๊ตฌ์กฐ
pytest-bdd๋ฅผ ์ฌ์ฉํ๋ฉด ์ผ๋ฐ์ ์ธ POM๋ณด๋ค ๋ ์ด์ด๊ฐ ํ๋ ๋ ์๊น๋๋ค.

์ฌ๊ธฐ์ Step๊ณผ Page์ ์ญํ ๊ฒฝ๊ณ๋ฅผ ๋ช ํํ ํ๋ ๊ฒ์ด ์ค์ํฉ๋๋ค.
- Page: ํ๋ฉด ์กฐ์, ๋ก์ผ์ดํฐ ๊ด๋ฆฌ, ๊ฐ ์์ฑ ๋ฐ return
- Step: Page ํธ์ถ, ๊ฒฐ๊ณผ๋ฅผ ๊ณต์ fixture์ ์ ์ฅ, ํ ์คํธ ํ๊ฒฝ ์ ๋ณด(ํ์ผ ๊ฒฝ๋ก ๋ฑ) ์ ๋ฌ
4-2. Page ๊ฐ์ฒด๋ฅผ fixture๋ก ์ฃผ์ ๋ฐ๋ ๊ตฌ์กฐ
pytest-bdd์ Step ํจ์๋ pytest fixture๋ฅผ ํตํด ๋ธ๋ผ์ฐ์ Page๋ฅผ ์ฃผ์
๋ฐ์ต๋๋ค.
Step์์ ์ง์ Page๋ฅผ ์์ฑํ๋ ๊ฒ์ด ์๋๋ผ, conftest.py์์ ์ค์ ํ fixture๋ฅผ ๋ฐ์์ Page Object์ ๋๊ธฐ๋ ๊ตฌ์กฐ์
๋๋ค.
# conftest.py
@pytest.fixture
def store_page(browser) -> Page:
context = browser.new_context()
page = context.new_page()
page.goto('https://example.com/store')
return page
# step ํจ์์์ fixture๋ฅผ ๋ฐ์ Page Object์ ์ ๋ฌ
@when('Store [๋ก๊ทธ์ธ] ๋ฒํผ ํด๋ฆญ')
def click_login_btn(store_page: Page):
LoginPage(store_page).click_login_btn()
4-3. Step ๊ฐ ๋ฐ์ดํฐ ๊ณต์
pytest-bdd์์ ๊ฐ์ฅ ํฐ ์ฐจ์ด์ ์ Step ํจ์๊ฐ ๊ฐ๊ฐ ๋
๋ฆฝ์ ์ด๋ผ๋ ๊ฒ์
๋๋ค.
์ผ๋ฐ pytest์์๋ ํ๋์ ํ
์คํธ ํจ์ ์์์ ๋ณ์๋ฅผ ์์ ๋กญ๊ฒ ๊ณต์ ํ ์ ์์ง๋ง,
pytest-bdd์์๋ Step A์์ ๋ง๋ ๊ฐ์ Step B์์ ์ฌ์ฉํ๋ ค๋ฉด ๊ณต์ fixture๊ฐ ํ์ํฉ๋๋ค.
# conftest.py - ๊ฐ์ ๊ณต์ ํ ์ ์ฅ์ fixture
@pytest.fixture
def shared_values():
random_text = uuid4().hex[:6]
return {'random_text': random_text}
# Step A: Page๊ฐ ๋ง๋ ๊ฐ์ ๊ณต์ ์ ์ฅ์์ ์ ์ฅ
@when('Store ํ์๊ฐ์
์ ๋๋ค์ ์
๋ ฅ')
def input_nickname(store_page: Page, shared_values):
shared_values['nickname'] = SignupPage(store_page).input_nickname(
shared_values['random_text']
)
# Step B: ์ ์ฅ์์์ ๊บผ๋ด์ ์ฌ์ฉ
@then('Store ๋ง์ดํ์ด์ง์์ ๋๋ค์ ํ์ธ')
def check_nickname(store_page: Page, shared_values):
nickname = shared_values['nickname']
MyPage(store_page).expect_nickname(nickname)
์ด ํจํด์์ Page ํด๋์ค๋ ๊ฐ์ ์์ฑํ๊ณ returnํ๋ ์ญํ ๋ง ํ๊ณ , Step์ด ๊ทธ ๊ฐ์ ๊ณต์ ์ ์ฅ์์ ์ ์ฅํ๋ ์ญํ ์ ๋ด๋นํฉ๋๋ค.
์ด๋ ๊ฒ ํ๋ฉด Page ํด๋์ค๋ pytest-bdd์ fixture ๊ตฌ์กฐ๋ฅผ ์ ํ์ ์์ด ์์ํ๊ฒ ํ๋ฉด ์กฐ์์๋ง ์ง์คํ ์ ์์ต๋๋ค.
๋ง๋ฌด๋ฆฌํ๋ฉฐ
POM ํจํด์ ๋์ ํ ํ ๋ค์๊ณผ ๊ฐ์ ํจ๊ณผ๋ฅผ ์ฒด๊ฐํ ์ ์์์ต๋๋ค.
- ๋ก์ผ์ดํฐ ๋ณ๊ฒฝ ์ ์ํฅ ๋ฒ์๋ฅผ ๋ช ํํ ํ์ ๊ฐ๋ฅ
- ๊ณตํต ๋ฉ์๋์ ์ฌ์๋ ๋ก์ง, ๋๊ธฐ ๋ก์ง ๋ฑ์ ์ผ๊ด ์ ์ฉ ๊ฐ๋ฅ
- ํ ์คํธ ์ฝ๋์ ํ์ด์ง ์ํธ์์ฉ ๋ก์ง์ด ๋ถ๋ฆฌ๋์ด ๊ด์ฌ์ฌ๊ฐ ๋ช ํํด์ง
๋ฌผ๋ก ์ ํ ๊ณผ์ ์ด ์ํํ์ง๋ง์ ์์์ต๋๋ค.
์ฒ์์๋ ๊ธฐ์กด Step ํ์ผ์ ๋ก์ผ์ดํฐ์ ๋ก์ง์ด ๋ค์์ฌ ์์ด์, ์ด๋๊น์ง๋ฅผ Page ํด๋์ค๋ก ์ฎ๊ธฐ๊ณ ์ด๋๊น์ง๋ฅผ Step์ ๋จ๊ธธ์ง ๊ฒฝ๊ณ๋ฅผ ์ก๋ ๊ฒ ์ด๋ ค์ ์ต๋๋ค.
๋ํ ๊ธฐ์กด ํ
์คํธ๋ฅผ ์ ์งํ๋ฉด์ ์ ์ง์ ์ผ๋ก ์ ํํด์ผ ํ๊ธฐ ๋๋ฌธ์,
์ ํฌ ํ์ ํ ํ์ด์ง๋ถํฐ ์ ํํ๊ณ , ํจํด์ด ์ต์ํด์ง๋ฉด ๋ค์ ํ์ด์ง๋ก ๋์ด๊ฐ๋ ๋ฐฉ์์ ํํ๊ณ , ์ด ๋ฐฉ์์ด ํจ์ฌ ํจ๊ณผ์ ์ด์์ต๋๋ค.
๊ธด ๊ธ ์ฝ์ด์ฃผ์ ์ ๊ฐ์ฌํฉ๋๋ค. ๋น์ทํ ๊ณ ๋ฏผ์ ํ๊ณ ๊ณ์ ๋ถ๋ค๊ป ์กฐ๊ธ์ด๋๋ง ๋์์ด ๋์์ผ๋ฉด ํฉ๋๋ค.