Blog de Amazon Web Services (AWS)

Test Driven Development aplicado al desarrollo de infraestructura como código con AWS CDK Python

Por Gabriel Paredes, Arquitecto de Soluciones Senior para Sector Público en AWS.

 

Test Driven Development (TDD) es una metodología de diseño de software enfocada en generar confianza en el código de forma incremental y progresiva. Es importante diferenciar que más allá de formular casos de pruebas unitarias (Unittests), la metodología de TDD refuerza la lógica iterativa de diseño, la cual parte por formular primero una prueba sencilla, luego desarrollar el mínimo código que satisfaga la condición de éxito y finalmente refinar la implementación.

En la actualidad no solamente las aplicaciones se definen en código; la infraestructura que la soporta también cuenta con la capacidad de ser modelada como código (AWS CloudFormation, AWS CDK, Terraform, Pulumi, entre otros). Alineado con el pilar de eficiencia operacional del AWS Well-Architected Framework, se recomienda contar con un mecanismo de Infraestructura como Código (IaC) que permita el despliegue de los servicios de forma automatizada, controlada, predecible y versionada en el tiempo.

En el siguiente blog post abordaremos la aplicación de TDD para generar confianza, calidad y minimizar errores en el desarrollo de Infraestructura como código (IaC) a través del AWS Cloud Development Kit (AWS CDK) Python en una demostración de concepto.

TDD a 10.000 pies de altura

Aplicar TDD consta de tres (3) pasos fundamentales (Imagen 1), que suelen representan un cambio al flujo tradicional de desarrollo (Primero el código de aplicación y luego los casos de prueba). Con TDD se inicia pensando primero en el caso de prueba y luego en el código, esto permita avanzar con la certeza que el código cumple con las condiciones de calidad desde el inicio y a medida que se agregan funcionalidades.

1.       Etapa “Roja”: se plantea abordar el problema de negocio que se desea resolver por escribir una prueba unitaria de código en su mínima expresión que resulte en una condición de falla.

2.       Etapa “Verde”: se plantea escribir la mínima unidad de código de aplicación o incluir el mínimo cambio requerido para hacer cumplir la condición de prueba y obtener una prueba positiva

3.       Etapa “Azul”: se plantea refactorizar y refinar el código de la aplicación y/o del caso de prueba, con el objetivo de eliminar duplicados, optimizar las llamadas entre funciones. Por último, se espera obtener un caso positivo de prueba luego de los cambios incluidos.

Imagen 1: Flujo de implementación de TDD

Imagen 1: Flujo de implementación de TDD

Introducción a AWS CDK

AWS CDK acelera el desarrollo y despliegue de servicios en AWS mediante el uso de lenguajes de programación comunes para modelar sus aplicaciones. Este enfoque permite desarrollar constructos de alto nivel que proporcionen valor y agilizando la definición de infraestructura con menos código, permite el uso de expresiones y condicionales nativos de los lenguajes de programación. Al unificar el código de la aplicación y el código de la infraestructura (IaC) se logra tener bloques de despliegue uniformes y consistentes.

Imagen 2: Estructura lógica de AWS CDK

Imagen 2: Estructura lógica de AWS CDK

El framework (Imagen 2) está compuesto de constructos, que son la representación lógica de un servicio de AWS o grupos de servicios coordinados para lograr un objetivo. Los constructos hacen parte de Stacks de despliegues, con conforman unidades únicas de creación, modificación y terminación de recursos en combinaciones un o múltiples cuentas de AWS y/o Regiones. Los Stacks son agrupados lógicamente en aplicaciones, que definen el alcance de despliegue

El CLI de AWS CDK sintetiza el código que compone la aplicación y genera automaticamente plantillas de AWS CloudFormation, es decir, se traduce el código de aplicación de alto nivel, las dependencias entre recursos, permisos granulares (IAM Policy & IAM Roles) en definiciones de infraestructura JSON o YAML en AWS CloudFormation.

AWS CloudFormation es utilizado como el mecanismo de despliegue, creación, modificación (ChangeSets) y terminación de la infraestructura.

Liberia de AWS CDK Assertions

AWS CDK cuenta con la Liberia CDK Assertions que facilita utilizar prácticas de ingeniería de software, como revisiones de código, pruebas unitarias sobre aplicaciones de AWS CDK con foco en plantillas de AWS CloudFormation.

Desde la perspectiva de definición de las pruebas unitarias, es importante destacar que la librería de AWS CDK Assertions las realiza contra la plantilla resultante de AWS CloudFormation resultante del proceso de síntesis. Por ende, estaremos buscando encontrar coincidencias de patrones en estructuras de datos anidados en forma de JSON. Estos son alguno de los casos de coincidencia comúnmente utilizados:

  •  Full Template Match: Realiza la aserción que la plantilla y recursos resultante hace match con una estructura de datos dada.
  • Counting Resources: Realiza la aserción de que en la plantilla resultante existe una cantidad especificada de tipos de recursos (AWS Resource Types).
  • Resource Matching: Realiza la aserción de que un recurso definido en la plantilla resultante cuenta con una o varias propiedades específicas.

CDK Assertions cuenta con otros mecanismos adicionales, para capturar campos de la plantilla y aplicar lógica de comparación directamente en el lenguaje de programación, validar estructuras de objetos y arrays, así como validar condiciones especiales de presencia y ausencia de parámetros. El detalle de estos se escapa del alcance del presente blog post, sin embargo, están documentados con ejemplos.

Arquitectura y objetivo de la demostración

A continuación, realizaremos la creación y despliegue de una aplicación Serverless que permite crear y consultar registros de usuarios. Para lograr esto, utilizaremos Amazon DynamoDB como base de datos, AWS Lambda para implementar la lógica de aplicación, así como la funcionalidad de AWS Lambda HTTPS Endpoints para exponer el API de consulta.

Imagen 3: Diagrama de arquitectura

Imagen 3: Diagrama de arquitectura

Como ya se ha mencionado, utilizaremos AWS CDK Python para definir e implementar la infraestructura de servicios AWS. Así como, el Framework de Pytest para definir los casos de prueba de la mano con la metodología de TDD.

Prerrequisitos

  • Python 3.9+
  • Pytest
  • AWS CDK 2.75+
  • Crear un proyecto nuevo de CDK mediante CLI en un directorio vacío
cdk init app --language=python
  • Activar entorno virtual de Python creado por el CDK en el CLI
source .venv/bin/activate
  • Instalar las dependencias en el entorno virtual de trabajo:
pip install -r requirements.txt
pip install pytest

Iteración – 1

Iniciaremos creando una prueba unitaria que valida la existencia de una (1) tabla de Amazon DynamoDB en nuestro template resultante. Por defecto al crear un nuevo proyecto de AWS CDK será aprovisionado una carpeta “tests” la cual utilizaremos para ubicar el código de las pruebas en la ruta tdd-for-cdk-python-blogpost > tests > unit > test_tdd_for_cdk_python_blogpost_stack.py.

Iteración 1: Etapa Roja – Escribir una prueba que falle

Iniciaremos implementando una condición de conteo de recursos mediante Resource Count.

# test_tdd_for_cdk_python_blogpost_stack.py
from aws_cdk import Stack, assertions
from backend_event_proccesor.backend import BackendEventProcessor

def test_dynamodb_table():
    # Given
    test_stack = Stack()
    backend_event_processor = BackendEventProcessor(test_stack, "BackendEventProcessor")

    # When Template Syncs
    template = assertions.Template.from_stack(test_stack)

    # Expect
    template.resource_count_is("AWS::DynamoDB::Table", 1)

Como esperamos, será una condición de error dado que no hemos creado la definición de infraestructura para la tabla de Amazon DynamoDB requerida para satisfacer aserción.

Imagen 4: Resultado de pytest en la iteración 1 – Etapa Roja

Imagen 4: Resultado de pytest en la iteración 1 – Etapa Roja

Iteración 1: Etapa Verde – Pasar la prueba

Para lograr pasar la prueba, crearemos el directorio “backend_event_processor” en la raíz del proyecto, así como el archivo “backend.py” que contendrá la definición de una Tabla de DynamoDB en su mínima expresión mediante CDK:

# tdd-for-cdk-python-blogpost > backend_event_processor > backend.py
from aws_cdk import aws_dynamodb as ddb
from constructs import Construct

class BackendEventProcessor(Construct):

    def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
        super().__init__(scope, construct_id, **kwargs)

        ddb_table = ddb.Table(self, "ddb_table",
                              partition_key=ddb.Attribute(name="PK",
type=ddb.AttributeType.STRING))

Al ejecutar nuevamente la prueba unitaria veremos que resultado es exitoso, la plantilla resultante del proceso de síntesis contiene una (1) tabla de Amazon DynamoDB.

Imagen 5: Resultado de pytest en la iteración 1 – Etapa Verde

Imagen 5: Resultado de pytest en la iteración 1 – Etapa Verde

Iteración 1: Etapa Azul – Refactorizar el código

Continuaremos a refactorizar el código para optimizar la condición de prueba y el código que define la infraestructura. Implementaremos dos cambios. Incluiremos la funcionalidad de pytest fixture para reutilizar el código de síntesis de la plantilla.

El segundo, a fines de la demostración que estamos realizando, se fijará la Deletion Policy en Destroy para la tabla de Amazon DynamoDB (al finalizar la demostración y eliminemos los recursos). Implementaremos un Full Template Match dado que el DeletionPolicy hace parte del template y no es una propiedad del recurso.

# test_tdd_for_cdk_python_blogpost_stack.py
from aws_cdk import Stack, assertions
from backend_event_processsor.backend import BackendEventProcessor
from pytest import fixture, Mark

@fixture(scope='session')
def template():
    # Given
    test_stack = Stack()
    backend_event_processor = BackendEventProcessor(test_stack, "BackendEventProcessor")

    # When Template Syncs
    template = assertions.Template.from_stack(test_stack)

    return template

def test_dynamodb_table(template):
    # Expect
    template.resource_count_is("AWS::DynamoDB::Table", 1)
    template.has_resource("AWS::DynamoDB::Table", {
                            'DeletionPolicy': 'Delete'})

Para hacer cumplir las condiciones de prueba, realizaremos la siguiente modificación en la definición de la tabla de Amazon DynamoDB como se observa en el siguiente snippet de código.

# tdd-for-cdk-python-blogpost > backend_event_processor > backend.py
From aws_cdk import RemovalPolicy

# Código omitido por brevedad

        ddb_table = ddb.Table(self, "ddb_table",
                             partition_key=ddb.Attribute(name="PK",
                             type=ddb.AttributeType.STRING),
                             removal_policy=RemovalPolicy.DESTROY)
Imagen 6: Resultado de pytest en la iteración 1 – Etapa Azul

Imagen 6: Resultado de pytest en la iteración 1 – Etapa Azul

Iteración 2

Continuaremos con la definición de la función Lambda que manejará el backend del servicio. El código de la función AWS Lambda también puede ser abordado con una metodología de TDD y realizar pruebas locales mediante el AWS SAM CLI, sin embargo, en el presente blogpost nos enfocaremos en las pruebas desde la perspectiva de la Infraestructura como código (IaC).

Iteración 2: Etapa Roja – Escribir una prueba que falle

Definiremos en la condición de aserción en la plantilla resultante la existencia de un recurso de tipo “AWS::Lambda::Function” con el runtime Python 3.10. Esperando un resultado de falla.

# test_tdd_for_cdk_python_blogpost_stack.py

# Código omitido por brevedad

def test_lambda_fn(template):
    # Expect
    template.has_resource_properties("AWS::Lambda::Function", {
        "Runtime": "python3.10",
        "Handler": "event_processor_fn.lambda_handler"
    })
Imagen 7: Resultado de pytest en la iteración 2 – Etapa Roja

Imagen 7: Resultado de pytest en la iteración 2 – Etapa Roja

Iteración 2: Etapa Verde – Pasar la prueba

Procederemos con la definición de la función Lambda mediante AWS CDK. Así mismo crearemos el directorio que alojará el código de la función en la ruta “assets > backend > event_processor_fn.py. Observaremos que ambas pruebas son exitosas al ejecutarlas nuevamente.

# tdd-for-cdk-python-blogpost > backend_event_processor > backend.py

from aws_cdk import aws_lambda as _lambda

# Código omitido por brevedad

        lambda_fn = _lambda.Function(self, "LambdaFN",
                            runtime=_lambda.Runtime.PYTHON_3_10,
                            handler='event_processor_fn.lambda_handler',
                            code=_lambda.Code.from_asset("./assets/backend"))
Imagen 8: Resultado de pytest en la iteración 2 – Etapa Verde

Imagen 8: Resultado de pytest en la iteración 2 – Etapa Verde

Iteración 2: Etapa Azul – Refactorizar el código

Refinaremos la prueba unitaria para validar que la función Lambda cuente con una variable de entorno “DDB_TABLE_NAME” presente. Para esto, implementaremos la funcionalidad de CDK Assertions de Match Object Like que permite realizar una aserción de un subconjunto de un patrón de una propiedad en la plantilla resultante.

Debido a que los identificadores lógicos son generados por CDK al momento de la síntesis de la plantilla, mediante Match Any Value se validará la presencia de un valor para la llave de variable de entorno.

Igualmente, incluiremos validaremos los permisos de lectura y escritura a la tabla de Amazon DynamoDB del rol de ejecución (IAM Role) de la función Lambda. Adicional a las condiciones de match indicadas anteriormente, usaremos Match Array With para validar un subconjuntos de elementos de un array, en este caso, los valores de dynamodb:GetItem y dynamodb:PutItem.

# test_tdd_for_cdk_python_blogpost_stack.py

# Código omitido por brevedad

def test_lambda_fn(template):
    # Expect
    template.has_resource_properties("AWS::Lambda::Function", {
        "Runtime": "python3.10",
        "Handler": "event_processor_fn.lambda_handler",
        "Environment": {
            "Variables": assertions.Match.object_like({
                "DDB_TABLE_NAME": assertions.Match.any_value()
            })
        }
    })
    # Expect to have Put & Get Items
    template.has_resource_properties("AWS::IAM::Policy", {
        "PolicyDocument": {
            "Statement": [assertions.Match.object_like({
                        "Action": assertions.Match.array_with(["dynamodb:GetItem","dynamodb:PutItem"]),
                        "Effect": "Allow",
                    }
                ),
            ],
        },
    })

Para cumplir las nuevas condiciones de prueba, realizaremos la siguiente modificación en la definición de la función Lambda, como se observa a continuación:

# tdd-for-cdk-python-blogpost > backend_event_processor > backend.py

# Código omitido por brevedad

        lambda_fn = _lambda.Function(self, "LambdaFN",
                            runtime=_lambda.Runtime.PYTHON_3_10,
                            handler='event_processor_fn.lambda_handler',
                            code=_lambda.Code.from_asset("./assets/backend"),
                            environment={
                             "DDB_TABLE_NAME": ddb_table.table_name})

        ddb_table.grant_read_write_data(lambda_fn)
Imagen 9: Resultado de pytest en la iteración 2 – Etapa Azul

Imagen 9: Resultado de pytest en la iteración 2 – Etapa Azul

Iteración 3

Finalmente habilitaremos el endpoint para las llamadas al API, para esto implementaremos la funcionalidad de AWS Lambda Function URL.

Iteración 3: Etapa Roja – Escribir una prueba que falle

# test_tdd_for_cdk_python_blogpost_stack.py

# Código omitido por brevedad

def test_lambda_url_endpoint(template):
    template.has_resource_properties("AWS::Lambda::Url", {
        "TargetFunctionArn": assertions.Match.any_value()})

Iteración 2: Etapa Verde – Pasar la prueba

# tdd-for-cdk-python-blogpost > backend_event_processor > backend.py

# Código omitido por brevedad

        lambda_fn.add_function_url(
           auth_type=_lambda.FunctionUrlAuthType.AWS_IAM)
Imagen 10: Resultado de pytest en la iteración 3 – Etapa Verde

Imagen 10: Resultado de pytest en la iteración 3 – Etapa Verde

Iteración 3: Etapa Azul – Refactorizar el código

Finalmente, agregaremos un Output de CloudFormation con la URL del Endpoint mediante AWS CDK CfnOutput:

# tdd-for-cdk-python-blogpost > backend_event_processor > backend.py

from aws_cdk import CfnOutput

# Código omitido por brevedad

        lambda_url_output = CfnOutput(self, "LambdaUrl",
                                value=lambda_fn_url.url)

En este punto, hemos completado el proceso de desarrollo de la aplicación serverless objetivo siguiendo el flujo local de TDD. En adelante se puede proceder con la creación de los recursos descritos hacia una cuenta de AWS (cdk deploy).

Conclusiones y recomendaciones

Proveer mecanismos de calidad de código durante el ciclo de desarrollo de software es uno de los elementos claves para en el éxito de los proyectos tecnológicos. Trasladar el concepto de TDD a la definición de infraestructura como código permite que los equipos de desarrollo, infraestructura y/o DevOps logren entregar despliegues que cumplan con los estándares de calidad desde su inicio y previo a realizar el despliegue o creación de los recursos.

La guía de documentación de Recursos y sus Propiedades de AWS Cloudformation es un punto clave para observar ejemplos de la estructura, variables y posibles valores que pueden asumir un recurso en una plantilla de despliegue. La funcionalidad de CDK Assertions realiza los match sobre éstas estructuras, y conocer de antemano las propiedades claves, permite ser específicos en la construcción de las condiciones de prueba.

Adicionalmente, la librería de AWS CDK-NAG permite extender el flujo de TDD, incorporando a las pruebas unitarias y calidad con validaciones de mejores prácticas de seguridad desde la construcción de los recursos.

Referencias adicionales (blogs en inglés)

Sobre el autor

Gabriel Paredes es Arquitecto de Soluciones en Amazon Web Services para Sector Público. Gabriel ayuda a múltiples instituciones de educación en Latinoamérica en la adopción tecnológica y mejora de sus servicios estudiantiles.

 

 

Revisor Técnico

Juan Miguel Bermudez Mieles es arquitecto de soluciones en Amazon Web Services para Sector Público. Juan Miguel apoya a distintas entidades e instituciones públicas en Centro América y el Caribe en la adopción de nuevas tecnologías y prácticas que permitan el mejoramiento de sus servicios.