diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 2bb2d6e..297cfed 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -16,6 +16,7 @@ jobs: - name: Checkout uses: actions/checkout@v2 - name: Test - run: docker compose run --rm app sh -c "python manage.py test" + run: docker compose run --rm app sh -c "python manage.py wait_for_db && + python manage.py test" - name: Lint run: docker compose run --rm app sh -c "flake8" diff --git a/Dockerfile b/Dockerfile index 76098ca..6a4b877 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,12 @@ WORKDIR /app EXPOSE 8000 ARG DEV=false + +# apt install -y python3-dev libpq-dev python3-psycopg2 && \ RUN python -m venv /py && \ + apt update && \ + apt install -y postgresql-client python3-dev libpq-dev gcc python3-psycopg2 && \ + apt clean && \ /py/bin/pip install --upgrade pip && \ /py/bin/pip install -r /tmp/requirements.txt && \ if [ $DEV = "true" ]; \ diff --git a/README.md b/README.md index 7b7f7ba..28c1cab 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ ej. `docker-compose run --rm app sh -c "python manage.py collectstatic"` - `docker-compose` Ejecuta un comando de Docker Compose - `run` comienza un contenedor específico definido en la configuración - `--rm` remueve el contenedor - - `app` es el nombre del servicio/applicación + - `app` es el nombre del servicio/aplicación - `sh -c` pasa una orden a la shell del container - `"python manage.py ..."` comando a correr dentro del contenedor @@ -163,7 +163,7 @@ docker-compose build ### Testing - Django test suite -- Configurar test por cada applicación Django +- Configurar test por cada aplicación Django - Correr a travez de docker-compose `docker-compose run --rm app sh -c "python manage.py test"` @@ -213,7 +213,7 @@ TG ==> JB ==> RS - Se cobra por minutos de uso - 2.000 minutos *gratis* -#### Configranción GitHub Actions +#### Configuranción GitHub Actions - Creación de archivo [`checks.yml`](./.github/workflows/checks.yml) - Set Trigger @@ -226,3 +226,582 @@ TG ==> JB ==> RS - GitHub Actions usan IP compartida, la limitación aplica para todos los usuarios al autenticar con DockerHub se obtienen 200/6h de uso exclusivo +## Django Tests + +- Basado en la biblioteca **unittest** +- Caracteristicas añadidas de Django + - Cliente del pruebas *dummy web browser* + - Simula autenticación + - Base de datos temporal +- Caracteristicas añadidas de REST Framework + - Cliente de pruebas de la API + +### ¿Donde van los tests? + +- `test.py` por aplicación +- O crear un subdirectorio `tests/` para dividir las pruebas +- Recordar + - Solo usar `tests.py` o directorio `tests/`, no ambos + - Los moduloes de prueba deben comenzar con `test_` + - Los directorios de pruebas deben contener el archivo `__init__.py` + +```txt +demo_code +├── app_one +│ ├── __init__.py +│ ├── admin.py +│ ├── apps.py +│ ├── models.py +│ ├── tests.py +│ ├── views.py +│ └── admin.py +└── app_two + ├── tests + │ ├── __init__.py + │ ├── test_module_one.py + │ └── test_module_two.py + ├── __init__.py + ├── admin.py + ├── apps.py + ├── models.py + ├── views.py + └── admin.py +``` + +### Test DB + +- Codigo de pruebas que usa la base de datos +- Base de datos específica para pruebas + +```mermaid +%%{init: {'theme': 'dark','themeVariables': {'clusterBkg': '#2b2f38'}, 'flowchart': {'curve': 'natural'}}}%% +flowchart +subgraph " " +direction LR +RT["Runs Tests"] +CD["Clears data"] +end +RT ==> CD ==> RT +``` + +- Por defecto, esto ocurre para cada test + +### Clases de Test + +- `SimpleTestCase` + - Sin integración con BD + - Util si no se require una BD para la lógica a probar + - Ahorra tiempo de ejecución +- `TestCase` + - Integración con BD + - Util para probar código que usa la BD + +#### Ej. test + +```py +"""" +Unit test for views +"""" +from django.test import SimpleTestCase +form app_two import views + +class ViewsTests(SimpleTestCase): + + def test_make_list_unique(self): + """ Test making a list unique. """ + sample_items = [1, 1, 2, 2, 3, 4, 5, 5] + res = views.remove_duplicates(sample_items) + self.assertEqual(res, [1, 2, 3, 4, 5]) +``` + +`python manage.py test` + +## Creación del primer test + +Modulo a testear [`calc.py`](./app/app/calc.py) con [`tests.py`](app/app/tests.py) + +```py +""" +Sample tests +""" +from django.test import SimpleTestCase +from app import calc + +class CalcTests(SimpleTestCase): + """ Test the calc module. """ + + def test_add_numbers(self): + """ Test adding numbers together. """ + res = calc.add(5, 6) + + self.assertEqual(res, 11) + +``` + +### Correr el test + +`docker compose run --rm app sh -c "python manage.py test"` + +```python +Found 1 test(s). +System check identified no issues (0 silenced). +. +---------------------------------------------------------------------- +Ran 1 test in 0.000s + +OK +``` + +### Usando TDD + +1. Crear la prueba para el comportamiento esperado [tests.py](./app/app/tests.py) +2. La prueba debe fallar +3. Crear el código para que el test pase (añadir la funcionalidad) +[calc.py](./app/app/calc.py) + +## Mocking + +- Evita depender de servicios externos, pues estos + - No garantizan disponibilidad + - Pueden hacer que los tests sean impredecibles e incositentes +- Evita consecuencias no intecionadas, por ejm. + - Enviar mails accidentalmente + - Sobrecargar servicios externos + +ej. + +```mermaid +%%{init: {'theme': 'dark','themeVariables': {'clusterBkg': '#2b2f38'}, 'flowchart': {'curve': 'natural'}}}%% +flowchart +subgraph " " +direction LR +RU["register_user()"] +CIDB["create_in_db()"] +SWE["send_welcome_email()"] +MS[e-mail sent] + +end +RU --> CIDB --> SWE --x MS +``` + +- Previene el envio del correo electónico +- Asegura que `send_welcome_email()` es llamado correctamente + +### Otro beneficio de Mocking + +- Acelera las pruebas + +```mermaid +%%{init: {'theme': 'dark','themeVariables': {'clusterBkg': '#2b2f38'}, 'flowchart': {'curve': 'natural'}}}%% +flowchart +subgraph " " +direction LR +CDB["check_db()"] +SLP["sleep()"] + +end +CDB --x SLP --> CDB +``` + +### Como usar mock + +- Se usa `unittest.mock` + - `MagicMock/Mock` reemplaza objetos reales + - `patch` sobreescribe el código de las pruebas + + +## Testing Web Request + +Probando la API + +- Hacer peticiones reales +- Comprobar resultados + +Django REST Framework provee un cliente para la API basado en Django `TestClient`, +este realiza los requests y permite verificar resultados. Incluso permite +sobreescribir la autenticación, para probar la funcionalidad de la API, haciendo +que esta asuma que se esta autentificado. + +```py +from django.test import SimpleTestCase +from rest_framework.test import APIClient + +class TestViews(SimpleTestCase): + + def test_get_greetings(self): + """ Test getting greetings. """ + client = APIClient() + res = client.get('/greetings/') + + self.assertEqual(res.status_code, 200) + self.assertEqual( + res.data, + ["Hello!", "Bonjour!", "Hola!"], + ) +``` + +### Problemas comunes en las pruebas + +#### El test no se ejecuta + + ```py + System check identified no issues (0 silenced). + + ---------------------------------------------------------------------- + Ran 0 test in 0.000s + + OK + ``` + +- Se ejecutan menos tests que la cantidad creada + +**Razones posibles** + +- Falta el archivo `__init__.py` en el directorio `tests.py` +- Indentación de los casos de prueba +- Prefijo `test` faltante en los metodos `test_some_function(self):` + +#### `ImportError` al correr las pruebas + + ```py + raise ImportError( + ImportError: 'tests' module incorrectly imported from .... + Expected ....Is this module globally installed? + ) + ``` + +**Razon posible** + +- Existencia de `tests/` y `tests.py` en la misma aplicación + +## Configurando la base de datos + +PostgreSQL se integra bien con django, es oficialmente soportada. + +Se utiliza *Docker compose*, se define la configuración dentro del proyecto +para que sea reutilizable. Datos persistentes utilizando *volumes*. Maneja la +configuración de la red. Configuranción usando variables de entorno. + +### Arquitectura + +```mermaid +%%{init: {'theme': 'dark','themeVariables': {'clusterBkg': '#2b2f38'}, 'flowchart': {'curve': 'basis'}}}%% +flowchart +direction TB +subgraph "Docker Compose" +DB[(PostgreSQL)] +direction LR +APP("App +Django") +DB <-..-> APP +end +``` + +### Conectividad de red + +```yml +services: + app: + depends_on: + - db + + db: + image: postgres:16-alpine +``` + +- Establecer `depends_on` para iniciar `db` primero +- El servicio `app` puede usar el hostname de `db` + +### Volumes + +- Persistencia de datos +- Mapea el directorio del contenedeor con la máquina local + +```yml + db: + image: postgres:16-alphine + volumes: + - dev-db-data:/var/lib/postgresql/data + +volumes: + dev-db-data: + dev-static-data: +``` + +### Agregando el servicio base de datos + +[docker-compose.yml](./docker-compose.yml) + +```yml +services: + app: + build: + context: . + args: + - DEV=true + ports: + - "8000:8000" + volumes: + - ./app:/app + command: > + sh -c "python manage.py runserver 0.0.0.0:8000" + environment: + - DB_HOST=db + - DB_NAME=devdb + - DB_USER=devuser + - DB_PASS=changeme + depends_on: + - db + + db: + image: postgres:16-alpine + volumes: + - dev-db-data:/var/lib/postgresql/data + environment: + - POSTGRES_DB=devdb + - POSTGRES_USER=devuser + - POSTGRES_PASSWORD=changeme + + +volumes: + dev-db-data: +``` + +Probar que todo funcione correctamente `docker compose up` + +### Configuración de la BD en Django + +- Especificar como django conecta con la BD [`settings.py`](./app/app/settings.py) + - Engine (tipo de BD) + - Hostname (DB IP o dominio) + - Port + - DB name + - DB user + - DB password +- Instalar las dependencias del conector +- Actualizar los requerimientos + +```py +DATABASES = { + 'default': { + 'ENGINE' django.db.backends.postgresql', + 'HOST': os.environ.get('DB_HOST'), + 'NAME': os.environ.get('DB_NAME'), + 'USER': os.environ.get('DB_USER'), + 'PASSWORD': os.environ.get('DB_PASSWORD'), + } +} +``` + +### Psycopg2 + +Packete necesario para conectar Django con la base de datos PostgreSQL, es el +conector mas popular en Python y es soportado por Django. Se puede instalar de +las siguientes maneras: + +- `psycopg2-binary` (Ok para desarrollo, no para producción) +- `psycopg2` (se compila desde el código fuente, hay que satisfacer +dependencias para esto) +- Fácil de instalar con Docker + +Lista de dependencias + +- Compilador C +- python3-dev +- libpq-dev + +Equivalentes para Apine + +- postgresql-client +- build-base +- postgresql-dev +- musl-dev + +Equivalentes para Debian (python:3.12.0-slim-bookworm) + +- postgresql-client +- python3-dev +- libpq-dev +- gcc *confimar* +- python3-psycopg2 *confirmar* + +Una **buena practica** es limpiar las dependencias que ya no serán necesarias + +### Previniendo Race Condition con docker compose + +Usar `depends_on` asegura que el `service` comience *(no asegura que la app +este corriendo)* + +### Docker services timeline + +![img](./imgs_readme/docker_services_timeline1.png) + +La **solución** es hace que Django espere a la base de datos `db`. Este chequea +la disponibilidad de la base de datos y continua cuando esta disponible + +![img](./imgs_readme/docker_services_timeline2.png) + +Comando personalizado de administración de Django en app **core** + +## Creación de aplicación core + +`docker compose run --rm app sh -c "python manage.py startapp core"` + +Se eliminal el archvo `tests.py` para crear y usar el directorio `app/core/tests/` +donde se agrega el correspondiente archivo `__init__.py` + +Se crean los directorios `app/core/management/commands/` con los archivos +`__init__.py` y `commands.py` + +```py +""" +Django command to wait for the DB to be available. +""" +import time + +from psycopg2 import OperationalError as Psycopg2OpError + +from django.db.utils import OperationalError +from django.core.management.base import BaseCommand + +class Command(BaseCommand): + """ Django commando to wait for database. """ + + def handle(self, *args, **options): + """Entrypoint for command.""" + self.stdout.write('Waiting for database...') + db_up = False + while db_up is False: + try: + self.check(databases=['default']) + db_up = True + except (Psycopg2OpError, OperationalError): + self.stdout.write('Database unavailable, waiting 1 second...') + time.sleep(1) + + self.stdout.write(self.style.SUCCESS('Database available!')) +``` + +### Correr tests + +`docker compose run --rm app sh -c "python manage.py test"` + +```sh +[+] Creating 1/0 + ✔ Container django_rest_api_udemy-db-1 Running 0.0s +Found 4 test(s). +System check identified no issues (0 silenced). +..Waiting for database... +Database unavailable, waiting 1 second... +Database unavailable, waiting 1 second... +Database unavailable, waiting 1 second... +Database unavailable, waiting 1 second... +Database unavailable, waiting 1 second... +Database available! +.Waiting for database... +Database available! +. +---------------------------------------------------------------------- +Ran 4 tests in 0.005s + +OK +``` + +### Correr command + +`docker compose run --rm app sh -c "python manage.py wait_for_db"` + +```sh +[+] Creating 1/0 + ✔ Container django_rest_api_udemy-db-1 Running 0.0s +Waiting for database... +Database available! +``` + +Correr linter `docker compose run --rm app sh -c "flake8"` y corregir + +## Migraciones de la base de datos + +### Django ORM + +- Object Reational Mapper (ORM) +- Capa de abstracción para los datos + - Django maneja la estructura y cambios de la BD + - Ayuda a enfocarse en el código de Python + - Permite usar otras bases de datos + +```mermaid +%%{init: {'theme': 'dark','themeVariables': {'clusterBkg': '#2b2f38'}, 'flowchart': {'curve': 'basis'}}}%% +flowchart +subgraph "ORM" +DM["Define models"] +GMF["Generate +migration files"] +SD["Setup database"] +STD["Store data"] +DM -.-> GMF -.-> SD -.-> STD +end +``` + +### Models + +- Cada modelo mapea a una tabla +- Los modelos contienen + - Nombre + - Campos + - Otra metadata + - Lógica en Python personalizada + +Modelo ejemplo + +```py +class Ingredient(models.Model): + """Ingredient for recipies.""" + name = models.CharField(max_length=255) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + ) +``` + +### Creación de las migraciones + +- Asegura que la app esta activa en `settings.py` +- Se utiliza el CLI de DJango `python manage.py makemigrations` +- Aplicar migraciones `python manage.py makemigrations` +- Correr despues de esperar por la base de datos + +## Actualización de Docker Compose y CI/CD + +```yml + ... + command: > + sh -c "python manage.py wait_for_db && + python manage.py migrate && + python manage.py runserver 0.0.0.0:8000" + ... +``` + +`docker compose down` + +```sh +[+] Running 3/3 + ✔ Container django_rest_api_udemy-app-1 Removed 0.0s + ✔ Container django_rest_api_udemy-db-1 Removed 0.1s + ✔ Network django_rest_api_udemy_default Removed 0.1s +``` + +`docker compose up` + +#### Corriendo `wait_for_db` antes de los tests en GitHub Actions + +[checks.yml](./.github/workflows/checks.yml) + +```yml + ... + - name: Test + run: > + docker compose run --rm app sh -c "python manage.py wait_for_db && + python manage.py test" + ... +``` diff --git a/app/app/calc.py b/app/app/calc.py new file mode 100644 index 0000000..9d3f2d6 --- /dev/null +++ b/app/app/calc.py @@ -0,0 +1,13 @@ +""" +Calculator functions +""" + + +def add(x, y): + """ Add x and y and return result. """ + return x + y + + +def subtract(x, y): + """ Subtract x from y and return resutl. """ + return x - y diff --git a/app/app/settings.py b/app/app/settings.py index f96fa36..43a7bec 100644 --- a/app/app/settings.py +++ b/app/app/settings.py @@ -9,7 +9,7 @@ https://docs.djangoproject.com/en/4.2/topics/settings/ For the full list of settings and their values, see https://docs.djangoproject.com/en/4.2/ref/settings/ """ - +import os from pathlib import Path # Build paths inside the project like this: BASE_DIR / 'subdir'. @@ -37,6 +37,7 @@ INSTALLED_APPS = [ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'core', ] MIDDLEWARE = [ @@ -75,8 +76,11 @@ WSGI_APPLICATION = 'app.wsgi.application' DATABASES = { 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', + 'ENGINE': 'django.db.backends.postgresql', + 'HOST': os.environ.get('DB_HOST'), + 'NAME': os.environ.get('DB_NAME'), + 'USER': os.environ.get('DB_USER'), + 'PASSWORD': os.environ.get('DB_PASS'), } } diff --git a/app/app/tests.py b/app/app/tests.py new file mode 100644 index 0000000..e408f8f --- /dev/null +++ b/app/app/tests.py @@ -0,0 +1,21 @@ +""" +Sample tests +""" +from django.test import SimpleTestCase +from app import calc + + +class CalcTests(SimpleTestCase): + """ Test the calc module. """ + + def test_add_numbers(self): + """ Test adding numbers together. """ + res = calc.add(5, 6) + + self.assertEqual(res, 11) + + def test_subtract_numbers(self): + """ Test subtracting numbers. """ + res = calc.subtract(10, 5) + + self.assertEqual(res, 5) diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/admin.py b/app/core/admin.py new file mode 100644 index 0000000..6af52da --- /dev/null +++ b/app/core/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin # noqa + +# Register your models here. diff --git a/app/core/apps.py b/app/core/apps.py new file mode 100644 index 0000000..8115ae6 --- /dev/null +++ b/app/core/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'core' diff --git a/app/core/management/commands/__init__.py b/app/core/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/management/commands/wait_for_db.py b/app/core/management/commands/wait_for_db.py new file mode 100644 index 0000000..6db1f92 --- /dev/null +++ b/app/core/management/commands/wait_for_db.py @@ -0,0 +1,27 @@ +""" +Django command to wait for the DB to be available. +""" +import time + +from psycopg2 import OperationalError as Psycopg2OpError + +from django.db.utils import OperationalError +from django.core.management.base import BaseCommand + + +class Command(BaseCommand): + """ Django commando to wait for database. """ + + def handle(self, *args, **options): + """Entrypoint for command.""" + self.stdout.write('Waiting for database...') + db_up = False + while db_up is False: + try: + self.check(databases=['default']) + db_up = True + except (Psycopg2OpError, OperationalError): + self.stdout.write('Database unavailable, waiting 1 second...') + time.sleep(1) + + self.stdout.write(self.style.SUCCESS('Database available!')) diff --git a/app/core/migrations/__init__.py b/app/core/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/models.py b/app/core/models.py new file mode 100644 index 0000000..9d57c55 --- /dev/null +++ b/app/core/models.py @@ -0,0 +1,3 @@ +from django.db import models # noqa + +# Create your models here. diff --git a/app/core/tests/__init__.py b/app/core/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/tests/test_commands.py b/app/core/tests/test_commands.py new file mode 100644 index 0000000..db941da --- /dev/null +++ b/app/core/tests/test_commands.py @@ -0,0 +1,35 @@ +""" +Test custom Django management commands. +""" +from unittest.mock import patch + +from psycopg2 import OperationalError as Psycopg2OpError + +from django.core.management import call_command +from django.db.utils import OperationalError +from django.test import SimpleTestCase + + +@patch('core.management.commands.wait_for_db.Command.check') +class CommandTests(SimpleTestCase): + """Test Comands.""" + + def test_wait_for_db_ready(self, patched_check): + """Test waiting for database if database ready.""" + patched_check.return_value = True + + call_command('wait_for_db') + + patched_check.assert_called_once_with(databases=['default']) + + # ojo con el orden de los parametros "desde dentro hacia fuera" + @patch('time.sleep') + def test_wait_for_db_delay(self, patched_sleep, patched_check): + """Test waiting for database when getting OperationalError.""" + patched_check.side_effect = [Psycopg2OpError] * 2 + \ + [OperationalError] * 3 + [True] + + call_command('wait_for_db') + + self.assertEqual(patched_check.call_count, 6) + patched_check.assert_called_with(databases=['default']) diff --git a/docker-compose.yml b/docker-compose.yml index cacfcfb..8a89582 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,9 +4,33 @@ services: context: . args: - DEV=true + - BUILDKIT_PROGRESS=plain docker compose build ports: - "8000:8000" volumes: - ./app:/app command: > - sh -c "python manage.py runserver 0.0.0.0:8000" + sh -c "python manage.py wait_for_db && + python manage.py migrate && + python manage.py runserver 0.0.0.0:8000" + environment: + - DB_HOST=db + - DB_NAME=devdb + - DB_USER=devuser + - DB_PASS=changeme + depends_on: + - db + + db: + image: postgres:16-alpine + volumes: + - dev-db-data:/var/lib/postgresql/data + environment: + - POSTGRES_DB=devdb + - POSTGRES_USER=devuser + - POSTGRES_PASSWORD=changeme + + +volumes: + dev-db-data: + diff --git a/imgs_readme/docker_services_timeline1.png b/imgs_readme/docker_services_timeline1.png new file mode 100644 index 0000000..948db83 Binary files /dev/null and b/imgs_readme/docker_services_timeline1.png differ diff --git a/imgs_readme/docker_services_timeline2.png b/imgs_readme/docker_services_timeline2.png new file mode 100644 index 0000000..b3f9e03 Binary files /dev/null and b/imgs_readme/docker_services_timeline2.png differ diff --git a/requirements.txt b/requirements.txt index f1b3359..e40fb74 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ Django==4.2.5 djangorestframework==3.14.0 +psycopg2>=2.9.9