Pruebas CI/CD (Testing)


Índice

  1. Introducción
  2. Pruebas
  3. Afirmar
  4. Pruebas Unitarias
  5. Pruebas en Django
  6. Selenium
  7. CI/CD (Integración Continua/Despliegue Continuo)
  8. Acciones de GitHub
  9. Docker

Introducción

Hasta ahora, hemos discutido cómo construir páginas web simples utilizando HTML y CSS, y cómo utilizar Git y GitHub para realizar un seguimiento de los cambios en nuestro código y colaborar con otros. También nos familiarizamos con el lenguaje de programación Python, comenzamos a utilizar Django para crear aplicaciones web y aprendimos a utilizar los modelos de Django para almacenar información en nuestros sitios. Luego, introdujimos JavaScript y aprendimos a usarlo para hacer que las páginas web sean más interactivas, y hablamos sobre el uso de la animación y React para mejorar aún más nuestras interfaces de usuario. Hoy, aprenderemos sobre las mejores prácticas cuando se trata de trabajar en proyectos más grandes y lanzarlos.

Pruebas

Una parte importante del proceso de desarrollo de software es la acción de probar el código que hemos escrito para asegurarnos de que todo funcione como esperamos. En este curso, discutiremos varias formas en las que podemos mejorar la manera en que probamos nuestro código.

Afirmar

Una de las formas más simples de ejecutar pruebas en Python es mediante el uso del comandoassert. Este comando va seguido de alguna expresión que debería ser verdadera. Si la expresión es verdadera, no sucede nada; sin embargo, si esfalsa, se generará una excepción. Veamos cómo podríamos incorporar este comando para probar la función de cuadrado que escribimos al aprender Python por primera vez. Cuando la función está escrita correctamente, no sucede nada, ya que elassertesverdadero.

python
1def square(x):
2return x * x
3
4assert square(10) == 100
5
6""" Output:
7
8"""

Y luego, cuando está escrita incorrectamente, se genera una excepción.

python
1def square(x):
2return x + x
3
4assert square(10) == 100
5
6""" Output:
7Traceback (most recent call last):
8File "assert.py", line 4, in <module>
9assert square(10) == 100
10AssertionError
11"""

Desarrollo guiado por pruebas

Al comenzar a construir proyectos más grandes, es posible que desees considerar el uso del desarrollo guiado por pruebas (Test-Driven Development, TDD), un estilo de desarrollo en el que cada vez que solucionas un error, añades una prueba que verifica ese error a un conjunto creciente de pruebas que se ejecutan cada vez que realizas cambios. Esto te ayudará a asegurarte de que las características adicionales que agregas a un proyecto no interfieran con tus características existentes.

Ahora, veamos una función un poco más compleja y pensemos en cómo escribir pruebas puede ayudarnos a encontrar errores. Ahora escribiremos una función llamadais_primeque devuelveTruesolo si su entrada es un número primo.

python
1import math
2
3def es_primo(n):
4 # Sabemos que los números menores que 2 no son primos
5 if n < 2:
6 return False
7
8 # Verificamos factores hasta la raíz cuadrada de n
9 for i in range(2, int(math.sqrt(n))):
10
11 # Si i es un factor, devolvemos False
12 if n % i == 0:
13 return False
14
15# Si no se encontraron factores, devolvemos True
16return True

Ahora, echemos un vistazo a una función que hemos escrito para probar nuestra función de números primos:

python
1from prime import is_prime
2
3def test_prime(n, expected):
4 if is_prime(n) != expected:
5 print(f"ERROR on is_prime({n}), expected {expected}")

En este punto, podemos ingresar a nuestro intérprete de Python y probar algunos valores:

python
1>>> test_prime(5, True)
2>>> test_prime(10, False)
3>>> test_prime(25, False)
4ERROR on is_prime(25), expected False

Podemos observar en la salida anterior que 5 y 10 fueron identificados correctamente como primos y no primos, pero 25 fue incorrectamente identificado como primo, por lo que debe haber algo mal con nuestra función. Antes de investigar qué está mal con nuestra función, veamos una manera de automatizar nuestras pruebas. Una forma de hacer esto es mediante la creación de un script de shell, o algún script que pueda ejecutarse dentro de nuestra terminal. Estos archivos requieren una extensión .sh, por lo que nuestro archivo se llamará tests0.sh. Cada una de las líneas a continuación consta de:

  1. Unpython3para especificar la versión de Python que estamos ejecutando.
  2. Un-cpara indicar que deseamos ejecutar un comando.
  3. Un comando a ejecutar en formato de cadena.
python
1python3 -c "from tests0 import test_prime; test_prime(1, False)"
2python3 -c "from tests0 import test_prime; test_prime(2, True)"
3python3 -c "from tests0 import test_prime; test_prime(8, False)"
4python3 -c "from tests0 import test_prime; test_prime(11, True)"
5python3 -c "from tests0 import test_prime; test_prime(25, False)"
6python3 -c "from tests0 import test_prime; test_prime(28, False)"

Ahora podemos ejecutar estos comandos ejecutando./tests0.shen nuestra terminal, lo que nos dará este resultado:

ERROR on is_prime(8), expected False ERROR on is_prime(25), expected False

Pruebas Unitarias

Aunque pudimos ejecutar pruebas automáticamente utilizando el método anterior, aún podríamos querer evitar tener que escribir cada una de esas pruebas. Afortunadamente, podemos utilizar la biblioteca de pruebas unitarias de Python para facilitar un poco este proceso. Echemos un vistazo a cómo podría ser un programa de pruebas para nuestra funciónis_prime.

python
1# Importamos la librería de unittest y nuestra función
2import unittest
3from prime import is_prime
4
5# Una clase que contiene todos nuestras pruebas
6class Tests(unittest.TestCase):
7
8 def test_1(self):
9 """Check that 1 is not prime."""
10 self.assertFalse(is_prime(1))
11
12 def test_2(self):
13 """Check that 2 is prime."""
14 self.assertTrue(is_prime(2))
15
16 def test_8(self):
17 """Check that 8 is not prime."""
18 self.assertFalse(is_prime(8))
19
20 def test_11(self):
21 """Check that 11 is prime."""
22 self.assertTrue(is_prime(11))
23
24 def test_25(self):
25 """Check that 25 is not prime."""
26 self.assertFalse(is_prime(25))
27
28 def test_28(self):
29 """Check that 28 is not prime."""
30 self.assertFalse(is_prime(28))
31
32
33# Corremos cada una de las funciones de prueba
34if __name__ == "__main__":
35 unittest.main()

Observa que cada una de las funciones dentro de nuestra claseTestssigue un patrón:

  • El nombre de las funciones comienza contest_. Esto es necesario para que las funciones se ejecuten automáticamente con la llamada aunittest.main().
  • Cada prueba recibe el argumentoself. Esto es estándar al escribir métodos dentro de clases en Python.
  • La primera línea de cada función contiene una cadena de documentación rodeada por tres comillas. No es solo para la legibilidad del código. Cuando se ejecutan las pruebas, el comentario se mostrará como una descripción de la prueba si falla.
  • La siguiente línea de cada una de las funciones contenía una afirmación en forma deself.assertSOMETHING.
    1. Hay muchas afirmaciones diferentes que puedes hacer, incluyendo:
    2. assertTrue
    3. assertFalse
    4. assertEqual
    5. yassertGreater

Puedes encontrar estas y más en la documentación. Ahora, echemos un vistazo a los resultados de estas pruebas:

output
1...F.F
2======================================================================
3FAIL: test_25 (__main__.Tests)
4Check that 25 is not prime.
5----------------------------------------------------------------------
6Traceback (most recent call last):
7 File "tests1.py", line 26, in test_25
8 self.assertFalse(is_prime(25))
9AssertionError: True is not false
10
11======================================================================
12FAIL: test_8 (__main__.Tests)
13Check that 8 is not prime.
14----------------------------------------------------------------------
15Traceback (most recent call last):
16 File "tests1.py", line 18, in test_8
17 self.assertFalse(is_prime(8))
18AssertionError: True is not false
19
20----------------------------------------------------------------------
21Ran 6 tests in 0.001s
22
23FAILED (failures=2)

Después de ejecutar las pruebas, unittest nos proporciona información útil sobre lo que encontró. En la primera línea, nos muestra una serie de . para éxitos y F para fallos en el orden en que se escribieron nuestras pruebas.

output
1...F.F

A continuación, para cada una de las pruebas que fallaron, se nos proporciona el nombre de la función que falló:

output
1FAIL: test_25 (__main__.Tests)

El comentario descriptivo que proporcionamos anteriormente:

output
1Check that 25 is not prime.

Y un rastreo (traceback) para la excepción:

output
1Traceback (most recent call last): File "tests1.py", line 26, in
2test_25 self.assertFalse(is_prime(25)) AssertionError: True is not
3false

Y finalmente, se nos proporciona una revisión de cuántas pruebas se ejecutaron, cuánto tiempo tomaron y cuántas fallaron:

output
1Ran 6 tests in 0.001s
2
3FAILED (failures=2)

Ahora, veamos cómo corregir el error en nuestra función. Resulta que necesitamos probar un número adicional en nuestro bucle for. Por ejemplo, cuando n es 25, la raíz cuadrada es 5, pero cuando ese es un argumento en la función de rango, el bucle for termina en el número 4. Por lo tanto, simplemente podemos cambiar el encabezado de nuestro bucle for a:

python
1for i in range(2, int(math.sqrt(n)) + 1):

Ahora, cuando ejecutamos las pruebas nuevamente utilizando nuestras pruebas unitarias, obtenemos la siguiente salida, indicando que nuestro cambio solucionó el error.

output
1......
2----------------------------------------------------------------------
3Ran 6 tests in 0.000s
4
5OK

Estas pruebas automatizadas se volverán aún más útiles a medida que trabajes para optimizar esta función. Por ejemplo, es posible que desees aprovechar el hecho de que no necesitas verificar todos los enteros como factores, solo primos más pequeños (si un número no es divisible por 3, tampoco es divisible por 6, 9, 12, ...), o es posible que desees utilizar pruebas de primalidad más avanzadas como las pruebas de primalidad de Fermat y Miller-Rabin. Cada vez que realices cambios para mejorar esta función, querrás tener la capacidad de ejecutar fácilmente tus pruebas unitarias nuevamente para asegurarte de que tu función siga siendo correcta.

Pruebas Django

Ahora, veamos cómo podemos aplicar las ideas de pruebas automatizadas al crear aplicaciones Django. Mientras trabajamos en esto, utilizaremos el proyecto "flights" que creamos cuando aprendimos por primera vez sobre los modelos de Django. Primero vamos a agregar un método a nuestro modelo de Vuelo (Flight) que verifique que un vuelo sea válido al verificar dos condiciones:

  1. El origen no es lo mismo que destino.
  2. La duración es mayor a 0 minutos.

Ahora nuestro modelo podría verse de la siguiente manera:

python
1class Flight(models.Model):
2origin = models.ForeignKey(Airport, on_delete=models.CASCADE, related_name="departures")
3destination = models.ForeignKey(Airport, on_delete=models.CASCADE, related_name="arrivals")
4duration = models.IntegerField()
5
6def __str__(self):
7 return f"{self.id}: {self.origin} to {self.destination}"
8
9def is_valid_flight(self):
10 return self.origin != self.destination or self.duration > 0

Para asegurarnos de que nuestra aplicación funcione según lo esperado, cada vez que creamos una nueva aplicación, automáticamente se nos proporciona un archivotests.py. Cuando abrimos este archivo por primera vez, vemos que la biblioteca de pruebas de DjangoTestCasese importa automáticamente:

python
1from django.test import TestCase

Una ventaja de utilizar la biblioteca TestCase es que, al ejecutar nuestras pruebas, se creará una base de datos completamente nueva solo con fines de prueba. Esto es útil porque evitamos el riesgo de modificar o eliminar accidentalmente entradas existentes en nuestra base de datos y no tenemos que preocuparnos por eliminar entradas ficticias que creamos solo para realizar pruebas.

Para comenzar a utilizar esta biblioteca, primero querremos importar todos nuestros modelos:

python
1from .models import Flight, Airport, Passenger

Y luego crearemos una nueva clase que extienda la clase TestCase que acabamos de importar. Dentro de esta clase, definiremos una función setUp que se ejecutará al inicio del proceso de prueba. En esta función, probablemente querremos crear. Así es como se verá nuestra clase para comenzar:

python
1class FlightTestCase(TestCase):
2
3def setUp(self):
4
5 # Crear aeropuertos.
6 a1 = Airport.objects.create(code="AAA", city="City A")
7 a2 = Airport.objects.create(code="BBB", city="City B")
8
9 # Crear vuelos.
10 Flight.objects.create(origin=a1, destination=a2, duration=100)
11 Flight.objects.create(origin=a1, destination=a1, duration=200)
12 Flight.objects.create(origin=a1, destination=a2, duration=-100)

Ahora que tenemos algunas entradas en nuestra base de datos de prueba, agreguemos algunas funciones a esta clase para realizar algunas pruebas. Primero, asegurémonos de que nuestros campos dedeparturesyarrivesfuncionen correctamente intentando contar el número de salidas (que sabemos deberían ser 3) y llegadas (que deberían ser 1) desde el aeropuertoAAA:

python
1def test_departures_count(self):
2a = Airport.objects.get(code="AAA")
3self.assertEqual(a.departures.count(), 3)
4
5def test_arrivals_count(self):
6a = Airport.objects.get(code="AAA")
7self.assertEqual(a.arrivals.count(), 1)

También podemos probar la funciónis_valid_flightque agregamos a nuestro modelo de vuelo (`Flight`). Comenzaremos haciendo una afirmación de que la función devuelve verdadero cuando el vuelo es válido:

python
1def test_valid_flight(self):
2a1 = Airport.objects.get(code="AAA")
3a2 = Airport.objects.get(code="BBB")
4f = Flight.objects.get(origin=a1, destination=a2, duration=100)
5self.assertTrue(f.is_valid_flight())

A continuación, asegurémonos de que los vuelos con destinos y duraciones no válidos devuelvan falso:

python
1def test_invalid_flight_destination(self):
2a1 = Airport.objects.get(code="AAA")
3f = Flight.objects.get(origin=a1, destination=a1)
4self.assertFalse(f.is_valid_flight())
5
6def test_invalid_flight_duration(self):
7a1 = Airport.objects.get(code="AAA")
8a2 = Airport.objects.get(code="BBB")
9f = Flight.objects.get(origin=a1, destination=a2, duration=-100)
10self.assertFalse(f.is_valid_flight())

Ahora, para ejecutar nuestras pruebas, ejecutaremospython manage.py test. La salida para esto es casi idéntica a la salida que vimos al utilizar la biblioteca deunittestde Python, aunque también registra que está creando y destruyendo una base de datos de prueba:

output
1Creating test database for alias 'default'...
2System check identified no issues (0 silenced).
3..FF.
4======================================================================
5FAIL: test_invalid_flight_destination (flights.tests.FlightTestCase)
6----------------------------------------------------------------------
7Traceback (most recent call last):
8 File "/Users/Neo/Documents/cs50/web_notes_files/7/django/airline/flights/tests.py",
9 line 37, in test_invalid_flight_destination
10 self.assertFalse(f.is_valid_flight())
11AssertionError: True is not false
12
13======================================================================
14FAIL: test_invalid_flight_duration (flights.tests.FlightTestCase)
15----------------------------------------------------------------------
16Traceback (most recent call last):
17 File "/Users/Neo/Documents/cs50/web_notes_files/7/django/airline/flights/tests.py",
18 line 43, in test_invalid_flight_duration
19 self.assertFalse(f.is_valid_flight())
20AssertionError: True is not false
21
22----------------------------------------------------------------------
23Ran 5 tests in 0.018s
24
25FAILED (failures=2)
26Destroying test database for alias 'default'...

Podemos ver en la salida anterior que hay momentos en los queis_valid_flightdevolvióTruecuando debería haber devueltoFalse. Al examinar más a fondo nuestra función, vemos que cometimos el error de usaroren lugar deand, lo que significa que solo uno de los requisitos del vuelo debe cumplirse para que sea válido. Si cambiamos la función a esto:

python
1def is_valid_flight(self):
2return self.origin != self.destination and self.duration > 0

Ahora nosotros podemos correr las pruebas con mejores resultados:

output
1Creating test database for alias 'default'...
2System check identified no issues (0 silenced).
3.....
4----------------------------------------------------------------------
5Ran 5 tests in 0.014s
6
7OK
8Destroying test database for alias 'default'...

Pruebas de Cliente

Al crear aplicaciones web, es probable que deseemos verificar no solo si funciones específicas funcionan, sino también si las páginas web individuales se cargan según lo previsto. Podemos lograr esto creando un objetoClienteen nuestra clase de pruebas de Django y luego realizando solicitudes utilizando ese objeto. Para hacer esto, primero tendremos que agregarClienta nuestras importaciones.

python
1from django.tests import Client, TestCase

Por ejemplo, ahora agreguemos una prueba que se asegure de que obtengamos un código de respuesta HTTP 200 y que los tres de nuestros vuelos se añadan al contexto de una respuesta:

python
1def test_index(self):
2# Configurar el cliente para realizar solicitudes
3c = Client()
4
5# Enviar solicitud GET a la página de índice y almacenar la respuesta
6response = c.get("/vuelos/")
7
8# Asegurarse de que el código de estado sea 200
9self.assertEqual(response.status_code, 200)
10
11# Asegurarse de que se devuelvan tres vuelos en el contexto
12self.assertEqual(response.context["vuelos"].count(), 3)

Podemos realizar una verificación similar para asegurarnos de obtener un código de respuesta válido para una página de vuelo válida y un código de respuesta no válido para una página de vuelo que no existe. (Observa que utilizamos la funciónMaxpara encontrar elIDmáximo, al cual tenemos acceso al incluirfrom django.db.models import Maxal principio de nuestro archivo).

python
1def test_valid_flight_page(self):
2a1 = Airport.objects.get(code="AAA")
3f = Flight.objects.get(origin=a1, destination=a1)
4
5c = Client()
6response = c.get(f"/flights/{f.id}")
7self.assertEqual(response.status_code, 200)
8
9def test_invalid_flight_page(self):
10max_id = Flight.objects.all().aggregate(Max("id"))["id__max"]
11
12c = Client()
13response = c.get(f"/flights/{max_id + 1}")
14self.assertEqual(response.status_code, 404)

Finalmente, agreguemos algunas pruebas para asegurarnos de que las listas de pasajeros y no pasajeros se estén generando como se espera:

python
1def test_flight_page_passengers(self):
2f = Flight.objects.get(pk=1)
3p = Passenger.objects.create(first="Alice", last="Adams")
4f.passengers.add(p)
5
6c = Client()
7response = c.get(f"/flights/{f.id}")
8self.assertEqual(response.status_code, 200)
9self.assertEqual(response.context["passengers"].count(), 1)
10
11def test_flight_page_non_passengers(self):
12f = Flight.objects.get(pk=1)
13p = Passenger.objects.create(first="Alice", last="Adams")
14
15c = Client()
16response = c.get(f"/flights/{f.id}")
17self.assertEqual(response.status_code, 200)
18self.assertEqual(response.context["non_passengers"].count(), 1)

Ahora podemos ejecutar todos nuestros tests juntos, y ver que hasta el momento no hay errores.

output
1Creating test database for alias 'default'...
2System check identified no issues (0 silenced).
3..........
4----------------------------------------------------------------------
5Ran 10 tests in 0.048s
6
7OK
8Destroying test database for alias 'default'...

Selenium

Hasta ahora, hemos podido probar el código del lado del servidor que hemos escrito utilizando Python y Django, pero a medida que construimos nuestras aplicaciones, también querremos la capacidad de crear pruebas para nuestro código del lado del cliente. Por ejemplo, volvamos a nuestra páginacounter.htmly trabajemos en escribir algunas pruebas para ella.

Comenzaremos escribiendo una página de contador ligeramente diferente en la que incluiremos un botón para disminuir el conteo:

javascript
1<!DOCTYPE html>
2<html lang="en">
3 <head>
4 <title>Counter</title>
5 <script>
6
7 // Esperamos que cargue la página
8 document.addEventListener('DOMContentLoaded', () => {
9
10 // Inicializamos la variable en 0
11 let counter = 0;
12
13 // Si hacemos click en el botón de incremento, aumeta el valor y lo ingresa al HTML
14 document.querySelector('#increase').onclick = () => {
15 counter ++;
16 document.querySelector('h1').innerHTML = counter;
17 }
18
19 // Si hacemos click en el botón de decremento, disminuye el valor y lo ingresa al HTML
20 document.querySelector('#decrease').onclick = () => {
21 counter --;
22 document.querySelector('h1').innerHTML = counter;
23 }
24 })
25 </script>
26 </head>
27 <body>
28 <h1>0</h1>
29 <button id="increase">+</button>
30 <button id="decrease">-</button>
31 </body>
32</html>

Ahora, si deseamos probar este código, podríamos simplemente abrir nuestro navegador web, hacer clic en los dos botones y observar qué sucede. Sin embargo, esto se volvería muy tedioso a medida que escribimos aplicaciones de una sola página más grandes, razón por la cual se han creado varios frameworks que ayudan con las pruebas en el navegador, uno de los cuales se llamaSelenium.

Utilizando Selenium, podremos definir un archivo de pruebas en Python donde podemos simular que un usuario abre un navegador web, navega hacia nuestra página e interactúa con ella. Nuestra herramienta principal al hacer esto se conoce como un controlador web (Web Driver), que abrirá un navegador web en su computadora. Echemos un vistazo a cómo podríamos empezar a usar esta biblioteca para comenzar a interactuar con las páginas. Ten en cuenta que a continuación utilizamos tantoSeleniumcomoChromeDriver. Selenium se puede instalar para Python ejecutandopip install selenium, yChromeDriverse puede instalar ejecutandopip install chromedriver-py.

python
1import os
2import pathlib
3import unittest
4
5from selenium import webdriver
6
7# Encuentra el Identificador Uniforme de Recursos (Uniform Resource Identifier) de un archivo.
8def file_uri(filename):
9 return pathlib.Path(os.path.abspath(filename)).as_uri()
10
11# configura el controlador web utilizando Google Chrome
12driver = webdriver.Chrome()

El código anterior constituye la configuración básica que necesitamos, por lo que ahora podemos adentrarnos en algunos usos más interesantes empleando el intérprete de Python. Una observación sobre las primeras líneas es que, para apuntar a una página específica, necesitamos el Identificador Uniforme de Recursos (URI) de esa página, que es una cadena única que representa ese recurso.

python
1# Encontrar la dirección URI de nuestro archivo creado
2>>> uri = file_uri("counter.html")
3
4# Usamos la URI para abrir la página web
5>>> driver.get(uri)
6
7# Accedemos al título de la página actual
8>>> driver.title
9'Counter'
10
11# Encontrar y almacenar los campos de los botones
12>>> increase = driver.find_element_by_id("increase")
13>>> decrease = driver.find_element_by_id("decrease")
14
15# Simular el clickeo de los usuarios en los botones
16>>> increase.click()
17>>> increase.click()
18>>> decrease.click()
19
20# Incluso podemos incluir clics dentro de otras construcciones de Python:
21>>> for i in range(25):
22... increase.click()

Ahora demos un vistazo como nosotros podemos utilizar ésta simulación y crear un test automático de nuestra página.

python
1# Estructura estándar de una clase de pruebas
2class PruebasPaginaWeb(unittest.TestCase):
3
4 def test_titulo(self):
5 """Asegurarse de que el título sea correcto"""
6 driver.get(file_uri("counter.html"))
7 self.assertEqual(driver.title, "Counter")
8
9 def test_aumento(self):
10 """Asegurarse de que el encabezado se actualice a 1 después de hacer clic en el botón
11 de aumento una vez"""
12 driver.get(file_uri("counter.html"))
13 increase = driver.find_element_by_id("increase")
14 increase.click()
15 self.assertEqual(driver.find_element_by_tag_name("h1").text, "1")
16
17 def test_disminucion(self):
18 """Asegurarse de que el encabezado se actualice a -1 después de hacer clic en el botón
19 de disminución una vez"""
20 driver.get(file_uri("counter.html"))
21 decrease = driver.find_element_by_id("decrease")
22 decrease.click()
23 self.assertEqual(driver.find_element_by_tag_name("h1").text, "-1")
24
25 def test_aumento_multiple(self):
26 """Asegurarse de que el encabezado se actualice a 3 después de hacer clic en el botón
27 de aumento tres veces"""
28 driver.get(file_uri("counter.html"))
29 increase = driver.find_element_by_id("increase")
30 for i in range(3):
31 increase.click()
32 self.assertEqual(driver.find_element_by_tag_name("h1").text, "3")
33
34if __name__ == "__main__":
35 unittest.main()

El código completo se vería de la siguiente manera:

python
1import os
2import pathlib
3import unittest
4
5from selenium import webdriver
6
7def file_uri(file_name):
8 return pathlib.Path(os.path.abspath(file_name)).as_uri()
9
10driver = webdriver.Chrome()
11
12uri = file_uri("counter.html")
13
14driver.get(uri)
15
16increase = driver.find_element_by_id("increase")
17decrease = driver.find_element_by_id("decrease")
18
19increase.click()
20increase.click()
21decrease.click()
22
23for i in range(25):
24 increase.click()
25
26class WebpageTests(unittest.TestCase):
27
28 def test_title(self):
29 """Make sure title is correct"""
30 driver.get(file_uri("counter.html"))
31 self.assertEqual(driver.title, "Counter")
32
33 def test_increase(self):
34 """Make sure header updated to 1 after 1 click of increase button"""
35 driver.get(file_uri("counter.html"))
36 increase = driver.find_element_by_id("increase")
37 increase.click()
38 self.assertEqual(driver.find_element_by_tag_name("h1").text, "1")
39
40 def test_decrease(self):
41 """Make sure header updated to -1 after 1 click of decrease button"""
42 driver.get(file_uri("counter.html"))
43 decrease = driver.find_element_by_id("decrease")
44 decrease.click()
45 self.assertEqual(driver.find_element_by_tag_name("h1").text, "-1")
46
47 def test_multiple_increase(self):
48 """Make sure header updated to 3 after 3 clicks of increase button"""
49 driver.get(file_uri("counter.html"))
50 increase = driver.find_element_by_id("increase")
51 for i in range(3):
52 increase.click()
53 self.assertEqual(driver.find_element_by_tag_name("h1").text, "3")
54
55if __name__ == "__main__":
56 unittest.main()

Ahora, si ejecutamospython tests.py, nuestras simulaciones se llevarán a cabo en el navegador y luego los resultados de las pruebas se imprimirán en la consola. Aquí tienes un ejemplo de cómo podría verse esto cuando hay un error en el código y una prueba falla:

Para terminar un test y cerrar el navegador, podríamos terminar el código con:

python
1driver.quit()

La impresión de los resultados en consola serán los siguientes:

Resulatado test de python en consola, url de la imagen: /images/tests_py.png

CI/CD

CI/CD, que significa Integración Continua y Despliegue Continuo, es un conjunto de mejores prácticas de desarrollo de software que dicta cómo se escribe el código por un equipo de personas y cómo ese código se entrega posteriormente a los usuarios de la aplicación. Como su nombre indica, este método consta de dos partes principales:

  • Integración Continua:
    • Fusiones presentes con la rama principal.
    • Pruebas unitarias automatizadas con cada fusión.
  • Despliegue Continuo:
    • Programas cortos de lanzamiento, lo que significa que nuevas versiones de una aplicación se lanzan con frecuencia.
    • CI/CD se ha vuelto cada vez más popular entre los equipos de desarrollo de software por varias razones:
  • Cuando diferentes miembros del equipo están trabajando en diferentes funciones, pueden surgir muchos problemas de compatibilidad cuando se combinan múltiples funciones al mismo tiempo. La integración continua permite a los equipos abordar pequeños conflictos a medida que surgen.
  • Debido a que las pruebas unitarias se ejecutan con cada fusión, cuando una prueba falla, es más fácil aislar la parte del código que está causando el problema.
  • El lanzamiento frecuente de nuevas versiones de una aplicación permite a los desarrolladores aislar problemas si surgen después del lanzamiento.
  • El lanzamiento de cambios pequeños e incrementales permite a los usuarios acostumbrarse gradualmente a las nuevas funciones de la aplicación en lugar de sentirse abrumados con una versión completamente diferente.
  • No esperar para lanzar nuevas funciones permite a las empresas mantenerse a la vanguardia en un mercado competitivo.

Acciones de GitHub

Una herramienta popular utilizada para facilitar la integración continua es conocida comoGitHub Actions. GitHub Actions nos permite crear flujos de trabajo donde podemos especificar ciertas acciones que se realizarán cada vez que alguien haga un push a un repositorio de Git. Por ejemplo, podríamos querer verificar con cada push que se cumple con una guía de estilo o que se aprueben una serie de pruebas unitarias.

Para configurar una GitHub Action, utilizaremos un lenguaje de configuración llamado YAML. YAML estructura sus datos en torno a pares de clave-valor (como un objeto JSON o un diccionario de Python). Aquí tienes un ejemplo de un archivo YAML simple:

yaml
1key1: value1
2key2: value2
3key3:
4 - item1
5 - item2
6 - item3

Ahora, veamos un ejemplo de cómo configuraríamos un archivo YAML (que toma la forma dename.ymloname.yaml) que funcione con GitHub Actions. Para hacer esto, crearé un directorio.githuben mi repositorio, luego un directorioworkflowsdentro de ese, y finalmente un archivoci.ymldentro de ese. En ese archivo, escribiremos:

yaml
1name: Testing
2on: push
3
4jobs:
5 test_project:
6 runs-on: ubuntu-latest
7 steps:
8 - uses: actions/checkout@v2
9 - name: Run Django unit tests
10 run: |
11 pip3 install --user django
12 python3 manage.py test

Dado que es la primera vez que utilizamos GitHub Actions, revisemos qué hace cada parte de este archivo:

  • Primero, damos un nombre al flujo de trabajo, que en nuestro caso esTesting. Luego, con la claveon, especificamos cuándo debería ejecutarse el flujo de trabajo.
  • En nuestro caso, queremos realizar las pruebas cada vez que alguien haga un push al repositorio.
  • El resto del archivo está contenido dentro de una clavejobs, que indica qué trabajos se deben ejecutar en cada push.
    • En nuestro caso, el único trabajo estest_project. Cada trabajo debe definir dos componentes:
      • La claveruns-onespecifica en qué máquinas virtuales de GitHub deseamos que se ejecute nuestro código.
      • La clavestepsproporciona las acciones que deben ocurrir cuando se ejecute este trabajo.
        • En la claveusesespecificamos qué acción de GitHub deseamos utilizar.actions/checkout@v2es una acción escrita por GitHub que podemos utilizar.
        • La clavenamenos permite proporcionar una descripción de la acción que estamos realizando.
        • Después de la claverun, escribimos los comandos que deseamos ejecutar en el servidor de GitHub. En nuestro caso, queremos instalar Django y luego ejecutar el archivo de pruebas.

Ahora, abramos nuestro repositorio en GitHub y echemos un vistazo a algunas de las pestañas cerca de la parte superior de la página:

  • Code: Esta es la pestaña que hemos estado utilizando con más frecuencia, ya que nos permite ver los archivos y carpetas dentro de nuestro directorio.
  • Issues: Aquí podemos abrir y cerrar problemas, que son solicitudes de corrección de errores o nuevas características. Podemos pensar en esto como una lista de tareas pendientes para nuestra aplicación.
  • Pull Requests: Solicitudes de personas que desean fusionar algún código de una rama en otra. Esta es una herramienta útil, ya que permite a las personas realizar revisiones de código donde comentan y dan sugerencias antes de que el código se integre en la rama principal.
  • GitHub Actions: Esta es la pestaña que usaremos al trabajar en la integración continua, ya que proporciona registros de las acciones que han tenido lugar después de cada push.

Aquí, imaginemos que hicimos un push de nuestros cambios antes de corregir el error que teníamos en la función is_valid_flight en models.py dentro de nuestro proyecto de aeropuerto. Ahora podemos navegar a la pestaña GitHub Actions, hacer clic en nuestro push más reciente, hacer clic en la acción que falló y ver el registro:

Github actions ejemplo, url de la imagen: /images/action.gif

Ahora, después de corregir el error, podríamos hacer un push nuevamente y obtener un resultado más satisfactorio:

Github actions ejemplo, url de la imagen: /images/action_success.gif

Docker

Los problemas pueden surgir en el mundo del desarrollo de software cuando la configuración en tu computadora es diferente a la que se está utilizando para ejecutar tu aplicación. Puedes tener una versión diferente de Python o algunos paquetes adicionales instalados que permiten que la aplicación se ejecute sin problemas en tu computadora, mientras que podría fallar en tu servidor. Para evitar estos problemas, necesitamos una forma de asegurarnos de que todos los que trabajan en un proyecto estén utilizando el mismo entorno. Una manera de hacer esto es mediante el uso de una herramienta llamada Docker, que es un software de contenerización, lo que significa que crea un entorno aislado dentro de tu computadora que puede estandarizarse entre muchos colaboradores y el servidor en el que se ejecuta tu sitio. Aunque Docker es un poco similar a una Máquina Virtual, son tecnologías diferentes. Una máquina virtual (como la utilizada en GitHub Actions o al lanzar un servidorAWS) es efectivamente una computadora virtual completa con su propio sistema operativo, lo que significa que ocupa mucho espacio donde se esté ejecutando. Docker, por otro lado, funciona configurando un contenedor dentro de una computadora existente, ocupando así menos espacio.

Ahora que tenemos una idea de lo que es un contenedor Docker, echemos un vistazo a cómo podemos configurar uno en nuestras computadoras. Nuestro primer paso para hacer esto será crear un archivo Docker que llamaremosDockerfile. En este archivo, proporcionaremos instrucciones sobre cómo crear una imagen Docker que describa las bibliotecas y binarios que deseamos incluir en nuestro contenedor. Aquí tienes un ejemplo de cómo podría verse nuestroDockerfile:

dockerfile
1FROM python:3
2COPY . /usr/src/app
3WORKDIR /usr/src/app
4RUN pip install -r requirements.txt
5CMD ["python3", "manage.py", "runserver", "0.0.0.0:8000"]

Aquí, examinaremos detenidamente lo que hace el archivo anterior:

  • FROM python3: Esto indica que estamos basando esta imagen en una imagen estándar en la que Python 3 está instalado. Esto es bastante común al escribir un archivo Docker, ya que te permite evitar la tarea de volver a definir la misma configuración básica con cada nueva imagen.
  • COPY . /usr/src/app: Esto indica que deseamos copiar todo desde nuestro directorio actual (.) y almacenarlo en el directorio /usr/src/app en nuestro nuevo contenedor.
  • WORKDIR /usr/src/app: Esto configura dónde ejecutaremos comandos dentro del contenedor. (Un poco como cd en la terminal).
  • RUN pip install -r requirements.txt: En esta línea, suponiendo que hayas incluido todos tus requisitos en un archivo llamado requirements.txt, todos se instalarán dentro del contenedor.
  • CMD ["python3", "manage.py", "runserver", "0.0.0.0:8000"]: Finalmente, especificamos el comando que se ejecutará cuando iniciemos el contenedor.

Hasta ahora en esta clase, solo hemos estado utilizando SQLite, ya que es el sistema de gestión de bases de datos predeterminado para Django. Sin embargo, en aplicaciones en vivo con usuarios reales, SQLite casi nunca se usa, ya que no es tan escalable como otros sistemas. Afortunadamente, si deseamos ejecutar un servidor de base de datos por separado, simplemente podemos agregar otro contenedor Docker y ejecutarlos juntos utilizando una función llamada Docker Compose. Esto permitirá que dos servidores diferentes se ejecuten en contenedores separados, pero también puedan comunicarse entre sí. Para especificar esto, usaremos un archivo YAML llamadodocker-compose.yml:

dockerfile
1version: '3'
2
3services:
4 db:
5 image: postgres
6
7 web:
8 build: .
9 volumes:
10 - .:/usr/src/app
11 ports:
12 - "8000:8000"

En el archivo anterior:

  1. Especificamos que estamos utilizando la versión 3 de Docker Compose.
  2. Detallamos dos servicios:
    • dbconfigura nuestro contenedor de base de datos basado en una imagen ya escrita por Postgres.
    • webconfigura nuestro contenedor de servidor instruyendo a Docker para:
      • Utilizar el Dockerfile dentro del directorio actual.
      • Utilizar la ruta especificada dentro del contenedor.
      • Vincular el puerto 8000 dentro del contenedor al puerto 8000 en nuestra computadora.

Ahora estamos listos para iniciar nuestros servicios con el comandodocker-compose up. Esto lanzará ambos de nuestros servidores dentro de nuevos contenedores Docker.

En este punto, es posible que deseemos ejecutar comandos dentro de nuestro contenedor Docker para agregar entradas a la base de datos o ejecutar pruebas. Para hacer esto, primero ejecutaremos docker ps para mostrar todos los contenedores de Docker que se están ejecutando. Luego, encontraremos elID del CONTENEDORdel contenedor que deseamos ingresar y ejecutaremosdocker exec -it CONTAINER_ID bash -l. Esto te llevará al directoriousr/src/appque configuramos dentro de nuestro contenedor. Podemos ejecutar cualquier comando que deseemos dentro de ese contenedor y luego salir ejecutandoCTRL-D.

¡Eso es todo en este curso! La próxima vez, trabajaremos en escalar nuestros proyectos y asegurarnos de que sean seguros.

Compartir

Todos los nombres de productos, logos y marcas son propiedad de sus respectivos creadores.