Showing Posts From
기능이
- 14 Dec, 2025
새 기능이 나올 때마다 테스트 스크립트를 처음부터 짜야 할까
새 기능 나올 때마다 테스트 스크립트 처음부터 짜는 거 아니다 처음엔 나도 매번 새로 짰다 입사 첫 해. 새 기능 나오면 테스트 스크립트를 처음부터 작성했다. 로그인 테스트 짜면서 로그인 함수 또 만들고. 검색 기능 테스트하면서 검색 함수 또 만들고. 같은 코드를 계속 복붙했다. 어느 날 PM이 물었다. "새 기능 테스트 자동화 언제 돼요?" 2주 걸린다고 했더니 놀랐다. "자동화인데 왜 그렇게 오래 걸려요?" 그때 깨달았다. 내 자동화는 자동화가 아니었다. 테스트 케이스만 자동으로 돌아갈 뿐, 스크립트 작성은 수동이었다. 퇴근길에 생각했다. 개발자들은 라이브러리 쓰잖아. 매번 HTTP 요청 코드 새로 안 짜잖아. 나도 그렇게 해야 하는 거다.공통 함수부터 만들었다 다음 날부터 공통 함수를 모으기 시작했다. # common/auth.py def login(driver, username, password): driver.find_element(By.ID, "username").send_keys(username) driver.find_element(By.ID, "password").send_keys(password) driver.find_element(By.ID, "login-btn").click() wait_for_dashboard(driver)간단했다. 로그인 기능을 함수 하나로. 이제 테스트마다 4줄이 1줄로 줄었다. login(driver, "test@test.com", "password123") 검색도 마찬가지였다. # common/search.py def search_product(driver, keyword): search_box = driver.find_element(By.ID, "search") search_box.clear() search_box.send_keys(keyword) search_box.send_keys(Keys.RETURN) wait_for_results(driver)처음엔 이것만으로도 신기했다. 테스트 작성 시간이 반으로 줄었다. 하지만 문제가 생겼다. ID가 "username"에서 "user-email"로 바뀌었다. 로그인 쓰는 테스트가 100개. 함수 하나만 고치면 끝났다. 그때 알았다. 재사용은 유지보수를 위한 거다.Page Object Model을 배웠다 선배가 코드 리뷰하면서 말했다. "POM 써봤어?" Page Object Model. 페이지를 클래스로 만드는 패턴이었다. # pages/login_page.py class LoginPage: def __init__(self, driver): self.driver = driver self.username_field = (By.ID, "username") self.password_field = (By.ID, "password") self.login_button = (By.ID, "login-btn") def login(self, username, password): self.driver.find_element(*self.username_field).send_keys(username) self.driver.find_element(*self.password_field).send_keys(password) self.driver.find_element(*self.login_button).click() return DashboardPage(self.driver)처음엔 복잡해 보였다. 함수 하나로 충분한데 왜 클래스로 만들어. 하지만 써보니 달랐다. # tests/test_login.py def test_successful_login(): login_page = LoginPage(driver) dashboard = login_page.login("test@test.com", "password123") assert dashboard.is_loaded()읽기 쉬웠다. 테스트가 뭘 하는지 한눈에 보였다. 로그인하고, 대시보드 뜨는지 확인하고. 더 중요한 건 확장성이었다. 로그인 페이지에 소셜 로그인 버튼이 추가됐다. def login_with_google(self): self.driver.find_element(*self.google_button).click() # OAuth 처리 return DashboardPage(self.driver)LoginPage 클래스에 메서드만 추가하면 됐다. 기존 테스트는 안 건드렸다. 컴포넌트를 더 작게 쪼갰다 POM도 중복이 있었다. 모든 페이지에 공통 헤더가 있었다. 로고, 검색창, 프로필 메뉴. 매 페이지마다 헤더 요소를 정의하는 건 비효율적이었다. # components/header.py class Header: def __init__(self, driver): self.driver = driver self.search_box = (By.ID, "global-search") self.profile_menu = (By.ID, "profile-menu") def search(self, keyword): element = self.driver.find_element(*self.search_box) element.send_keys(keyword) element.send_keys(Keys.RETURN) def open_profile(self): self.driver.find_element(*self.profile_menu).click() return ProfilePage(self.driver)페이지 클래스는 컴포넌트를 포함했다. # pages/dashboard_page.py class DashboardPage: def __init__(self, driver): self.driver = driver self.header = Header(driver) self.sidebar = Sidebar(driver) def search_from_dashboard(self, keyword): self.header.search(keyword) return SearchResultsPage(self.driver)이제 헤더가 바뀌어도 Header 클래스만 수정하면 됐다. 10개 페이지를 안 건드렸다. 컴포넌트 단위로 생각하니 UI도 다르게 보였다. 모달, 툴팁, 드롭다운. 전부 재사용 가능한 컴포넌트였다. # components/modal.py class Modal: def __init__(self, driver): self.driver = driver self.close_button = (By.CLASS_NAME, "modal-close") self.confirm_button = (By.CLASS_NAME, "modal-confirm") def close(self): self.driver.find_element(*self.close_button).click() def confirm(self): self.driver.find_element(*self.confirm_button).click()어떤 모달이든 이 클래스로 처리했다. 삭제 확인 모달, 저장 확인 모달, 에러 모달.공통 액션을 모듈화했다 테스트를 짜다 보면 패턴이 보였다. 기다리고, 클릭하고, 확인하고. 특히 기다리는 코드가 많았다. 로딩 끝날 때까지, 버튼 활성화될 때까지, 데이터 나올 때까지. # utils/waits.py class CustomWait: def __init__(self, driver, timeout=10): self.driver = driver self.wait = WebDriverWait(driver, timeout) def until_visible(self, locator): return self.wait.until(EC.visibility_of_element_located(locator)) def until_clickable(self, locator): return self.wait.until(EC.element_to_be_clickable(locator)) def until_text_present(self, locator, text): return self.wait.until(EC.text_to_be_present_in_element(locator, text)) def until_loading_done(self): self.wait.until(EC.invisibility_of_element_located((By.CLASS_NAME, "loading-spinner")))이제 테스트마다 WebDriverWait 안 써도 됐다. waiter = CustomWait(driver) waiter.until_loading_done() element = waiter.until_clickable(login_button)스크롤도 자주 썼다. 무한 스크롤, 특정 요소까지 스크롤, 맨 위로 스크롤. # utils/actions.py class Actions: def __init__(self, driver): self.driver = driver def scroll_to_element(self, element): self.driver.execute_script("arguments[0].scrollIntoView(true);", element) def scroll_to_bottom(self): self.driver.execute_script("window.scrollTo(0, document.body.scrollHeight);") def infinite_scroll_until(self, target_count, item_locator): while True: items = self.driver.find_elements(*item_locator) if len(items) >= target_count: break self.scroll_to_bottom() time.sleep(1)테스트 코드가 깔끔해졌다. 의도가 명확했다. actions = Actions(driver) actions.infinite_scroll_until(50, (By.CLASS_NAME, "product-item"))"50개 아이템 나올 때까지 스크롤한다." 코드가 주석이 됐다. 데이터 생성도 라이브러리화했다 테스트마다 데이터가 필요했다. 유저, 상품, 주문. 처음엔 테스트 파일마다 데이터를 만들었다. test_user = { "email": "test@test.com", "password": "password123", "name": "테스터" }100개 테스트에 100개 유저가 있었다. 유저 스키마가 바뀌면 100군데를 고쳐야 했다. 팩토리 패턴을 적용했다. # fixtures/user_factory.py class UserFactory: @staticmethod def create_user(role="user", **kwargs): default_user = { "email": f"test_{uuid.uuid4()}@test.com", "password": "password123", "name": "테스트 유저", "role": role } default_user.update(kwargs) return default_user @staticmethod def create_admin(**kwargs): return UserFactory.create_user(role="admin", **kwargs)테스트에선 간단히 호출했다. user = UserFactory.create_user() admin = UserFactory.create_admin(name="관리자")이메일은 자동으로 유니크했다. 역할별로 쉽게 만들었다. 필요한 필드만 오버라이드했다. 상품도 마찬가지였다. # fixtures/product_factory.py class ProductFactory: @staticmethod def create_product(category="electronics", **kwargs): default_product = { "name": f"테스트 상품 {uuid.uuid4().hex[:8]}", "price": 10000, "stock": 100, "category": category, "description": "테스트용 상품입니다" } default_product.update(kwargs) return default_product재고 없는 상품, 할인 중인 상품, 카테고리별 상품. 필요한 데이터를 즉시 만들었다. out_of_stock = ProductFactory.create_product(stock=0) on_sale = ProductFactory.create_product(price=5000, original_price=10000)API 호출도 재사용했다 E2E 테스트 전에 데이터 세팅이 필요했다. UI로 만들면 너무 오래 걸렸다. API로 직접 데이터를 만들었다. # api/user_api.py class UserAPI: def __init__(self, base_url, token=None): self.base_url = base_url self.token = token def create_user(self, user_data): response = requests.post( f"{self.base_url}/api/users", json=user_data, headers={"Authorization": f"Bearer {self.token}"} ) return response.json() def delete_user(self, user_id): requests.delete( f"{self.base_url}/api/users/{user_id}", headers={"Authorization": f"Bearer {self.token}"} )테스트 전에 유저를 API로 만들고, 테스트 후에 삭제했다. @pytest.fixture def test_user(): user_api = UserAPI(BASE_URL, ADMIN_TOKEN) user_data = UserFactory.create_user() created_user = user_api.create_user(user_data) yield created_user user_api.delete_user(created_user["id"])테스트 시간이 70% 줄었다. UI로 회원가입하면 30초, API로 만들면 1초. 주문도 API로 만들었다. 장바구니 담고, 결제하고, 주문 완료까지. UI로 하면 2분, API로 하면 5초. 설정도 중앙화했다 환경별로 URL이 달랐다. 개발, 스테이징, 운영. 처음엔 테스트마다 URL을 하드코딩했다. 환경 바뀌면 전부 수정해야 했다. 설정 파일을 만들었다. # config/config.py class Config: ENVIRONMENTS = { "dev": { "base_url": "https://dev.example.com", "api_url": "https://api-dev.example.com", "db_host": "dev-db.example.com" }, "staging": { "base_url": "https://staging.example.com", "api_url": "https://api-staging.example.com", "db_host": "staging-db.example.com" }, "prod": { "base_url": "https://example.com", "api_url": "https://api.example.com", "db_host": "prod-db.example.com" } } @classmethod def get_config(cls, env="dev"): return cls.ENVIRONMENTS.get(env)테스트 실행할 때 환경만 지정했다. pytest --env=stagingconftest.py에서 설정을 로드했다. # conftest.py def pytest_addoption(parser): parser.addoption("--env", action="store", default="dev")@pytest.fixture(scope="session") def config(request): env = request.config.getoption("--env") return Config.get_config(env)@pytest.fixture def driver(config): driver = webdriver.Chrome() driver.get(config["base_url"]) yield driver driver.quit()이제 모든 테스트가 설정을 참조했다. 환경 바뀌어도 코드는 안 바뀌었다. 커스텀 어서션을 만들었다 Pytest 기본 assert도 좋지만, 반복되는 체크가 있었다. 요소가 화면에 보이는지, 특정 텍스트를 포함하는지, 특정 상태인지. # assertions/custom_assertions.py class CustomAssertions: def __init__(self, driver): self.driver = driver def assert_element_visible(self, locator, message=""): element = self.driver.find_element(*locator) assert element.is_displayed(), f"Element {locator} not visible. {message}" def assert_text_contains(self, locator, expected_text): element = self.driver.find_element(*locator) actual_text = element.text assert expected_text in actual_text, \ f"Expected '{expected_text}' in '{actual_text}'" def assert_element_count(self, locator, expected_count): elements = self.driver.find_elements(*locator) actual_count = len(elements) assert actual_count == expected_count, \ f"Expected {expected_count} elements, found {actual_count}"테스트가 읽기 쉬워졌다. assertions = CustomAssertions(driver) assertions.assert_element_visible((By.ID, "welcome-message")) assertions.assert_text_contains((By.CLASS_NAME, "username"), "테스터") assertions.assert_element_count((By.CLASS_NAME, "product-item"), 20)에러 메시지도 명확했다. 어떤 요소가, 뭘 기대했고, 실제론 뭐였는지. 베이스 테스트 클래스를 만들었다 모든 테스트가 공통으로 하는 일이 있었다. 드라이버 시작, 로그인, 종료. 베이스 클래스로 추상화했다. # tests/base_test.py class BaseTest: @pytest.fixture(autouse=True) def setup(self, driver, config): self.driver = driver self.config = config self.waiter = CustomWait(driver) self.actions = Actions(driver) self.assertions = CustomAssertions(driver) # 공통 페이지 객체 self.login_page = LoginPage(driver) yield # 스크린샷 저장 (실패 시) if hasattr(self, '_test_failed') and self._test_failed: self.save_screenshot() def save_screenshot(self): timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") self.driver.save_screenshot(f"screenshots/failed_{timestamp}.png") def login_as_user(self, user=None): if not user: user = UserFactory.create_user() self.login_page.login(user["email"], user["password"]) return DashboardPage(self.driver)테스트 클래스는 BaseTest를 상속했다. class TestDashboard(BaseTest): def test_dashboard_loads(self): self.login_as_user() self.assertions.assert_element_visible((By.ID, "dashboard")) def test_search_from_dashboard(self): dashboard = self.login_as_user() dashboard.header.search("테스트") self.waiter.until_loading_done() self.assertions.assert_element_count((By.CLASS_NAME, "result-item"), 10)setup, teardown, 공통 헬퍼가 자동으로 제공됐다. 테스트는 비즈니스 로직에만 집중했다. 라이브러리 구조를 정리했다 코드가 많아지니 구조가 중요해졌다. 어디에 뭐가 있는지 한눈에 봐야 했다. automation/ ├── pages/ │ ├── login_page.py │ ├── dashboard_page.py │ └── product_page.py ├── components/ │ ├── header.py │ ├── sidebar.py │ └── modal.py ├── api/ │ ├── user_api.py │ ├── product_api.py │ └── order_api.py ├── fixtures/ │ ├── user_factory.py │ └── product_factory.py ├── utils/ │ ├── waits.py │ ├── actions.py │ └── db_helper.py ├── assertions/ │ └── custom_assertions.py ├── config/ │ └── config.py └── tests/ ├── base_test.py ├── test_login.py └── test_dashboard.py폴더별로 역할이 명확했다. 새 기능 테스트 짤 때 어디서 가져올지 바로 알았다. 로그인 기능? pages/login_page.py. 헤더 검색? components/header.py. 유저 생성? api/user_api.py. import도 일관적이었다. from pages.login_page import LoginPage from components.header import Header from api.user_api import UserAPI from fixtures.user_factory import UserFactory from utils.waits import CustomWait문서화도 라이브러리의 일부다 코드만 있으면 안 됐다. 후배가 어떻게 쓰는지 알아야 했다. 각 모듈에 docstring을 추가했다. class LoginPage: """ 로그인 페이지 객체 사용법: login_page = LoginPage(driver) dashboard = login_page.login("test@test.com", "password123") 메서드: - login(email, password): 로그인 수행, DashboardPage 반환 - login_with_google(): 구글 로그인, DashboardPage 반환 - forgot_password(email): 비밀번호 재설정 메일 전송 """README.md도 작성했다. # 테스트 자동화 라이브러리## 빠른 시작1. 로그인 테스트 ```python from pages.login_page import LoginPagedef test_login(driver): login_page = LoginPage(driver) dashboard = login_page.login("test@test.com", "password123") assert dashboard.is_loaded()데이터 생성from fixtures.user_factory import UserFactory from api.user_api import UserAPIuser_data = UserFactory.create_user() user_api = UserAPI(BASE_URL, TOKEN) created_user = user_api.create_user(user_data)예제가 있으니 후배가 금방 따라했다. 질문도 줄었다.## 버전 관리도 했다라이브러리가 바뀌면 알려줘야 했다. 어떤 기능이 추가됐고, 뭐가 deprecated 됐는지.CHANGELOG.md를 만들었다.```markdown # Changelog## [2.1.0] - 2024-01-15 ### Added - Modal 컴포넌트에 `wait_until_closed()` 메서드 추가 - ProductFactory에 `create_bundle()` 메서드 추가### Changed - CustomWait의 기본 timeout을 10초에서 15초로 변경### Deprecated - `login_page.login_old()` 메서드는 v3.0.0에서 제거 예정## [2.0.0] - 2023-12-01 ### Breaking Changes - LoginPage의 `login()` 메서드가 DashboardPage 대신 HomePage 반환팀원들이 업데이트 전에 체크했다. Breaking change가 있으면 테스트 코드를 미리 수정했다. 실제로 얼마나 빨라졌나 숫자로 측정했다. 라이브러리 도입 전과 후. 새 기능 테스트 작성 시간전: 평균 2주 (10개 시나리오 기준) 후: 평균 3일테스트 유지보수 시간전: UI 변경 시 평균 5일 (영향받는 테스트 수정) 후: 평균 1일 (페이지 객체만 수정)코드 재사용률전: 30% (복붙이 대부분) 후: 80% (라이브러리 사용)테스트 코드 길이전: 평균 100줄 후: 평균 30줄수치가 명확하니 설득력이 있었다. PM도 인정했다. "자동화 투자가 빛을 보네요." 팀원들도 쓰기 시작했다 처음엔 나만 썼다. 후배는 여전히 매번 새로 짰다. 어느 날 후배가 물었다. "선배, 로그인 함수 어디 있어요?" "pages 폴더에 LoginPage 있어." "아 이거... 이거 쓰면 되는구나." 다음 날 후배 코드를 봤다. LoginPage, UserFactory, CustomWait를 썼다. 코드가 내 스타일과 비슷했다. 일주일 후 후배가 말했다. "선배, 저도 컴포넌트 하나 만들어도 돼요? Notification 자주 써서요." "당연하지. PR 올려봐." # components/notification.py class Notification: def __init__(self, driver): self.driver = driver self.toast = (By.CLASS_NAME, "toast-message") def get_message(self): element = self.driver.find_element(*self.toast) return element.text def wait_until_disappear(self): wait = WebDriverWait(self.driver, 10) wait.until(EC.invisibility_of_element_located(self.toast))좋았다. 머지했다. 이제 팀 전체가 Notification 컴포넌트를 쓴다. 라이브러리가 함께 자랐다. 혼자 만들 때보다 빨랐다. 신입이 와도 빨리 적응했다 3개월 전 신입이 들어왔다. QA 경험 1년, 자동화는 처음. "뭐부터 배워야 해요?" README 보여줬다. "여기 예제 따라해봐. 로그인 테스트부터." 2시간 뒤 신입이 로그인 테스트를 짰다. LoginPage 쓰고, UserFactory 쓰고, CustomAssertions 썼다. "이거 다 있네요. 편하다." 1주일 뒤 신입이 검색 기능 테스트를 완성했다. 혼자서. 예전 같았으면 한 달 걸렸을 거다. 라이브러리가 온보딩 시간을 줄였다. 한계도 있었다 라이브러리가 만능은 아니었다. 과도한 추상화 너무 범용적으로 만들