Testing serverless python applications with serverless offline + pytest
Tags: serverless, testing, programming, python
Published: 2024-01-04 14:04:08

Recently, I found myself in a tedious and cumbersome development process that required deploying a serverless framework every time I needed to test my application. Overcoming this with the [serverless-offline plugin](https://www.serverless.com/plugins/serverless-offline) as highlighted in [one of my articles](https://www.linkedin.com/pulse/how-do-you-develop-your-severless-microservices-charles-oraegbu%3FtrackingId=vXNOFC1eRM%252BwC9seb4g28w%253D%253D/?trackingId=vXNOFC1eRM%2BwC9seb4g28w%3D%3D), I decided to find ways to have this incorporated in my testings suite in other to simulate real world events and eventually, applied to my CI/CD pipelines.

Testing is a critical aspect of the software development process, and it serves several important purposes(I had to learn the hard way haha). Writing self sufficient test cases for robust serverless applications, especially when considering integration into CI/CD pipelines, can be a complex and challenging task and this arises more often than not due to the ease with which resources and functions within the serverless application become tightly coupled.

---
**Requirements**

Before we begin, make sure you have the following:

- A simple serverless application to follow with, feel free to use [my simple todo app](https://github.com/charles-co/ms-todo-app) (since that is what I will be using in this tutorial).

**Creating serverless configuration file**

The first step I usually follow is creating a separate serverless configuration file for testing, and this helps to isolate and tailor the configuration specifically for testing purposes. By having a dedicated configuration file, I can customize settings, environment variables, and plugins to optimize the testing environment without impacting the production setup. This separation ensures a streamlined and efficient testing process, allowing for easy adjustments and experimentation without affecting the production deployment.

Below is an example of a _serverless-test.yml_ file:

```
app: 'todo-app'
service: 'todo-app'
frameworkVersion: '3'

provider:
  name: aws
  stage: test
  runtime: python3.11
  timeout: 30
  region: us-east-1
  memorySize: 512
  architecture: arm64
  environment:
    STAGE: ${self:provider.stage}

    TODO_APP_DB: ms-todo-app
    TODO_APP_DB_PK: id
    TODO_APP_DB_SK: updated_at

# Upldoad just essential files to AWS Lambda
package:
  patterns:
    - '!./**'
    - src/**
    - '!src/**/__pycache__/**'
    - 'docs/**'

plugins:
  - serverless-dynamodb
  - serverless-offline

custom:
  dynamodb:
    # If you only want to use DynamoDB Local in some stages, declare them here
    stages:
      - test
    start:
      host: 127.0.0.1
      port: 8001
      inMemory: true
      heapInitial: 200m
      heapMax: 1g
      migrate: true
      seed: true
      convertEmptyValues: true
    # Uncomment only if you already have a DynamoDB running locally
    # noStart: true

# Add functions
functions:
  todo_app_gateway_handler:
    handler: src.main.todo_app_gateway_handler
    description: Todo app system gateway for different funtions
    events:
      - http:
          path: /docs/{path+} # Matches every path under /docs
          method: get
          cors:
            origin: '*'
            headers: '*'

      - http:
          path: '/{path+}' # Matches every path under /
          method: ANY
          cors:
            origin: '*'
            headers: '*'

resources:
  # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/template-reference.html
  Resources:
    TodoAppDynamoDB:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: ${self:provider.environment.TODO_APP_DB}
        AttributeDefinitions:
          - AttributeName: id
            AttributeType: S
        KeySchema:
          - AttributeName: id
            KeyType: HASH
        ProvisionedThroughput:
          ReadCapacityUnits: 1
          WriteCapacityUnits: 1
        BillingMode: 'PROVISIONED'

```

---

**Setting up pytest**

Next, install the required python packages in your desired environment:

`pip install pytest pytest-xprocess pytest-cov`

[pytest-xprocess](https://pypi.org/project/pytest-xprocess/) is a pytest plugin for managing external processes across test runs.

[pytest-cov](https://pypi.org/project/pytest-cov/) is a pytest plugin for producing coverage report.

For ease and to avoid passing arguments through the cli, set up your pytest configuration in the _pyproject.toml_ file like:

```
# ==== pytest ====
[tool.pytest.ini_options]
minversion = "6.0"
addopts = [
    "--junit-xml=./unittests.xml",
    "--cov=./src",
    "--cov-report=term",
    "--cov-report=xml",
    "--cov-branch",
    "-p tests.plugins.env_vars", # injecting environment variables
]
python_files = [
    "tests.py",
    "test_*.py",
]
log_cli = true # set to `false` if you do not want to log output
```

Also environment variables are set in _test.plugins.env_vars_ as thus:

```
import os

import pytest


@pytest.hookimpl(tryfirst=True)
def pytest_load_initial_conftests(args, early_config, parser):
    os.environ["STAGE"] = "test"
    os.environ["TODO_APP_DB"] = "ms-todo-app"
    os.environ["TODO_APP_DB_PK"] = "id"
    os.environ["TODO_APP_DB_SK"] = "updated_at"
```

For generating realistic data I use [python faker library](https://faker.readthedocs.io/en/master/) alongside [polyfactory](https://polyfactory.litestar.dev/) — which works well with [pydantic](https://docs.pydantic.dev/latest/) as that is what is being used for data validation, to generate my data factories. Feel free to check them out.

Lastly and most importantly, creating your _conftest.py_ file to setup fixtures and manage processes.

```
import time
import sys
from pathlib import Path

import pytest
from xprocess import ProcessStarter

from src.config.settings import get_settings
from src.db import DynamoDB
from tests import logger

settings = get_settings()
BASE_DIR = Path(__file__).resolve().parent.parent


@pytest.fixture(scope="session")
def server(xprocess):
    """
    This fixture starts the serverless offline server and dynamodb.
    Also logs the serverless offline server output to the console.
    """

    class Starter(ProcessStarter):
        pattern = "Server ready"
        args = [
            "/bin/bash",
            "-c",
            "cd " + str(BASE_DIR) + " && serverless offline start " + "--config serverless-test.yml",
        ]

    logfile = xprocess.ensure("server", Starter)

    try:
        # Print logs in console
        sys.stderr.flush()
        sys.stdin.flush()
    except Exception:
        ...
    time.sleep(3)

    yield
    with open(str(logfile[1]), "r") as f:
        logger.info(f.read())

    xprocess.getinfo("server").terminate()


@pytest.fixture(scope="module")
def dynamodb(xprocess) -> DynamoDB:
    """
    This fixture starts the dynamodb server only and logs the output to the console.
    Scope is set to `module` to avoid db clashing with server db - so db only test cases are placed in the same module.
    """

    class Starter(ProcessStarter):
        pattern = "DynamoDB - created"
        args = [
            "/bin/bash",
            "-c",
            "cd " + str(BASE_DIR) + " && serverless dynamodb start --migrate --config serverless-test.yml --sharedDb",
        ]

    logfile = xprocess.ensure("dynamodb", Starter)

    try:
        # Print logs in console
        sys.stderr.flush()
        sys.stdin.flush()
    except Exception:
        ...

    time.sleep(3)
    yield
    with open(str(logfile[1]), "r") as f:
        logger.info(f.read())

    xprocess.getinfo("dynamodb").terminate()


def create_todo_app_db(db: DynamoDB):
    """
    This function creates the todo app database.
    """
    return db.dynamodb_client.create_table(
        AttributeDefinitions=[
            {"AttributeName": "id", "AttributeType": "S"},
            {"AttributeName": "updated_at", "AttributeType": "S"},
        ],
        KeySchema=[
            {"AttributeName": "id", "KeyType": "HASH"},
            {"AttributeName": "updated_at", "KeyType": "RANGE"},
        ],
        TableName=db._table.name,
        BillingMode="PAY_PER_REQUEST",
    )


@pytest.fixture(scope="function")
def clear_db():
    """
    This fixture helps in resetting the database after each test by deleting the table and recreating it.
    """
    yield
    dbs = [
        DynamoDB(settings.TODO_APP_DB),
    ]
    for db in dbs:
        db.dynamodb_client.delete_table(TableName=db._table.name)
        match db._table.name:
            case settings.TODO_APP_DB:
                create_todo_app_db(db)
            case _:
                raise ValueError(f"Invalid table name: {db._table.name}")
        time.sleep(1)
```

---

**Writing test cases**

Below is a test case to get a todo item from the database:

```
import requests

from src.config.settings import get_settings
from src.db import DynamoDB
from tests.factories import TodoDBSchemaFactory

settings = get_settings()
dynamodb = DynamoDB(table_name=settings.TODO_APP_DB)
BASE_URL = "http://localhost:3000"


def test_get_todo(server, clear_db):
    # Create a todo
    path = "/test/todos/{id}"
    todo = TodoDBSchemaFactory.build()
    dynamodb.put_item(todo.model_dump())

    resp = requests.get(BASE_URL + path.format(id=todo.id))
    assert resp.status_code == 200
    assert resp.json()["data"]["id"] == todo.id
```

---

**Setting up coverage [Optional]**

Since the server is a new process, retrieving the complete code coverage was a bit tricky but thanks to the _pytest-cov_ package I was able to hook it up by placing a simple code block (which can be found in the [documentation](https://pytest-cov.readthedocs.io/en/latest/subprocess-support.html)) into the `__init__.py` file of my parent folder.

```
import os 

if os.environ["STAGE"] == "test":
    try:
        from pytest_cov.embed import cleanup_on_sigterm
    except ImportError:
        pass
    else:
        cleanup_on_sigterm()
```

Once completed, use the command `python -m pytest` to run tests 🚀

Well, I think that's it for now. For the complete code implementation, you can refer to the Github repository at: https://github.com/charles-co/ms-todo-app. I hope you find this resource helpful in your development process. If you have any further questions or need additional assistance, please let me know. Happy coding! :)