프레임워크 아키텍처 및 디렉토리 구조
UI 자동화 테스트의 유지보수성과 재사용성을 높이기 위해서는 체계적인 프레임워크 구축이 필수적입니다. 본 가이드에서는 Page Object Model(POM) 디자인 패턴을 적용하여 Selenium과 Unittest 기반의 자동화 테스트 프레임워크를 설계하는 방법을 다룹니다.
프로젝트의 표준 디렉토리 구조는 다음과 같이 구성합니다.
| 디렉토리 / 파일 | 설명 | Python 패키지 여부 |
|---|---|---|
common | 공통 유틸리티 클래스 (설정 파일 읽기, 요소 파싱 등) | 예 |
config | 전역 설정 및 환경 변수 파일 | 예 |
logs | 실행 로그 저장 디렉토리 | 아니오 |
page | Selenium Base Page 클래스 | 예 |
page_element | UI 요소Locator 데이터 (YAML) | 아니오 |
page_object | POM 패턴 기반 페이지 객체 클래스 | 예 |
report | 테스트 결과 보고서 저장 디렉토리 | 아니오 |
TestCase | Unittest 테스트 스위트 | 예 |
utils | 로거, 시간, 이메일 등 보조 도구 | 예 |
run_tests.py | 테스트 실행 진입점 (Entry Point) | 아니오 |
Python 패키지(__init__.py가 필요한 디렉토리)로 지정된 곳은 반드시 빈 __init__.py 파일을 포함해야 합니다.
시간 및 유틸리티 모듈 구현
테스트 실행 시간 측정 및 포맷팅을 위해 utils/time_utils.py 파일을 생성합니다.
import time
import datetime
from functools import wraps
def get_unix_time():
"""현재 유닉스 타임스탬프 반환"""
return time.time()
def get_formatted_date(fmt="%Y%m%d_%H%M%S"):
"""포맷팅된 현재 날짜 문자열 반환"""
return datetime.datetime.now().strftime(fmt)
def pause_execution(seconds=1.0):
"""지정된 시간만큼 실행 일시 정지"""
time.sleep(seconds)
def measure_execution_time(func):
"""함수 실행 시간을 측정하는 데코레이터"""
@wraps(func)
def wrapper(*args, **kwargs):
start = get_unix_time()
result = func(*args, **kwargs)
elapsed = get_unix_time() - start
print(f"[Execution Time] {func.__name__} completed in {elapsed:.3f} seconds.")
return result
return wrapper
전역 설정 및 환경 변수 관리
프로젝트의 경로 및 기본 설정을 관리하는 config/settings.py를 작성합니다.
import os
from selenium.webdriver.common.by import By
from utils.time_utils import get_formatted_date
class ProjectSettings:
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
INI_FILE_PATH = os.path.join(BASE_DIR, 'config', 'env_config.ini')
ELEMENTS_DIR = os.path.join(BASE_DIR, 'page_element')
REPORTS_DIR = os.path.join(BASE_DIR, 'report')
TEST_SUITES_DIR = os.path.join(BASE_DIR, "TestCase")
LOCATOR_STRATEGY = {
'css': By.CSS_SELECTOR,
'xpath': By.XPATH,
'id': By.ID,
'name': By.NAME,
'class': By.CLASS_NAME
}
SMTP_CONFIG = {
'sender': 'test_sender@example.com',
'auth_code': 'YOUR_SMTP_AUTH_CODE',
'host': 'smtp.example.com',
'port': 465
}
RECEIVERS = ['admin@example.com']
@property
def log_file_path(self):
log_dir = os.path.join(self.BASE_DIR, 'logs')
os.makedirs(log_dir, exist_ok=True)
return os.path.join(log_dir, f"test_{get_formatted_date('%Y%m%d')}.log")
@property
def html_report_path(self):
os.makedirs(self.REPORTS_DIR, exist_ok=True)
return os.path.join(self.REPORTS_DIR, f"report_{get_formatted_date()}.html")
@property
def latest_report_content(self):
files = sorted(os.listdir(self.REPORTS_DIR), key=lambda x: os.path.getmtime(os.path.join(self.REPORTS_DIR, x)))
if not files:
return "<p>No reports found.</p>"
with open(os.path.join(self.REPORTS_DIR, files[-1]), 'r', encoding='utf-8') as f:
return f.read()
env = ProjectSettings()
이어서 실제 테스트 대상 URL 등을 저장할 config/env_config.ini 파일을 생성합니다.
[TARGET_ENV]
BASE_URL = https://www.example.com
설정 파일 파서 구현
INI 파일을 읽고 파싱하는 common/config_parser.py 모듈을 작성합니다.
import os
import configparser
from config.settings import env
class IniConfigReader:
def __init__(self):
self.file_path = env.INI_FILE_PATH
if not os.path.exists(self.file_path):
raise FileNotFoundError(f"Configuration file not found at {self.file_path}")
self.parser = configparser.RawConfigParser()
self.parser.read(self.file_path, encoding='utf-8')
def _fetch_value(self, section, key):
return self.parser.get(section, key)
@property
def target_url(self):
return self._fetch_value('TARGET_ENV', 'BASE_URL')
config_data = IniConfigReader()
로깅 시스템 구축
테스트 실행过程中的 디버깅과 추적을 위한 utils/log_manager.py를 설정합니다.
import logging
from config.settings import env
class TestLogger:
def __init__(self, logger_name=None):
self._logger = logging.getLogger(logger_name)
if not self._logger.handlers:
self._logger.setLevel(logging.DEBUG)
file_handler = logging.FileHandler(env.log_file_path, encoding='utf-8')
file_handler.setLevel(logging.INFO)
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
log_format = logging.Formatter('%(asctime)s | %(levelname)-8s | %(name)s:%(lineno)d | %(message)s')
file_handler.setFormatter(log_format)
console_handler.setFormatter(log_format)
self._logger.addHandler(file_handler)
self._logger.addHandler(console_handler)
@property
def instance(self):
return self._logger
POM 패턴과 요소 데이터 관리
Page Object Model(POM)은 UI 변경에 대한 테스트 코드의 영향을 최소화하고 재사용성을 극대화하는 디자인 패턴입니다. 요소Locator는 코드와 분리하여 page_element/search_elements.yaml과 같은 YAML 파일로 관리합니다.
search_input_field: "id==search_query"
search_submit_btn: "css==button.submit-btn"
suggestions_list: "xpath==//ul[@class='suggestions']/li"
YAML 파일을 동적으로 로드하는 common/yaml_element_reader.py를 구현합니다.
import os
import yaml
from config.settings import env
class YamlElementLoader:
def __init__(self, page_name):
self.yaml_file = f"{page_name}.yaml"
self.full_path = os.path.join(env.ELEMENTS_DIR, self.yaml_file)
if not os.path.exists(self.full_path):
raise FileNotFoundError(f"Element file {self.yaml_file} does not exist.")
with open(self.full_path, encoding='utf-8') as f:
self._elements = yaml.safe_load(f)
def __getitem__(self, element_key):
raw_locator = self._elements.get(element_key)
if not raw_locator:
raise KeyError(f"Element '{element_key}' not found in {self.yaml_file}")
strategy, value = raw_locator.split('==')
return strategy.strip(), value.strip()
page_elements = YamlElementLoader('search_elements')
Selenium Base Page 캡슐화
Selenium의 기본 메서드를 래핑하여 명시적 대기(Explicit Wait)와 예외 처리를 적용한 page/base_page.py를 작성합니다.
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import TimeoutException
from config.settings import env
from utils.time_utils import pause_execution
from utils.log_manager import TestLogger
log = TestLogger(__name__).instance
class SeleniumBasePage:
def __init__(self, driver):
self.driver = driver
self.wait = WebDriverWait(self.driver, 15)
def navigate_to(self, url):
self.driver.maximize_window()
try:
self.driver.get(url)
log.info(f"Navigated to {url}")
except TimeoutException:
raise TimeoutException(f"Failed to load {url} within the timeout limit.")
def _locate(self, find_method, locator_tuple):
strategy, value = locator_tuple
by_type = env.LOCATOR_STRATEGY.get(strategy)
if not by_type:
raise ValueError(f"Invalid locator strategy: {strategy}")
return find_method(by_type, value)
def find_single_element(self, locator_tuple):
return self._locate(
lambda by, val: self.wait.until(EC.presence_of_element_located((by, val))),
locator_tuple
)
def find_multiple_elements(self, locator_tuple):
return self._locate(
lambda by, val: self.wait.until(EC.presence_of_all_elements_located((by, val))),
locator_tuple
)
def enter_text(self, locator_tuple, text):
pause_execution(0.5)
element = self.find_single_element(locator_tuple)
element.clear()
element.send_keys(text)
log.info(f"Entered text: '{text}'")
def perform_click(self, locator_tuple):
self.find_single_element(locator_tuple).click()
pause_execution()
log.info(f"Clicked element: {locator_tuple}")
def extract_text(self, locator_tuple):
text = self.find_single_element(locator_tuple).text
log.info(f"Extracted text: '{text}'")
return text
@property
def page_html_source(self):
return self.driver.page_source
페이지 객체(Page Object) 생성
Base Page를 상속받아 특정 페이지의 비즈니스 로직을 캡슐화한 page_object/search_page_object.py를 작성합니다.
from page.base_page import SeleniumBasePage
from common.yaml_element_reader import page_elements
from utils.time_utils import pause_execution
class SearchPageActions(SeleniumBasePage):
def input_search_keyword(self, keyword):
self.enter_text(page_elements['search_input_field'], keyword)
pause_execution()
def submit_search(self):
self.perform_click(page_elements['search_submit_btn'])
@property
def get_search_suggestions(self):
elements = self.find_multiple_elements(page_elements['suggestions_list'])
return [elem.text for elem in elements]
Unittest 테스트 스위트 작성
TestCase/test_search_functionality.py에서 실제 테스트 시나리오를 정의합니다.
import unittest
import re
from selenium import webdriver
from common.config_parser import config_data
from page_object.search_page_object import SearchPageActions
from utils.log_manager import TestLogger
log = TestLogger(__name__).instance
class TestSearchFeature(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.driver = webdriver.Chrome()
cls.search_page = SearchPageActions(cls.driver)
cls.search_page.navigate_to(config_data.target_url)
@classmethod
def tearDownClass(cls):
cls.driver.quit()
def test_01_verify_search_results(self):
"""검색 결과 페이지에 키워드가 포함되는지 확인"""
keyword = "automation"
self.search_page.input_search_keyword(keyword)
self.search_page.submit_search()
source = self.search_page.page_html_source
match = re.search(keyword, source, re.IGNORECASE)
log.info(f"Search result match: {match}")
self.assertIsNotNone(match)
def test_02_verify_suggestions_contain_keyword(self):
"""검색 제안 목록에 키워드가 포함되는지 확인"""
keyword = "selenium"
self.search_page.input_search_keyword(keyword)
suggestions = self.search_page.get_search_suggestions
log.info(f"Fetched suggestions: {suggestions}")
self.assertTrue(all(keyword in s.lower() for s in suggestions))
if __name__ == '__main__':
unittest.main(verbosity=2)
테스트 실행 및 HTML 보고서 생성
프로젝트 루트에 run_tests.py를 생성하여 테스트를 실행하고 HTML 보고서를 생성합니다. 외부 라이브러리인 HTMLTestRunner를 활용합니다.
import unittest
from config.settings import env
from utils.email_notifier import dispatch_test_report
# HTMLTestRunner는 별도 설치가 필요합니다.
from HTMLTestRunner import HTMLTestRunner
def execute_suite():
test_loader = unittest.defaultTestLoader.discover(env.TEST_SUITES_DIR, pattern="test*.py")
try:
with open(env.html_report_path, 'wb') as report_file:
runner = HTMLTestRunner(
stream=report_file,
title="UI Automation Test Report",
description="Execution results of the POM-based test suite.",
verbosity=2
)
result = runner.run(test_loader)
except Exception as e:
print(f"Test execution failed: {e}")
else:
if result.failure_count > 0 or result.error_count > 0:
dispatch_test_report()
if __name__ == "__main__":
execute_suite()
테스트 결과 이메일 알림
테스트 실패 시 생성된 HTML 보고서를 이메일로 발송하는 utils/email_notifier.py 모듈을 구현합니다.
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.header import Header
from config.settings import env
def dispatch_test_report():
smtp_conf = env.SMTP_CONFIG
sender = smtp_conf['sender']
password = smtp_conf['auth_code']
receivers = env.RECEIVERS
message = MIMEMultipart()
message['From'] = Header("Automation Bot", 'utf-8')
message['To'] = Header("QA Team", 'utf-8')
message['Subject'] = Header("Latest UI Test Execution Report", 'utf-8')
html_content = env.latest_report_content
message.attach(MIMEText(html_content, 'html', 'utf-8'))
try:
server = smtplib.SMTP_SSL(smtp_conf['host'], smtp_conf['port'])
server.login(sender, password)
server.sendmail(sender, receivers, message.as_string())
server.quit()
print("Test report email dispatched successfully.")
except smtplib.SMTPException as err:
print(f"Failed to send email: {err}")