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.
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.
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
1defsquare(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.
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
3defes_primo(n):
4# Sabemos que los números menores que 2 no son primos
5if n <2:
6returnFalse
7
8# Verificamos factores hasta la raíz cuadrada de n
9for i inrange(2,int(math.sqrt(n))):
10
11# Si i es un factor, devolvemos False
12if n % i ==0:
13returnFalse
14
15# Si no se encontraron factores, devolvemos True
16returnTrue
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
3deftest_prime(n, expected):
4if is_prime(n)!= expected:
5print(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:
Unpython3para especificar la versión de Python que estamos ejecutando.
Un-cpara indicar que deseamos ejecutar un comando.
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
6classTests(unittest.TestCase):
7
8deftest_1(self):
9"""Check that 1 is not prime."""
10 self.assertFalse(is_prime(1))
11
12deftest_2(self):
13"""Check that 2 is prime."""
14 self.assertTrue(is_prime(2))
15
16deftest_8(self):
17"""Check that 8 is not prime."""
18 self.assertFalse(is_prime(8))
19
20deftest_11(self):
21"""Check that 11 is prime."""
22 self.assertTrue(is_prime(11))
23
24deftest_25(self):
25"""Check that 25 is not prime."""
26 self.assertFalse(is_prime(25))
27
28deftest_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.
Hay muchas afirmaciones diferentes que puedes hacer, incluyendo:
assertTrue
assertFalse
assertEqual
yassertGreater
Puedes encontrar estas y más en la documentación. Ahora, echemos un vistazo a los resultados de estas pruebas:
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 inrange(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.
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.
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:
El origen no es lo mismo que destino.
La duración es mayor a 0 minutos.
Ahora nuestro modelo podría verse de la siguiente manera:
7returnf"{self.id}: {self.origin} to {self.destination}"
8
9defis_valid_flight(self):
10return 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:
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
1deftest_departures_count(self):
2a = Airport.objects.get(code="AAA")
3self.assertEqual(a.departures.count(),3)
4
5deftest_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:
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:
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
1defis_valid_flight(self):
2return self.origin != self.destination and self.duration >0
Ahora nosotros podemos correr las pruebas con mejores resultados:
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
1deftest_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
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).
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:
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.
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
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:
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.
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:
5test_project:
6runs-on: ubuntu-latest
7steps:
8-uses: actions/checkout@v2
9-name: Run Django unit tests
10run:|
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:
Ahora, después de corregir el error, podríamos hacer un push nuevamente y obtener un resultado más satisfactorio:
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:
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:
Especificamos que estamos utilizando la versión 3 de Docker Compose.
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.