หมวดหมู่: Testing

Selenium: Mini FrameworkSelenium: 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 ได้ง่ายขึ้น