Tag Archive Framework

Byphunsanit

Selenium: Mini Framework

ถ้าเขียน selenium จะเห็นว่ามันจะมีส่วนที่เหมือนเขียน code อื่น ๆ (จริง ๆ มันก็คือ code) นั่นละ ที่จะมีส่วนที่ทำซ้ำ ๆ ในทุก project เลยจะ dry ส่วนซ้ำ ๆ ออกมาเป็น Mini Framework


ส่วนแรกคือ selenium_test.py เป็นส่วนที่เก็บ code ส่วนที่ใช้ซ้ำบ่อย ๆ อย่าง สร้าง folder เก็บ report, screenshot
selenium_test.py

import unittest
import time
import os
import subprocess
import threading
import http.server
import socketserver
import sys
from datetime import datetime
from selenium_base_test import BaseSQLMappingAppTest

# --- Configuration ---
SOURCE_CODE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), 'SourceCode'))
DIST_DIR = os.path.join(SOURCE_CODE_DIR, 'dist')
SERVER_PORT = 8000
SELENIUM_TEST_DIR = os.path.join(os.path.dirname(__file__), "selenium")
MAIN_SCREENSHOT_DIR = os.path.join(os.path.dirname(__file__), "temp/selenium", datetime.now().strftime("%Y%m%d_%H%M%S"))

# --- Global Variables ---
httpd_server = None

def start_server():
    global httpd_server
    class Handler(http.server.SimpleHTTPRequestHandler):
        def __init__(self, *args, **kwargs):
            super().__init__(*args, directory=DIST_DIR, **kwargs)
    httpd_server = socketserver.TCPServer(("", SERVER_PORT), Handler)
    print(f"\nStarting local server for '{DIST_DIR}' at http://localhost:{SERVER_PORT}")
    httpd_server.serve_forever()

class ScreenshotTestResult(unittest.TextTestResult):
    def __init__(self, stream, descriptions, verbosity, screenshot_dir=None):
        super().__init__(stream, descriptions, verbosity)
        self.screenshot_dir = screenshot_dir

    def addError(self, test, err):
        super().addError(test, err)
        # Set a flag on the test instance to prevent success screenshot
        setattr(test, '_test_has_failed', True)
        driver = getattr(test, 'driver', None)
        
        # This block now runs ALWAYS on error, regardless of --log-all
        if driver and self.screenshot_dir:
            test_method_name = test.id().split('.')[-1]
            print(f"\n--- Test '{test_method_name}' FAILED. Performing error handling. ---")
            
            screenshot_path = os.path.join(self.screenshot_dir, f"{test_method_name}_ERROR.png")
            driver.save_screenshot(screenshot_path)
            print(f"--- Saved error screenshot to: {screenshot_path} ---")

            # FIX: Change browser log filename on error
            browser_log_path = os.path.join(self.screenshot_dir, f"{test_method_name}_browser_ERROR.log")
            try:
                browser_logs = driver.get_log('browser')
                with open(browser_log_path, 'w') as f:
                    f.write(f"==== Browser Console Logs for {test_method_name} ====\n")
                    if browser_logs:
                        for entry in browser_logs: f.write(f"[{entry['level']}] {entry['message']}\n")
                    else:
                        f.write("No browser console logs were found.\n")
                print(f"--- Browser console logs saved to: {browser_log_path} ---")
            except Exception as e:
                with open(browser_log_path, 'w') as f: f.write(f"Could not retrieve browser logs: {e}\n")
                print(f"--- Error retrieving browser logs. See: {browser_log_path} ---")
            
            print(f"--- BROWSER FOR '{test_method_name}' WILL REMAIN OPEN for inspection. ---")
            setattr(test, 'keep_browser_open', True)

class CustomTestRunner(unittest.TextTestRunner):
    def __init__(self, *args, **kwargs):
        self.screenshot_dir = kwargs.pop('screenshot_dir', None)
        super().__init__(*args, **kwargs)

    def _makeResult(self):
        return ScreenshotTestResult(self.stream, self.descriptions, self.verbosity, self.screenshot_dir)

def run_tests():
    server_thread = threading.Thread(target=start_server)
    server_thread.daemon = True
    server_thread.start()
    time.sleep(1)

    try:
        BaseSQLMappingAppTest.SCREENSHOT_DIR = MAIN_SCREENSHOT_DIR
        if '--log-all' in sys.argv:
            BaseSQLMappingAppTest.LOG_ALL = True
            sys.argv.remove('--log-all')
            print("--- Log-all mode enabled. A screenshot will be taken for every test. ---")

        print(f"\n--- Discovering tests in: {SELENIUM_TEST_DIR} ---")
        loader = unittest.TestLoader()
        suite = loader.discover(start_dir=SELENIUM_TEST_DIR, pattern="test_*.py")
        
        log_file_path = os.path.join(MAIN_SCREENSHOT_DIR, 'test.log')
        print(f"--- Starting test run. Full log will be saved to: {log_file_path} ---")
        with open(log_file_path, 'w') as log_file:
            runner = CustomTestRunner(
                stream=log_file, 
                verbosity=2,
                screenshot_dir=MAIN_SCREENSHOT_DIR
            )
            result = runner.run(suite)
        
        print(f"--- Test run complete. Full log saved to: {log_file_path} ---")
        print(f"--- All artifacts are in: {os.path.abspath(MAIN_SCREENSHOT_DIR)} ---")
        
        if not result.wasSuccessful():
            sys.exit(1)

    finally:
        global httpd_server
        if httpd_server:
            print("\n--- Shutting down local server ---")
            httpd_server.shutdown()
            httpd_server.server_close()

if __name__ == "__main__":
    os.makedirs(MAIN_SCREENSHOT_DIR, exist_ok=True)
    run_tests()

ส่วนที่เก็บ config ที่ใช้เฉพาะ project อย่าง run server, login
selenium_base_test.py

import unittest
import time
import os
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait, Select
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.common.action_chains import ActionChains
from selenium.common.exceptions import TimeoutException

# --- Configuration (can be accessed by all tests) ---
APP_URL = "http://localhost:8000"

class BaseSQLMappingAppTest(unittest.TestCase):
    """
    This is the base class for all test cases.
    It contains the common setup, teardown, and helper methods.
    """
    LOG_ALL = False
    SCREENSHOT_DIR = "" # This will be set by the test runner
    _test_has_failed = False # Internal flag to track test failure

    def setUp(self):
        self._test_has_failed = False
        self.keep_browser_open = False
        chrome_options = Options()
        chrome_options.set_capability('goog:loggingPrefs', {'browser': 'ALL'})
        self.driver = webdriver.Chrome(options=chrome_options)
        self.driver.get(APP_URL)
        # FIX: Increased timeout to 30 seconds
        self.wait = WebDriverWait(self.driver, 30)

    def tearDown(self):
        # FIX: This is the new, correct logic for --log-all.
        # If log-all is enabled AND the test has NOT failed, take a success screenshot.
        # This block will be SKIPPED if the test failed, preserving the _ERROR.png file.
        if self.LOG_ALL and not self._test_has_failed:
            test_method_name = self.id().split('.')[-1]
            screenshot_path = os.path.join(self.SCREENSHOT_DIR, f"{test_method_name}_SUCCESS.png")
            self.driver.save_screenshot(screenshot_path)
            print(f"--- Saved success screenshot for '{test_method_name}' to: {screenshot_path} ---")

        if not self.keep_browser_open:
            self.driver.quit()

    def wait_for_editor(self, editor_name):
        try:
            container_id = "SQLSourceIDE" if editor_name == "sqlSourceEditor" else "SQLOutputIDE"
            self.wait.until(EC.presence_of_element_located((By.ID, container_id)))
            self.wait.until(
                lambda driver: driver.execute_script(
                    f"return window.monacoEditors && window.monacoEditors.{editor_name}"
                )
            )
        except TimeoutException:
            print(f"Timeout waiting for Monaco Editor instance '{editor_name}' to be created.")
            raise

    def get_monaco_editor_text(self, editor_name):
        self.wait_for_editor(editor_name)
        js_script = f"return window.monacoEditors.{editor_name}.getValue();"
        return self.driver.execute_script(js_script)

    def set_monaco_editor_text(self, editor_name, text):
        self.wait_for_editor(editor_name)
        escaped_text = text.replace('\\', '\\\\').replace("'", "\\'").replace('"', '\\"').replace('\n', '\\n').replace('`', '\\`')
        js_script = f"window.monacoEditors.{editor_name}.setValue(`{escaped_text}`);"
        self.driver.execute_script(js_script)

ส่วนสุดท้าย แต่ไม่ท้ายสุดคือ Test Case แยกออกมาเป็นไฟล์เล็ก ๆ ให้สามารถ copy ไปวาง reused ได้ง่าย ๆ จะเก็บใน /Selenium หลาย ๆ ไฟล์ เช่น
test_01_initial_load

from selenium_base_test import BaseSQLMappingAppTest

class TestInitialLoad(BaseSQLMappingAppTest):
    def test_01_initial_load_and_default_sql(self):
        print("\nRunning test_01_initial_load_and_default_sql...")
        editor_text = self.get_monaco_editor_text("sqlSourceEditor")
        self.assertIn("INSERT INTO Customers", editor_text)
        print("OK")

เพราะการแยกเป็นส่วน ๆ เหมือนการทำ modular ทำให้ reused ได้ง่ายขึ้น