Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 69 additions & 85 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ The HttpInterceptor construct needs to be instantiated in the stack, or inside a
import { Stack, StackProps } from "aws-cdk-lib";

import { Construct } from "constructs";
import { HttpInterceptor, applyHttpInterceptor } from "http-lambda-interceptor";
import { HttpInterceptor, applyHttpInterceptor } from "lambda-http-interceptor";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import { Runtime } from "aws-cdk-lib/aws-lambda";

Expand Down Expand Up @@ -47,15 +47,16 @@ import { expect, describe, it } from "vitest";

process.env.HTTP_INTERCEPTOR_TABLE_NAME = '<table-name-from-construct>'

import { setupLambdaHttpInterceptorConfig } from "http-lambda-interceptor";
import { HttpLambdaInterceptorClient } from "lambda-http-interceptor";

import { triggerMyLambdaFunctionThatMakesExternalCalls } from './utils';

describe("hello function", () => {
it("returns a 200", async () => {
await setupLambdaHttpInterceptorConfig({
lambdaName: '<myLambdaFunctionThatMakesExternalCalls-name>',
mockConfigs: [
describe("my test", () => {
const interceptorClient = new HttpLambdaInterceptorClient(
'<myLambdaFunctionThatMakesExternalCalls-name>',
);
it("tests my lambda function", async () => {
await interceptorClient.createConfigs([
{
url: "https://api-1/*",
response: {
Expand All @@ -71,10 +72,13 @@ describe("hello function", () => {
passThrough: true,
},
},
],
]);
await triggerMyLambdaFunctionThatMakesExternalCalls();
const interceptedCalls = await interceptorClient.pollInterceptedCalls({
numberOfCallsToExpect: 2,
timeout: 5000,
});
const response = await triggerMyLambdaFunctionThatMakesExternalCalls();
expect(response.status).toBe(200);
expect(resp.interceptedCalls).toBe(2);
});
});
```
Expand All @@ -85,7 +89,7 @@ describe("hello function", () => {

### The CDK Construct

The `HttpInterceptor` needs to be instantiated inside a stack. It contains what is necessary to mock calls:
The `HttpInterceptor` construct needs to be instantiated inside a stack. It contains what is necessary to mock calls:
- a DynamoDB table to store and fetch all calls to intercept and what to respond
- an extension to intercept these calls
- an aspect that applies this extension to every NodeJSFunction included in the stack
Expand All @@ -97,7 +101,7 @@ This method can be called with any construct and it will attach the extension to
import { Stack, StackProps } from "aws-cdk-lib";

import { Construct } from "constructs";
import { HttpInterceptor, applyHttpInterceptor } from "http-lambda-interceptor";
import { HttpInterceptor, applyHttpInterceptor } from "lambda-http-interceptor";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import { Runtime } from "aws-cdk-lib/aws-lambda";

Expand Down Expand Up @@ -131,13 +135,13 @@ The other part of the library sits in the sdk used to perform the mocking part.

### The SDK to use in integration tests

The SDK part is the tooling used to configure the calls that need to be intercepted.
The SDK part is the tooling used to configure the calls that need to be intercepted. It is encapsulated inside a class named HttpLambdaInterceptorClient.

The extension also uses the SDK for fetching the configuration that are set in the tests.

#### How to setup the calls configuration

A method named `setupLambdaHttpInterceptorConfig` is used to set up the configuration that will be used by the lambda to handle the interception of the calls.
The method named `createConfigs` of the client is used to set up the configuration that will be used by the lambda to handle the interception of the calls. When you call it, it creates the config if it doesn't exist yet, and it updates it if a record already exists. Thus it can be called once in a tets suite to setup the config for all tests in a before all. And it can also be called at the beginning of every test to have a specific config per tets.

The only requirement for the lambda calls to be intercepted is that this setup needs to be done synchronously before the call of the lambda. You can trigger the lambda synchronously or asynchronously, but the setup of the config needs to be done before that.

Expand Down Expand Up @@ -176,23 +180,21 @@ type LambdaHttpInterceptorConfigInput = {
};
```

This is how to use the `setupLambdaHttpInterceptorConfig` method.
This is how to use the `createConfigs` method.

```typescript
import fetch from "node-fetch";
import { expect, describe, it } from "vitest";

process.env.HTTP_INTERCEPTOR_TABLE_NAME = '<table-name-from-construct>'

import { setupLambdaHttpInterceptorConfig } from "http-lambda-interceptor";
import { setupLambdaHttpInterceptorConfig } from "lambda-http-interceptor";

import { triggerMyLambdaFunctionThatMakesExternalCalls } from './utils';

describe("hello function", () => {
it("returns a 200", async () => {
await setupLambdaHttpInterceptorConfig({
lambdaName: '<myLambdaFunctionThatMakesExternalCalls-name>',
mockConfigs: [
describe("my test", () => {
it("tests my lambda function", async () => {
await interceptorClient.createConfigs([
{
url: "https://api-1/*",
response: {
Expand All @@ -208,10 +210,13 @@ describe("hello function", () => {
passThrough: true,
},
},
],
]);
await triggerMyLambdaFunctionThatMakesExternalCalls();
const interceptedCalls = await interceptorClient.pollInterceptedCalls({
numberOfCallsToExpect: 2,
timeout: 5000,
});
const response = await triggerMyLambdaFunctionThatMakesExternalCalls();
expect(response.status).toBe(200);
expect(resp.interceptedCalls).toBe(2);
});
});
```
Expand All @@ -231,90 +236,69 @@ A `beforeEach` for setup and a `afterEach` with the cleaning method are required

For avoiding collisions between testing suites, if the cleaning method is not called in a test for a specific reason, the method needs to be called in a `afterAll`.

And all tests dealing with the same lambda need to be performed in band, they can't be performed in parallel.
And all tests dealing with the same lambda need to be performed sequentially, they can't be performed in parallel.

An example of the code described here is available in the following section.

### Making expects on the intercepted calls

The method waitForNumberOfInterceptedCalls is used to wait for all intercepted calls when they have been done. It needs configuration about the maximum time to wait before throwing because all supposed intercepted calls have been intercepted.
The method `pollInterceptedCalls` is used to wait for all intercepted calls when they have been done. It needs configuration about the maximum time to wait before throwing because all supposed intercepted calls have been intercepted.

The response contains all information about the calls made to to external API endpoints mocked. Thus assertions can be made on the content of the calls made once received.

```typescript
import fetch from "node-fetch";
import { expect, describe, it } from "vitest";

process.env.HTTP_INTERCEPTOR_TABLE_NAME = '<table-name-from-construct>'

import { setupLambdaHttpInterceptorConfig } from "http-lambda-interceptor";
import { setupLambdaHttpInterceptorConfig } from "lambda-http-interceptor";

import { triggerMyLambdaFunctionThatMakesExternalCalls } from './utils';

describe("hello function", () => {
it("returns a 200", async () => {
await setupLambdaHttpInterceptorConfig({
lambdaName: '<myLambdaFunctionThatMakesExternalCalls-name>',
mockConfigs: [
{
url: "https://api-1/*",
response: {
status: 404,
body: JSON.stringify({
errorMessage: "Not found",
}),
},
describe("my test", () => {
const interceptorClient = new HttpLambdaInterceptorClient(
'<myLambdaFunctionThatMakesExternalCalls-name>',
);
beforeAll(async () => {
await interceptorClient.createConfigs([
{
url: "https://api-1/*",
response: {
status: 404,
body: JSON.stringify({
errorMessage: "Not found",
}),
},
{
url: "https://api-2/path",
response: {
passThrough: true,
},
},
{
url: "https://api-2/path",
response: {
passThrough: true,
},
],
});
const response = await triggerMyLambdaFunctionThatMakesExternalCalls();
expect(response.status).toBe(200);
},
]),
});
afterEach(async () => {
await cleanInterceptedCalls('<myLambdaFunctionThatMakesExternalCalls-name>');
await interceptorClient.cleanInterceptedCalls();
});
it('returns 200 and catches 2 requests', async () => {
const response = await fetch(
`${TEST_ENV_VARS.API_URL}/make-external-call`,
{
method: 'post',
},
);
it('catches 2 requests', async () => {
await triggerMyLambdaFunctionThatMakesExternalCalls();

const resp = await waitForNumberOfInterceptedCalls(
'<myLambdaFunctionThatMakesExternalCalls-name>',
2,
5000,
);
expect(response.status).toBe(200);
expect(resp.length).toBe(2);
const interceptedCalls = await interceptorClient.pollInterceptedCalls({
numberOfCallsToExpect: 2,
timeout: 5000,
});
expect(resp.interceptedCalls).toBe(2);
});
it('returns also 200 and catches also 2 requests', async () => {
const response = await fetch(
`${TEST_ENV_VARS.API_URL}/make-external-call`,
{
method: 'post',
},
);

const resp = await waitForNumberOfInterceptedCalls(
'<myLambdaFunctionThatMakesExternalCalls-name>',
2,
5000,
);
it('catches also 2 requests', async () => {
await triggerMyLambdaFunctionThatMakesExternalCalls();

expect(response.status).toBe(200);
expect(resp.length).toBe(2);
// resp contains all information about the calls made to to external API endpoints mocked
// Expects can be made on the content of the calls made once received
const interceptedCalls = await interceptorClient.pollInterceptedCalls({
numberOfCallsToExpect: 2,
timeout: 5000,
});
expect(resp.interceptedCalls).toBe(2);
});
});
```

## Coming next

Having a class to instantiate in each test suite that will allow independent testing across all tests suites even on the same lambda.
134 changes: 134 additions & 0 deletions article.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
---
published: false
title: 'PLACEholder'
cover_image:
description: ''
tags:
series:
canonical_url:
---

## TL;DR

Learn how to perform integration tests iso prod with aws serverless services. Using lambda-http-interceptor you can easily intercept and mock http calls coming from your deployed lambda functions.

**Why should you use:**
- You want to test your lambda functions in a iso-prod environment
- You want to save money while running integration tests by not triggering costly third party APIs
- You want to control the behavior of third party APIs to test edge cases
- You don't want to change your lambda code to make it testable

And maybe you can use it for all theses reasons at the same time!

{% cta https://github.com/MaximeVivier/lambda-http-interceptor %} Try lambda-http-interceptor here 😉 {% endcta %}

## Intercept http calls in lambda functions

The lib is made of a CDK Construct to instantiate in your stack.

The `HttpInterceptor` **construct needs to be instantiated in the stack**, or inside another Construct. And then **the interceptor needs to be applied to the lambda function** http calls need to be intercepted from.

The `applyHttpInterceptor` uses `Aspects` in order to apply it on each `NodeLambdaFunction` it finds, thus `applyHttpInterceptor` takes any Construct as input.

```ts
import { Stack, StackProps } from "aws-cdk-lib";

import { Construct } from "constructs";
import { HttpInterceptor, applyHttpInterceptor } from "lambda-http-interceptor";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import { Runtime } from "aws-cdk-lib/aws-lambda";

export class MyStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);

const interceptor = new HttpInterceptor(this, "HttpInterceptor");

const myLambdaFunctionThatMakesExternalCalls = new NodejsFunction(
this,
"MakeExternalCalls",
{
runtime: Runtime.NODEJS_18_X,
handler: "index.handler",
entry: './handler.ts',
},
);

applyHttpInterceptor(myLambdaFunctionThatDoesExternalCalls, interceptor);
}
}
```

After deploying, everything is setup on the stack to then perform integration tests.

The second part of the lib is a set of **tools to perform integration tests**. They are gathered in the `HttpLambdaInterceptorClient` class.

```typescript
import fetch from "node-fetch";
import { expect, describe, it } from "vitest";

process.env.HTTP_INTERCEPTOR_TABLE_NAME = '<table-name-from-construct>'

import { HttpLambdaInterceptorClient } from "lambda-http-interceptor";

import { triggerMyLambdaFunctionThatMakesExternalCalls } from './utils';

describe("my test", () => {
const interceptorClient = new HttpLambdaInterceptorClient(
'<myLambdaFunctionThatMakesExternalCalls-name>',
);
it("tests my lambda function", async () => {
await interceptorClient.createConfigs([
{
url: "https://api-1/*",
response: {
status: 404,
body: JSON.stringify({
errorMessage: "Not found",
}),
},
},
{
url: "https://api-2/path",
response: {
passThrough: true,
},
},
]);
await triggerMyLambdaFunctionThatMakesExternalCalls();
const interceptedCalls = await interceptorClient.pollInterceptedCalls({
numberOfCallsToExpect: 2,
timeout: 5000,
});
expect(resp.interceptedCalls).toBe(2);
});
});
```

## How does it work?

### lambda-http-interceptor stores the configurations and the call made in DynamoDB table

The `HttpInterceptor` instantiates a DynamoDB table in the stack. The table is used to store the configurations of the http calls to intercept. When performing integration tests, filling up the table with configuration is done using the `createConfigs` method of the `HttpLambdaInterceptorClient` class.

Then assertions can be made on the calls made by the lambda after they are fetched using the `pollInterceptedCalls` method of the `HttpLambdaInterceptorClient` class.

> Don't forget to give the user you're using to perform the integration tests the right to read in the table. In general, we use AdministratorAccess role for the user performing these tasks.

### lambda-http-interceptor uses an internal extension to intercept http calls in lambda functions

The internal extension that the interceptor deploys on the lambda functions overrides the `http` module of nodejs that is used to make http calls.

For each call made by the lambda, it fetches the http calls configuration stored in DynamoDB and either passes through the call or returns the response value configured at the start.

It keeps track of the http calls listed that are listed in the configuration. If the response of a call doesn't need to be changed but it still needs to be tracked in order to make assertions on it, the configuration of the call doesn't change and the response only contains `passthrough: true`.

If you want to deep dive the functioning of the interceptor, you can check out [this article](https://dev.to/slsbytheodo/power-up-your-serverless-application-with-aws-lambda-extensions-3a31) that presents extensions really clearly using a simple example.

## lambda-http-interceptor has everything built in

The setup is fairly easy and it can be used to make assertions on the calls made by your deployed lambda functions. The documentation is far more exhaustive to get you started.

{% cta https://github.com/MaximeVivier/lambda-http-interceptor %} Try lambda-http-interceptor here 😉 {% endcta %}

Don't hesitate to star it ⭐️
Loading