Automatización de pruebas frontend: detectando cambios con Selenium y OpenCV

 

Uno de los aspectos que siempre es un buen tema de debate cuando hablamos de automatización de pruebas de software es la búsqueda de un mecanismo para realizar rápidamente validaciones de la estructura de un front completo. Sabemos que con selenium podemos realizar pruebas funcionales a los diferentes elementos de una aplicación web o sitio cualquiera, pero aún en la versión actual de Selenium (4) no es posible realizar validaciones de estructuras del frontend de forma nativa. 

 

Esta labor suele dejarse a otras aplicaciones o librerías complementarias que puedan suplir con esta actividad de una forma –más o menos– precisa. En este post realizaremos un pequeño tutorial sobre cómo capturar imágenes de en un tiempo X de un front para luego compararlas en pruebas regresivas, para ello utilizaremos OpenCV y Python.

 

¿Qué hace el siguiente código?

Permite básicamente lo siguiente:

  • Tomar una captura de pantalla si no existe una previa; (el tamaño dependerá la resolución en que estemos ejecutando nuestro WebDriver, se sugiere ejecutar el driver con la opción –fullscreen (aunque la prueba como tal redimencionará el navegador de forma automática para el screenshot completo). El screenshot se genera utilizando un método que realiza un scroll vertical con la finalidad de abarcar el alto completo del sitio.

En caso de no existir una captura previa se guardará una nueva, y retornará un True, notificandonos el éxito de la captura. (esto pensado en una primera ejecución para obtener las capturas actualizadas)

  • Si ya existe una captura previa, tomará una nueva y realizará la comparativa. Si no hay cambios notifica un True, si hay cambios notifica un False. y almacena la captura nueva.

  • La comparativa de diferencias se establece en base aun umbral máximo de tolerancia. La idea es notificar un cambio cuando se detecta alguna variación significativa en el front, ej: Aparece un nuevo texto en el front, desaparece o aparece un nuevo botón, algunos componentes se movieron de sitio, etc…

 

Limitaciones:

  • En sitios o fronts (particulares) de sitios que presentan imágenes como banners que cambian continuamente no será de mucha utilidad.

  • Implica realizar una ejecución preliminar sólo para la captura inicial de las imágenes.

 

  1. Debemos instalar open CV en nuestro proyecto

 

pip install opencv-python

 

  1. Crearemos una clase e importaremos lo necesario (si falta alguna libreria adicional a Open CV instalar)

 

import logging

import os

import pathlib

import cv2

import time

 

 

class Snapshot:

 

 

  1. Crearemos nuestro primer método llamado validateSnapshots(context, ruta)

Context: es el objeto que contiene la instancia del driver

Ruta: Será la URI del archivo, ej/google/home  donde /google/ será una carpeta, y “home” el nombre con el que se guardará el archivo de imagen (el método le añade un .png).

Realmente cuando guardamos una imagen nueva se guarda en (siguiendo el ej anterior): /timeMachine/snapshots/google/home.png

y Si se toma una nueva captura, durante la comparación se guardará en: 

/timeMachine/snapshots_temp/google/home.png

 

pueden modificarse las rutas de carpetas previas (/timeMachine/) en el punto 6 de este tutorial.

 

Este método será el responsable de generar el primer screenshot (si no hay uno previo) o tomar uno actualizado y comparar con el anterior (si ya existía uno).

Desde nuestra automatización, deberíamos simplemente llamar a este método.

 

 

def validateSnapshots(context, ruta):

   “””

   Si no existe un snapshot previo crea uno.

   Si existe un snapshot previo, obtiene un nuevo registro y lo compara al anterior

   :param ruta: en formato carpeta(s)/imagen (se usa / independiente del S.O.)

   :return:

   “””

   try:

       ruta = ruta.replace(‘/’, os.sep)

       snapshot_path = os.path.join(Snapshot.getRutaSnaps(), ruta+“.png”)

       temp_snapshot_path = os.path.join(Snapshot.getRutaSnapsTemp(), ruta+“.png”)

 

       if os.path.exists(snapshot_path):

           print(“Existe un archivo original”)

           Snapshot.save_snapshot(context, temp_snapshot_path)

           validator = Snapshot.compare_snapshots(context, snapshot_path, temp_snapshot_path)

           return validator

       else:

           print(“No Existe, se creará un original”)

           Snapshot.save_snapshot(context, snapshot_path)

           return True

   except Exception as ex:

       print(“Se ha producido una excepción en el método validateSnapshots: %s”, ex)

 

 

  1. Generamos nuestro método save_snapshot(context, rute_snap)

Context: es el objeto que contiene la instancia del driver

Ruta: Será la URI del archivo, ej snapshots/google/home  donde

Ambos argumentos vienen directamente del método anterior (validateSnapshots).

 

Este método realizará el guardado el screenshot, invocando previamente a un nuevo método que realizará el scroll correspondiente (realmente lo que hace es obtener el alto y ancho completo mediante el scroll generando un objeto rentangle.)

 

 

def save_snapshot(context, rute_snap):

   “””

   Guarda un snapshot a pantalla completa

   :param rute_snap: Ruta donde se guardará el snapshot.

   :return:

   “””

   try:

       time.sleep(3)

       height, width = Snapshot.scroll_down(context)

       context.browser.set_window_size(width, height)

 

       # Crear el directorio si no existe

       directorio = Snapshot.retornaSoloRuta(rute_snap)

       if not os.path.exists(str(directorio)):

           print(“Se crea el directorio para guardar el screen”)

           os.makedirs(str(directorio))

 

       # Capturar la pantalla y guardar el snapshot

       screenshot_path = rute_snap

       context.browser.save_screenshot(screenshot_path)

       print(“Se guarda la imagen en: “, screenshot_path)

   except Exception as ex:

       logging.info(“Error al guardar el snapshot:”, ex)

 

 

  1. Creamos el metodo central, compare_snapshots(context, original_snap, last_screen_snap)

Context: es el objeto que contiene la instancia del driver

Original_snap: ruta de la imagen original

Last_screen_snap: ruta de la imagen nueva (temporal)

 

La variable umbral_maximo representa la tolerancia a las diferencias, este umbral puede variar dependiente del tamaño y calidad de las imágenes. Recordar que esta comparativa se realiza en los canales RGB de la imagen

 

 

def compare_snapshots(context, original_snap, last_screen_snap):

   “””

   Compara dos imagenes, se debe establecer el umbral_maximo de diferencias (desde donde se considera una diferencia critica)

   :param original_snap: Ruta del snapshot original

   :param last_screen_snap: Ruta del snapshot actual

   :return:

   “””

   try:

       umbral_maximo = 500

       original = cv2.imread(original_snap)

       last_screen = cv2.imread(last_screen_snap)

       “””compara tamaños y capas”””

       if original.shape == last_screen.shape:

           print(‘Las imagenes tiene el mismo tamaño y canal’)

           difference = cv2.subtract(original, last_screen)

           b, g, r = cv2.split(difference)

           # print(“Valor de B “,cv2.countNonZero(b))

 

           if cv2.countNonZero(b) < umbral_maximo or cv2.countNonZero(g) < umbral_maximo or cv2.countNonZero(

                   r) < umbral_maximo:

               os.remove(last_screen_snap) #Si las imagenes son iguales elimina la temporal de inmediato

               return True

           else:

               return False

       else:

           print(‘Las imagenes no se pueden comparar’)

           return False

   except Exception as ex:

       print(“Error al comparar los snapshots:”, ex)

 

 

  1. Creamos el método scroll_down(context) que realizará el scroll retornando un alto y un ancho.

Context: es el objeto que contiene la instancia del driver

 

def scroll_down(context):

   “””

   Toma el tamaño completo de la pantalla incluyendo un posible scroll

   :return:

   “””

   total_width = context.browser.execute_script(“return document.body.offsetWidth”)

   total_height = context.browser.execute_script(“return document.body.parentNode.scrollHeight”)

   viewport_width = context.browser.execute_script(“return document.body.clientWidth”)

   viewport_height = context.browser.execute_script(“return window.innerHeight”)

   rectangles = []

   i = 0

   while i < total_height:

       ii = 0

       top_height = i + viewport_height

 

       if top_height > total_height:

           top_height = total_height

 

       while ii < total_width:

           top_width = ii + viewport_width

           if top_width > total_width:

               top_width = total_width

           rectangles.append((ii, i, top_width, top_height))

           ii = ii + viewport_width

       i = i + viewport_height

   previous = None

 

   for rectangle in rectangles:

       if not previous is None:

           context.browser.execute_script(“window.scrollTo({0}, {1})”.format(rectangle[0], rectangle[1]))

           time.sleep(3)

   return total_height, total_width

 

 

  1. Finalizamos con tres método complementarios para el manejo de rutas, estos métodos son llamados directamente por el método save_snapshot()

 

def retornaSoloRuta(ruta):

   “””

   Toma una ruta en formato “carpeta/archivo” y retorna solo “carpeta/”

   ej: carpeta/carpeta/archivo  retorna carpeta/carpeta

   La ruta ya debe venir con el os.sep correcto

   :return:

   “””

   resultado = ruta.split(os.sep)

   onlyURI = “”

   for i in range(len(resultado) 1):

       onlyURI = onlyURI + resultado[i] + os.sep

   return onlyURI

 

def getRutaSnaps():

   URI = str(pathlib.Path().absolute())

   return URI + os.sep + ‘timeMachine’ + os.sep + ‘snapshot’ + os.sep

 

def getRutaSnapsTemp():

   URI = str(pathlib.Path().absolute())

   return URI + os.sep + ‘timeMachine’ + os.sep + ‘snapshot_temp’ + os.sep

 

 

Ejecutemos una prueba en Google

 

Creamos un feature simple, con el siguiente caso de prueba:

 

@testOCV_1

Scenario: Validar cambios en el home de google.com

 Then valido que el home de “https://www.google.com” no haya sufrido cambios

 

 

En la definición del paso ingresamos lo sgte, incluyendo un assert de validación:

 

@then(uvalido que el home de “{url}” no haya sufrido cambios)

def step_impl(context, url):

   context.browser.get(url)

   validador = Snapshots.Snapshot.validateSnapshots(context, “google/home”)

   assert validador, “Los screenshots no coinciden”

 

 

Ejecutamos, y se crea la carpeta correspondiente, incluyendo el screenshot inicial del front de google.com

 

 

 

 

Ejecutamos una segunda vez y obtenemos por consola el resultado correcto, en este ejemplo ambos screenshots del front eran iguales.

 

Existe un archivo original

Se guarda la imagen en:  C:\Users\felipefarias\git\demo\timeMachine\snapshot_temp\google\home.png

Las imagenes tiene el mismo tamaño y canal

Finalizando escenario :  Validar cambios en el home de google.com con estado Status.passed

 

1 feature passed, 0 failed, 0 skipped

1 scenario passed, 0 failed, 0 skipped

1 step passed, 0 failed, 0 skipped, 0 undefined

 

 

Forcemos un error

 

Modificaremos el screenshot base, quitamos el logo de Google

 

 

y ejecutamos nuevamente. Esta vez la prueba ha fallado, tal cual como se esperaba. Pese a que las imagenes tienen el mismo tamaño y canal (de colores) el programa ha detectado un cambio significativo en la versión actual del front con respecto a la anterior (que modificamos a mano).

 

 

Existe un archivo original

Se guarda la imagen en:  C:\Users\felipefarias\git\demo\timeMachine\snapshot_temp\google\home.png

Las imagenes tiene el mismo tamaño y canal

      Assertion Failed: Los screenshots no coinciden

 

Finalizando escenario :  Validar cambios en el home de google.com con estado Status.failed

 

0 features passed, 1 failed, 0 skipped

0 scenarios passed, 1 failed, 0 skipped

0 steps passed, 1 failed, 0 skipped, 0 undefined

 

 

Además, nuestro código ha almacenado tanto la imagen original como la nueva, esto a fin de comparar o levantar evidencias de los cambios. Esta imagen temporal, es eliminada cuando la prueba es exitosa.