Издевательство над сервисами AWS и лучшими практиками Lambda

#typescript #amazon-web-services #aws-lambda #jestjs #ts-jest

#typescript #amazon-веб-сервисы #aws-lambda #jestjs #ts-шутка

Вопрос:

Я работаю над простой функцией AWS lambda, которая запускается потоками событий DynamoDB и должна пересылать все записи, кроме REMOVE событий, в очередь SQS. Функция работает так, как ожидалось, никаких сюрпризов.

Я хочу написать модульный тест, чтобы проверить, как ничего не отправлять в SQS, когда это DELETE событие. Я впервые попробовал это с помощью aws-sdk-mock. Как вы можете видеть в коде функции, я пытаюсь соответствовать лучшим практикам lambda, инициализируя клиент SQS вне кода обработчика. По-видимому, это не позволяет aws-sdk-mock издеваться над сервисом SQS (на GitHub есть проблема по этому поводу: https://github.com/dwyl/aws-sdk-mock/issues/206 ).

Затем я попытался издеваться над SQS, используя jest, для чего требовалось больше кода, чтобы все было правильно, но в итоге я столкнулся с той же проблемой, поскольку мне требовалось поместить инициализацию SQS внутри функции-обработчика, что нарушает лучшие практики lambda.

Как я могу написать модульный тест для этой функции, разрешив инициализацию SQS client ( const sqs: SQS = new SQS() ) вне обработчика? Я неправильно издеваюсь над сервисом или необходимо изменить структуру обработчика, чтобы упростить его тестирование?

Я знаю, что эта лямбда-функция довольно проста, и модульные тесты могут быть ненужными, но мне придется писать дополнительные лямбды с более сложной логикой, и я думаю, что это вполне подходит для демонстрации проблемы.

index.ts

 import {DynamoDBStreamEvent, DynamoDBStreamHandler} from "aws-lambda";
import SQS = require("aws-sdk/clients/sqs");
import DynamoDB = require("aws-sdk/clients/dynamodb");

const sqs: SQS = new SQS()

export const handleDynamoDbEvent: DynamoDBStreamHandler = async (event: DynamoDBStreamEvent, context, callback) => {
    const QUEUE_URL = process.env.TARGET_QUEUE_URL
    if (QUEUE_URL.length == 0) {
        throw new Error('TARGET_QUEUE_URL not set or empty')
    }
    await Promise.all(
        event.Records
            .filter(_ => _.eventName !== "REMOVE")
            .map((record) => {
                const unmarshalled = DynamoDB.Converter.unmarshall(record.dynamodb.NewImage);
                let request: SQS.SendMessageRequest = {
                    MessageAttributes: {
                        "EVENT_NAME": {
                            DataType: "String",
                            StringValue: record.eventName
                        }
                    },
                    MessageBody: JSON.stringify(unmarshalled),
                    QueueUrl: QUEUE_URL,
                }
                return sqs.sendMessage(request).promise()
            })
    );
}
  

index.spec.ts

 import {DynamoDBRecord, DynamoDBStreamEvent, StreamRecord} from "aws-lambda";
import {AttributeValue} from "aws-lambda/trigger/dynamodb-stream";
import {handleDynamoDbEvent} from "./index";
import {AWSError} from "aws-sdk/lib/error";
import {PromiseResult, Request} from "aws-sdk/lib/request";
import * as SQS from "aws-sdk/clients/sqs";
import {mocked} from "ts-jest/utils";
import DynamoDB = require("aws-sdk/clients/dynamodb");


jest.mock('aws-sdk/clients/sqs', () => {
    return jest.fn().mockImplementation(() => {
        return {
            sendMessage: (params: SQS.Types.SendMessageRequest, callback?: (err: AWSError, data: SQS.Types.SendMessageResult) => void): Request<SQS.Types.SendMessageResult, AWSError> => {
                // @ts-ignore
                const Mock = jest.fn<Request<SQS.Types.SendMessageResult, AWSError>>(()=>{
                    return {
                        promise: (): Promise<PromiseResult<SQS.Types.SendMessageResult, AWSError>> => {
                            return new Promise<PromiseResult<SQS.SendMessageResult, AWSError>>(resolve => {
                                resolve(null)
                            })
                        }
                    }
                })
                return new Mock()
            }
        }
    })
});


describe.only('Handler test', () => {

    const mockedSqs = mocked(SQS, true)

    process.env.TARGET_QUEUE_URL = 'test'
    const OLD_ENV = process.env;

    beforeEach(() => {
        mockedSqs.mockClear()
        jest.resetModules();
        process.env = {...OLD_ENV};
    });

    it('should write INSERT events to SQS', async () => {
        console.log('Starting test')
        await handleDynamoDbEvent(createEvent(), null, null)
        expect(mockedSqs).toHaveBeenCalledTimes(1)
    });
})
  

Ответ №1:

Просто приблизительное представление о том, как бы я подошел к этому:

  • Вместо того, чтобы выполнять фактическую отправку / манипулирование SQS внутри основной функции, я бы создал интерфейс для клиента сообщений. Что-то вроде этого:
 interface QueueClient {
    send(eventName: string, body: string): Promise<any>;
}
  
  • И создайте реальный класс, который реализует этот интерфейс для взаимодействия с SQS:
 class SQSQueueClient implements QueueClient {
    queueUrl: string
    sqs: SQS

    constructor() {
        this.queueUrl = process.env.TARGET_QUEUE_URL;
        if (this.queueUrl.length == 0) {
            throw new Error('TARGET_QUEUE_URL not set or empty')
        }
        this.sqs = new SQS();
    }

    send(eventName: string, body: string): Promise<any> {
        let request: SQS.SendMessageRequest = {
            MessageAttributes: {
                "EVENT_NAME": {
                    DataType: "String",
                    StringValue: eventName
                }
            },
            MessageBody: body,
            QueueUrl: this.queueUrl,
        }
        return this.sqs.sendMessage()
    }
}
  

Этот класс знает о деталях перевода данных в формат SQS

  • Затем я разделю основную функцию на 2. Точка входа просто анализирует URL-адрес очереди, создает фактический экземпляр клиента очереди sqs и вызывает process() . Основная логика заключается в process()
 const queueClient = new SQSQueueClient();

export const handleDynamoDbEvent: DynamoDBStreamHandler = async (event: DynamoDBStreamEvent, context, callback) => {
    return process(queueClient, event);
}

export const process = async (queueClient: QueueClient, event: DynamoDBStreamEvent) => {
    return await Promise.all(
        event.Records
            .filter(_ => _.eventName !== "REMOVE")
            .map((record) => {
                const unmarshalled = DynamoDB.Converter.unmarshall(record.dynamodb.NewImage);
                return queueClient.send(record.eventName, JSON.stringify(unmarshalled));
            })
    );
}
  
  • Теперь стало намного проще тестировать основную логику process() . Вы можете предоставить макет экземпляра, который реализует интерфейс QueueClient , написав его от руки, или использовать любой макет, который вам нравится
  • Для SQSQueueClient класса нет особой пользы от модульного тестирования, поэтому я буду больше полагаться на интеграционный тест (например, использовать что-то вроде localstack)

Сейчас у меня нет реальной среды разработки, поэтому простите меня, если здесь и там есть синтаксическая ошибка

Комментарии:

1. В этом примере new SQSQueueClient(QUEUE_URL) вызывается каждый раз, когда вызывается обработчик, который вызывает new SQS() вызов конструктора. Это не соответствует лучшим практикам lambda.

2. верно, в этом случае создание SQSQueueClient может быть перемещено за пределы, вместо этого может потребоваться передать URL-адрес очереди в качестве параметра send() . Но идея, вероятно, все та же

3. @mheck я просто редактирую код, чтобы перенести создание SQSQueueClient outside. Логика для анализа URL-адреса очереди из переменной env также может быть перенесена в конструктор SQSQueueClient

4. вы в основном реализуете внедрение зависимостей. Функция обработчика верхнего уровня разрешает зависимости и вводит их в качестве аргументов в фактическую реализацию обработчика. Я думаю, я мог бы сделать это и без интерфейса QueueClient. Проблема не в издевательстве над SQS, а в том, чтобы заставить обработчик использовать его. Таким образом, это должно сработать.

Ответ №2:

Я добавил метод инициализации, который вызывается изнутри функции-обработчика. Он немедленно возвращается, если он уже был вызван ранее, и в противном случае инициализирует клиент SQS. Его можно легко расширить, чтобы также инициализировать другие клиенты.

Это соответствует лучшим практикам lambda и заставляет тестовый код работать.

 let sqs: SQS = null
let initialized = false

export const handleDynamoDbEvent: DynamoDBStreamHandler = async (event: DynamoDBStreamEvent, context, callback) => {
    init()
    const QUEUE_URL = process.env.TARGET_QUEUE_URL
    if (QUEUE_URL.length == 0) {
        throw new Error('TARGET_QUEUE_URL not set or empty')
    }
    await Promise.all(
        event.Records
            .filter(_ => _.eventName !== "REMOVE")
            .map((record) => {
                const unmarshalled = DynamoDB.Converter.unmarshall(record.dynamodb.NewImage);
                let request: SQS.SendMessageRequest = {
                    MessageAttributes: {
                        "EVENT_NAME": {
                            DataType: "String",
                            StringValue: record.eventName
                        }
                    },
                    MessageBody: JSON.stringify(unmarshalled),
                    QueueUrl: QUEUE_URL,
                }
                return sqs.sendMessage(request).promise()
            })
    );
}

function init() {
    if (initialized) {
        return
    }
    console.log('Initializing...')
    initialized = true
    sqs = new SQS()
}