Cómo escribir pruebas de navegador confiables usando Selenium y Node.js

Hay muchos buenos artículos sobre cómo comenzar con las pruebas automáticas del navegador utilizando la versión NodeJS de Selenium.

Algunos envuelven las pruebas en Mocha o Jasmine, y otros automatizan todo con npm o Grunt o Gulp. Todos ellos describen cómo instalar lo que necesita, junto con un ejemplo de código de trabajo básico. Esto es muy útil porque hacer que todas las piezas funcionen por primera vez puede ser un desafío.

Pero no llegan a profundizar en los detalles de las muchas trampas y las mejores prácticas de automatizar las pruebas de su navegador cuando se usa Selenium.

Este artículo continúa donde terminan esos otros artículos y lo ayudará a escribir pruebas de navegador automatizadas que son mucho más confiables y fáciles de mantener con la API de Selenium de NodeJS.

Evita dormir

El driver.sleepmétodo del selenio es tu peor enemigo. Y todo el mundo lo usa. Esto puede deberse a que la documentación de la versión Node.js de Selenium es concisa y solo cubre la sintaxis de API. Carece de ejemplos de la vida real.

O puede deberse a que muchos códigos de ejemplo en artículos de blogs y en sitios de preguntas y respuestas como StackOverflow lo utilizan.

Digamos que un panel se anima desde un tamaño de cero a tamaño completo. Miremos.

Sucede tan rápido que es posible que no note que los botones y controles dentro del panel cambian constantemente de tamaño y posición.

Aquí hay una versión más lenta. Preste atención al botón de cierre verde y podrá ver el tamaño y la posición cambiantes del panel.

Esto casi nunca es un problema para los usuarios reales porque la animación ocurre muy rápido. Si fue lo suficientemente lento, como en el segundo video, y trató de hacer clic manualmente en el botón de cierre mientras esto sucedía, podría hacer clic en el botón incorrecto o perder el botón por completo.

Pero estas animaciones suelen suceder tan rápido que nunca tienes la oportunidad de hacer eso. Los humanos solo esperan a que se complete la animación. No es cierto con el selenio. Es tan rápido que puede intentar hacer clic en elementos que aún se están animando y es posible que obtenga un mensaje de error como:

System.InvalidOperationException : Element is not clickable at point (326, 792.5)

Aquí es cuando muchos programadores dirán “¡Ajá! Tengo que esperar a que termine la animación, así que solo driver.sleep(1000)esperaré a que el panel sea utilizable ".

¿Entonces, cuál es el problema?

La driver.sleep(1000)declaración hace lo que parece. Detiene la ejecución de su programa durante 1000 milisegundos y permite que el navegador continúe funcionando. Hacer maquetación, fundir o animar elementos, cargar la página o lo que sea.

Usando el ejemplo de arriba, si el panel se desvaneció durante un período de 800 milisegundos driver.sleep(1000), generalmente lograría lo que desea. Entonces, ¿por qué no usarlo?

La razón más importante es que no es determinista. Lo que significa que solo funcionará algunas veces. Dado que funciona solo una parte del tiempo, terminamos con pruebas frágiles que se rompen bajo ciertas condiciones. Esto le da una mala reputación a las pruebas automáticas del navegador.

¿Por qué funciona solo algunas veces? En otras palabras, ¿por qué no es determinista?

Lo que nota con los ojos no suele ser lo único que sucede en un sitio web. Un elemento de aparición gradual o animación es un ejemplo perfecto. Se supone que no debemos darnos cuenta de estas cosas si se hacen bien.

Si le dice a Selenium que primero busque un elemento y luego haga clic en él, es posible que solo haya unos pocos milisegundos entre esas dos operaciones. El selenio puede ser mucho más rápido que un humano.

Cuando un humano usa el sitio web, esperamos a que el elemento se desvanezca antes de hacer clic en él. Y cuando ese desvanecimiento toma menos de un segundo, probablemente ni siquiera nos damos cuenta de que estamos haciendo esa "espera". El selenio no solo es más rápido y menos indulgente, sus pruebas automatizadas tienen que lidiar con todo tipo de otros factores impredecibles:

  1. El diseñador de su página web puede cambiar el tiempo de animación de 800 milisegundos a 1200 milisegundos. Tu prueba acaba de romperse.
  2. Los navegadores no siempre hacen exactamente lo que pide. Debido a la carga del sistema, la animación podría detenerse y demorar más de 800 milisegundos, tal vez incluso más que su suspensión de 1000 milisegundos. Tu prueba acaba de romperse .
  3. Los diferentes navegadores tienen diferentes motores de diseño y priorizan las operaciones de diseño de manera diferente. Agregue un nuevo navegador a su conjunto de pruebas y sus pruebas simplemente fallaron .
  4. Los navegadores y el JavaScript que controla una página son asincrónicos por naturaleza. Si la animación en nuestro ejemplo cambia la funcionalidad que necesita información del back-end, el programador puede agregar una llamada AJAX y esperar el resultado antes de disparar la animación.

    Ahora estamos lidiando con latencia de red y garantía cero de cuánto tiempo tardará en mostrarse el panel. Tu prueba acaba de romperse .

  5. Seguramente hay otras razones que no conozco.

    Incluso un navegador por sí solo es una bestia compleja y todos tienen errores. Así que estamos hablando de intentar hacer que funcione lo mismo en variosdiferentes navegadores, varias versiones de navegadores diferentes, varios sistemas operativos diferentes y varias versiones de sistemas operativos diferentes.

    En algún momento, sus pruebas simplemente se rompen si no son deterministas. No es de extrañar que los programadores abandonen las pruebas automatizadas de navegadores y se quejen de lo frágiles que son las pruebas.

¿Qué suelen hacer los programadores para arreglar las cosas cuando ocurre algo de lo anterior? Ellos rastrean las cosas hasta los problemas de sincronización, por lo que la respuesta obvia es aumentar el tiempo en la declaración del conductor. Luego cruce los dedos para que cubra todos los posibles escenarios futuros de carga del sistema, diferencias de diseño del motor, etc. No es determinista y se rompe , ¡así que no hagas esto!

Si aún no está convencido, aquí hay una razón más: sus pruebas se ejecutarán mucho más rápido. La animación de nuestro ejemplo solo toma 800 milisegundos, esperamos. Para lidiar con el "esperamos" y hacer que las pruebas funcionen en todas las condiciones, probablemente verá algo parecido driver.sleep(2000)en el mundo real.

Eso es más de un segundo completo perdidopara un solo paso de sus pruebas automatizadas. En muchos pasos, se acumula rápidamente. Una prueba refactorizada recientemente para una de nuestras páginas web que tomó varios minutos debido al uso excesivo de driver.sleep ahora toma menos de quince segundos.

La mayor parte del resto de este artículo ofrece ejemplos específicos sobre cómo puede hacer que sus pruebas sean completamente deterministas y evitar el uso de driver.sleep.

Una nota sobre promesas

La API de JavaScript para Selenium hace un uso intensivo de las promesas, y también hace un buen trabajo al ocultarlas mediante el uso de un administrador de promesas incorporado. Esto está cambiando y quedará obsoleto.

En el futuro, necesitará aprender a usar el encadenamiento de promesas usted mismo o usar las nuevas funciones asíncronas de JavaScript como await.

In this article, the examples still make use of the traditional built-in Selenium promise manager and take advantage of promise chaining. The code examples here will make more sense if you understand how promises work. But you can still get a lot out of this article if you want to skip learning promises for the moment.

Let’s get started

Continuing with our example of a button that we want to click on a panel that animates, let’s look at several specific gotchas that could break our tests.

How about an element that is dynamically added to the page and does not even exist yet after the page is finished loading?

Waiting for an element to be present in the DOM

The following code would not work if an element with a CSS id of ‘my-button’ was added to the DOM after page load:

// Selenium initialization code left out for clarity
// Load the page.driver.get('https:/foobar.baz');
// Find the element.const button = driver.findElement(By.id('my-button'));
button.click();

The driver.findElement method expects the element to already be present in the DOM. It will error out if the element cannot be found immediately. In this case, immediately means “after page load is complete” due to the prior driver.get statement.

Remember that the current version of JavaScript Selenium manages the promises for you. So each statement will fully complete before moving on to the next statement.

Note: The above behavior isn’t always undesirable. driver.findElement on its own might be actually be handy if you are sure the element should already be there.

First let’s look at the wrong way of fixing this. Having been told it might take a few seconds for the element to be added to the DOM:

driver.get('https:/foobar.baz');
// Page has been loaded, now go to sleep for a few seconds.driver.sleep(3000);
// Pray that three seconds is enough and find the element.const button = driver.findElement(By.id('my-button'));
button.click();

For all the reasons mentioned earlier, this can break, and probably will. We need to learn how to wait for an element to be located. This is fairly easy, and you’ll see this often in examples from around the web. In the example below, we use the well documented driver.wait method to wait for up to twenty seconds for the element to be found in the DOM:

const button = driver.wait( until.elementLocated(By.id('my-button')), 20000);
button.click();

There are immediate advantages to this. For example, if the element is added to the DOM in one second, the driver.wait method will complete in one second. It will not wait the full twenty seconds specified.

Because of this behavior, we can put loads of padding in our timeout without worrying about the timeout slowing down our tests. Unlike the driver.sleep which will always wait the entire time specified.

This works in a lot of cases. But one case it doesn’t work in is trying to click an element that is present in the DOM, but is not yet visible.

Selenium is smart enough to not click an element that is not visible. This is good, because users cannot click invisible elements, but it does make us work harder at creating reliable automated tests.

Waiting until an element is visible

We will build on the above example because it makes sense to wait for an element to be located before it becomes visible.

You’ll also find our first use of promise chaining below:

const button = driver.wait( until.elementLocated(By.id('my-button')), 20000).then(element => { return driver.wait( until.elementIsVisible(element), 20000 );});
button.click();

We could almost stop here and you would already be far better off. With the code above, you will eliminate loads of test cases that would otherwise break because an element is not immediately present in the DOM. Or because it is not immediately visible due to things like animation. Or even for both reasons.

Now that you understand the technique, there should never be a reason to write Selenium code that is not deterministic. That’s not to say this is always easy.

When things become more difficult, developers often give up again and resort to driver.sleep. I hope by giving even more examples, I can encourage you to make your tests deterministic.

Writing your own conditions

Thanks to the until method, the JavaScript Selenium API already has a handful of convenience methods you can use with driver.wait. You can also wait until an element no longer exists, for an element that contains specific text, for an alert to be present, or many other conditions.

If you can’t find what you need in the supplied convenience methods you will need to write your own conditions. This is actually pretty easy, but it’s hard to find examples. And there is one gotcha — which we will get to.

According to the documentation, you can provide driver.wait with a function that returns true or false.

Let’s say we wanted to wait for an element to be full opacity:

// Get the element.const element = driver.wait( until.elementLocated(By.id('some-id')), 20000);
// driver.wait just needs a function that returns true of false.driver.wait(() => { return element.getCssValue('opacity') .then(opacity => opacity === '1');});

That seems useful and reusable, so let’s put it in a function:

const waitForOpacity = function(element) { return driver.wait(element => element.getCssValue('opacity') .then(opacity => opacity === '1'); );};

And then we can use our function:

driver.wait( until.elementLocated(By.id('some-id')), 20000).then(waitForOpacity);

Here comes the gotcha. What if we want to click the element after it reaches full opacity? If we try to assign the value returned by the above, we would not get what we want:

const element = driver.wait( until.elementLocated(By.id('some-id')), 20000).then(waitForOpacity);
// Oops, element is true or false, not an element.element.click();

We cannot use promise chaining either, for the same reason.

const element = driver.wait( until.elementLocated(By.id('some-id')), 20000).then(waitForOpacity).then(element => { // Nope, element is a boolean here too. element.click();}); 

This is easy to fix. Here is our improved method:

const waitForOpacity = function(element) { return driver.wait(element => element.getCssValue('opacity') .then(opacity => { if (opacity === '1') { return element; } else { return false; }); );};

The above pattern, which returns the element when the condition is true, and returns false otherwise, is a reusable pattern you can use when writing your own conditions.

Here is how we can use it with promise chaining:

driver.wait( until.elementLocated(By.id('some-id')), 20000).then(waitForOpacity).then(element => element.click());

Or even:

const element = driver.wait( until.elementLocated(By.id('some-id')), 20000).then(waitForOpacity);
element.click();

By writing your own simple conditions, you can expand your options for making your tests deterministic. But that’s not always enough.

Go negative

That’s right, sometimes you need to be negative instead of positive. What I mean by this is to test for something to no longer exist or for something to not be visible anymore.

Let’s say an element exists in the DOM already, but you shouldn’t interact with it until some data is loaded via AJAX. The element could be covered with a “loading…” panel.

If you paid close attention to the conditions offered by the until method, you might have noticed methods like elementIsNotVisible or elementIsDisabled or the not so obvious stalenessOfElement.

You could test for a “loading…” panel to no longer be visible:

// Already added to the DOM, so this will return immediately.const desiredElement = driver.wait( until.elementLocated(By.id('some-id')), 20000);
// But the element isn't really ready until the loading panel// is gone.driver.wait( until.elementIsNotVisible(By.id('loading-panel')), 20000);
// Loading panel is no longer visible, safe to interact now.desiredElement.click();

I find the stalenessOfElement to be particularly useful. It waits until an element has been removed from the DOM, which could also happen from page refresh.

Here is an example of waiting for the contents of an iframe to refresh before continuing:

let iframeElem = driver.wait( until.elementLocated(By.className('result-iframe')), 20000 );
// Now we do something that causes the iframe to refresh.someElement.click();
// Wait for the previous iframe to no longer exist:driver.wait( until.stalenessOf(iframeElem), 20000);
// Switch to the new iframe. driver.wait( until.ableToSwitchToFrame(By.className('result-iframe')), 20000);
// Any following code will be relative to the new iframe.

Always be deterministic, and don’t sleep

I hope these examples have helped you better understand how to make your Selenium tests deterministic. Do not rely on driver.sleep with an arbitrary guess.

If you have questions or your own techniques for making Selenium testing deterministic, please leave a comment.