docker, test, db, wait_for_db, db conf, core app

This commit is contained in:
devfzn 2023-10-05 22:48:42 -03:00
parent f8e0559424
commit ca9ca658c7
Signed by: devfzn
GPG Key ID: E070ECF4A754FDB1
19 changed files with 730 additions and 8 deletions

View File

@ -16,6 +16,7 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: Test - 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 - name: Lint
run: docker compose run --rm app sh -c "flake8" run: docker compose run --rm app sh -c "flake8"

View File

@ -10,7 +10,12 @@ WORKDIR /app
EXPOSE 8000 EXPOSE 8000
ARG DEV=false ARG DEV=false
# apt install -y python3-dev libpq-dev python3-psycopg2 && \
RUN python -m venv /py && \ 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 --upgrade pip && \
/py/bin/pip install -r /tmp/requirements.txt && \ /py/bin/pip install -r /tmp/requirements.txt && \
if [ $DEV = "true" ]; \ if [ $DEV = "true" ]; \

585
README.md
View File

@ -134,7 +134,7 @@ ej. `docker-compose run --rm app sh -c "python manage.py collectstatic"`
- `docker-compose` Ejecuta un comando de Docker Compose - `docker-compose` Ejecuta un comando de Docker Compose
- `run` comienza un contenedor específico definido en la configuración - `run` comienza un contenedor específico definido en la configuración
- `--rm` remueve el contenedor - `--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 - `sh -c` pasa una orden a la shell del container
- `"python manage.py ..."` comando a correr dentro del contenedor - `"python manage.py ..."` comando a correr dentro del contenedor
@ -163,7 +163,7 @@ docker-compose build
### Testing ### Testing
- Django test suite - 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 - Correr a travez de docker-compose `docker-compose run --rm app sh -c "python
manage.py test"` manage.py test"`
@ -213,7 +213,7 @@ TG ==> JB ==> RS
- Se cobra por minutos de uso - Se cobra por minutos de uso
- 2.000 minutos *gratis* - 2.000 minutos *gratis*
#### Configranción GitHub Actions #### Configuranción GitHub Actions
- Creación de archivo [`checks.yml`](./.github/workflows/checks.yml) - Creación de archivo [`checks.yml`](./.github/workflows/checks.yml)
- Set Trigger - Set Trigger
@ -226,3 +226,582 @@ TG ==> JB ==> RS
- GitHub Actions usan IP compartida, la limitación aplica para todos los usuarios - GitHub Actions usan IP compartida, la limitación aplica para todos los usuarios
al autenticar con DockerHub se obtienen 200/6h de uso exclusivo 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["<b>Runs Tests</b>"]
CD["<b>Clears data</b>"]
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["<b>register_user()</b>"]
CIDB["<b>create_in_db()</b>"]
SWE["<b>send_welcome_email()</b>"]
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["<b>check_db()</b>"]
SLP["<b>sleep()</b>"]
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 "<b>Docker Compose</b>"
DB[(PostgreSQL)]
direction LR
APP("<b>App</b>
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"
...
```

13
app/app/calc.py Normal file
View File

@ -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

View File

@ -9,7 +9,7 @@ https://docs.djangoproject.com/en/4.2/topics/settings/
For the full list of settings and their values, see For the full list of settings and their values, see
https://docs.djangoproject.com/en/4.2/ref/settings/ https://docs.djangoproject.com/en/4.2/ref/settings/
""" """
import os
from pathlib import Path from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
@ -37,6 +37,7 @@ INSTALLED_APPS = [
'django.contrib.sessions', 'django.contrib.sessions',
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'core',
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@ -75,8 +76,11 @@ WSGI_APPLICATION = 'app.wsgi.application'
DATABASES = { DATABASES = {
'default': { 'default': {
'ENGINE': 'django.db.backends.sqlite3', 'ENGINE': 'django.db.backends.postgresql',
'NAME': BASE_DIR / 'db.sqlite3', 'HOST': os.environ.get('DB_HOST'),
'NAME': os.environ.get('DB_NAME'),
'USER': os.environ.get('DB_USER'),
'PASSWORD': os.environ.get('DB_PASS'),
} }
} }

21
app/app/tests.py Normal file
View File

@ -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)

0
app/core/__init__.py Normal file
View File

3
app/core/admin.py Normal file
View File

@ -0,0 +1,3 @@
from django.contrib import admin # noqa
# Register your models here.

6
app/core/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'core'

View File

View File

@ -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!'))

View File

3
app/core/models.py Normal file
View File

@ -0,0 +1,3 @@
from django.db import models # noqa
# Create your models here.

View File

View File

@ -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'])

View File

@ -4,9 +4,33 @@ services:
context: . context: .
args: args:
- DEV=true - DEV=true
- BUILDKIT_PROGRESS=plain docker compose build
ports: ports:
- "8000:8000" - "8000:8000"
volumes: volumes:
- ./app:/app - ./app:/app
command: > 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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

View File

@ -1,2 +1,3 @@
Django==4.2.5 Django==4.2.5
djangorestframework==3.14.0 djangorestframework==3.14.0
psycopg2>=2.9.9