Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
cloudlena committed Dec 27, 2023
0 parents commit f0e7b6b
Show file tree
Hide file tree
Showing 39 changed files with 10,464 additions and 0 deletions.
20 changes: 20 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: Validate

on:
push:
branches:
- main

jobs:
validate:
name: Validate
runs-on: ubuntu-latest
steps:
- name: Set up Node.js
uses: actions/setup-node@v4
- name: Checkout repo
uses: actions/checkout@v4
- name: Install Prettier
run: npm install --global prettier
- name: Check formatting
run: prettier --check .
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
node_modules
coverage
*.lcov

**/.terraform/*
*.tfstate
*.tfstate.*
674 changes: 674 additions & 0 deletions LICENSE

Large diffs are not rendered by default.

134 changes: 134 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# Serverless Testing Workshop

Testing is crucial in any software project. When shifting to a serverless world, we need to accept and embrace multiple paradigm shifts, which also affect how we can test our applications. By doing so on multiple layers, we can drastically increase our confidence of releasing code and having minimal impact on the service availability and stability of the software we develop.

This workshop consists of multiple independent modules which can be done in any order. The modules are

- [Unit Tests](#unit-tests)
- [Local Testing](#local-testing)
- [Integration Tests](#integration-tests)
- [E2E Tests](#e2e-tests)
- [Testing in Production](#testing-in-production)

> For some exercises, you need to have certain tools installed. These will be highlighted at the beginning of the respective exercise.
## Unit Tests

In the Function-as-a-Service (FaaS) realm, unit tests are rather straight forward. The function as the unit under test has a clear interface consisting of inputs (function parameters) and outputs (function return value). We can therefore easily mock any dependencies and assert different outputs for the respective input values.

### Exercise

> Requirements: You need to have [Node.js](https://nodejs.org/) installed.
1. Take a look at the function defined in [unit-tests](./unit-tests) and understand what it does.
1. Investigate and run the unit tests in the directory by first running `npm install` and then `npm test`.
1. Add a unit test that checks correct error handling of the function in case no `jokeID` is provided.

<details>
<summary>Solution</summary>

```javascript
test("Input errors are handled", async () => {
const result = await handler({});
expect(result).toBeDefined();
expect(result.Error).toBe("no jokeID provided");
});
```

</details>

## Local Testing

Local development for more complex applications can be tedious if we don't have access to the tools we know and love. For web applications, for example, it's useful if we can use [cURL](https://curl.se/) or similar HTTP clients to directly hit our application running locally and verify different scenarios. A nice way to achieve this locally and gain the benefits of being able to develop with our favorite tools is to use wrappers which run our code as a normal web application locally and as a function which understands API Gateway requests when it's running in a serverless context (e.g., AWS Lambda).

In Node.js, [Express](https://expressjs.com/) is a popular framework for building web applications. It can easily be wrapped using another third party library and, as such, function transparently in a Lambda context.

### Exercise

> Requirements: You need to have [Node.js](https://nodejs.org/) installed.
1. Read up on [`serverless-http`](https://github.com/dougmoscrop/serverless-http) and understand how it works
1. Check out the example application in [local-testing](./local-testing) and investigate how it uses the serverless-http framework
1. Run the application locally by running `npm install` and then `npm start`
1. Send an HTTP request to the app (e.g. using `curl localhost:8080`)
1. Deploy the app to AWS Lambda and hook it up with API Gateway.
1. Research how you could do something similar with the web framework and programming language of your choice

## Integration Tests

Integration testing is crucial to being confident that your application behaves as expected towards its peripheral systems and environments. When working with serverless services, this is usually not so easy. Those services are highly abstracted and mostly closed source. That's why we cannot just spin them up on our local computer or in a CI environment. A good alternative is [LocalStack](https://localstack.cloud/) as it provides high-quality emulations for the APIs of many serverless services. By using it, we can run a dummy environment in almost no time, then run tests against it and delete it again. Even though, these tests don't give us a 100% certainty because the emulation may be faulty, they can drastically increase our confidence before deploying to actual infrastructure.

### Exercise

> Requirements: You need to have either [Docker](https://www.docker.com/) (including [docker-compose](https://github.com/docker/compose)) or [Podman](https://podman.io/) (including [podman-compose](https://github.com/containers/podman-compose)) installed.
1. Take a look at the introduction to LocalStack by reading their [overview documentation](https://docs.localstack.cloud/overview/).
1. Investigate the `docker-compose.yml` file in the [`integration-tests`](./integration-tests) directory and understand how it sets up
1. Run `docker compose up -d` or (`PODMAN_COMPOSE_PROVIDER=podman-compose podman compose up -d` for Podman) and visit [localhost:4566/\_localstack/health](http://localhost:4566/_localstack/health) to verify all services are available.
1. Run `aws --endpoint-url http://localhost:4566 dynamodb create-table --table-name jokes --attribute-definitions AttributeName=ID,AttributeType=S --key-schema AttributeName=ID,KeyType=HASH --provisioned-throughput ReadCapacityUnits=1,WriteCapacityUnits=1` to create the `jokes` table locally.
1. Run `aws --endpoint-url http://localhost:4566 dynamodb list-tables` to verify it has been created.
1. Run `aws --endpoint-url http://localhost:4566 dynamodb put-item --table-name jokes --item '{"ID":{"S":"1"},"Text":{"S":"Hello funny world"}}'` to insert a joke into the newly created table.
1. Run `aws --endpoint-url http://locahlost:4566 dynamodb scan --table-name jokes` to verify it has been inserted.

## E2E Tests

End-to-end tests require a whole environment to be present. The environment should be a similar as possible to the final production environment, the application will run in. Infrastructure as Code allows us to do so by having a clearly declared definition of what an environment looks like. Using that definition, we can spin up ephemeral environments, run our end-to-end tests and then tear them down again. This can usually be done with very low cost, as almost all serverless services are billed on a pay-as-you go model.

As soon as we have our infrastructure defined cleanly as code, we can use a tool like [Terratest](https://terratest.gruntwork.io/) to apply the Terraform code in an automated way using random resource suffixes to prevent name clashes. Terratest then checks certain assertions on the provisioned infrastructure and afterward tears it down again. This can be achieved by using known tools and a mature environment with Go and Terraform as its backbones.

### Exercise

> Requirements: You need to have [Go](https://go.dev/) and either [Terraform](https://www.terraform.io/) or [OpenTofu](https://opentofu.org/) installed.
1. Take a look at the infrastructure code present in [e2e-tests](./e2e-tests) and understand what infrastructure gets provisioned.
1. Investigate the Terratest tests and run them by running `make test`.
1. Add another assertion that sends an HTTP request to our function and checks if it gets a response with the status code `200`. Note that Terratest provides a [`http-helper`](https://pkg.go.dev/github.com/gruntwork-io/terratest/modules/http-helper) package to facilitate that.

<details>
<summary>Solution</summary>

```go
invokeURL := terraform.Output(t, terraformOptions, "invoke_url")

expectedStatusCode := http.StatusOK
statusCode, _ := httphelper.HttpGet(t, invokeURL+"jokes/1", nil)
if statusCode != http.StatusOK {
t.Errorf("Expected status code to be %v, got %v", expectedStatusCode, statusCode)
}
```

</details>

## Testing in Production

Many FaaS platforms allow performing canary deployments. By doing so, we don't release a new version of our software to all users at once. Rather, we first release it to a small percentage of them and then gradually increase that percentage. This is a very controlled process that allows us to roll back on failures or increased error rates. We can identify regressions which have slipped through our net of automated testing before they reach too many clients. This can give us a last boost of confidence in order to release and deploy new versions of our software.

### Exercise

1. Get familiar with how AWS CodeDeploy works by reading through their [How it works guide](https://aws.amazon.com/codedeploy/).
1. Investigate the Terraform resources defined in [testing-in-production](./testing-in-production) and understand what they do.
1. Navigate to the function code
1. Install the functions dependencies with `npm install`
1. Navigate to your Terraform module
1. Init and apply the infrastructure code
1. Change something about the function code and apply again to publish a new version (notice the `publish: true` flag in `function.tf`)
1. Visit the [CodeDeploy UI](https://console.aws.amazon.com/codesuite/codedeploy/applications)
1. Choose your application
1. Click "Create deployment" and choose "Use AppSpec editor" with "YAML"
1. Enter the following code into the text field (replacing `RESOURCE_SUFFIX` with the suffix you chose):

```yml
version: 0.0
Resources:
- my-function:
Type: AWS::Lambda::Function
Properties:
Name: "canaryRESOURCE_SUFFIX"
Alias: "production"
CurrentVersion: "1"
TargetVersion: "2"
```

1. Click "Create deployment"
1. You can now observe in real time how your `production` alias gets switched from version 1 to version 2 gradually using a canary deployment
1. Implement a CodeDeploy deployment for one of the functions you created. You can follow [this tutorial](https://www.ioconnectservices.com/insight/simple-cd-ci-pipeline-for-aws-lambda-walkthrough) if you get stuck.
45 changes: 45 additions & 0 deletions e2e-tests/.terraform.lock.hcl

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions e2e-tests/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.PHONY: test
test:
cd test && go test -count 1 -timeout 30m -p 1 -v ./...
44 changes: 44 additions & 0 deletions e2e-tests/apigateway.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
resource "aws_apigatewayv2_api" "main" {
name = "${local.application}${var.resource_suffix}"
description = "The ${local.application} ${var.environment} API"
protocol_type = "HTTP"
fail_on_warnings = true

tags = {
Application = local.application
Environment = var.environment
}
}

resource "aws_apigatewayv2_route" "root" {
api_id = aws_apigatewayv2_api.main.id
route_key = "$default"
target = "integrations/${aws_apigatewayv2_integration.root.id}"
}

resource "aws_apigatewayv2_integration" "root" {
api_id = aws_apigatewayv2_api.main.id
integration_type = "AWS_PROXY"
integration_method = "POST"
payload_format_version = "2.0"
integration_uri = aws_lambda_function.jokester.arn
}

resource "aws_apigatewayv2_stage" "main" {
api_id = aws_apigatewayv2_api.main.id
name = "$default"
auto_deploy = true

tags = {
Application = local.application
Environment = var.environment
}
}

resource "aws_lambda_permission" "main" {
statement_id = "allow-invocation-by-api-gateway"
principal = "apigateway.amazonaws.com"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.jokester.function_name
source_arn = "${aws_apigatewayv2_stage.main.execution_arn}/$default"
}
83 changes: 83 additions & 0 deletions e2e-tests/function.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
resource "aws_lambda_function" "jokester" {
function_name = "${local.application}${var.resource_suffix}"
filename = data.archive_file.jokester_code.output_path
runtime = "nodejs18.x"
handler = "index.handler"
source_code_hash = data.archive_file.jokester_code.output_base64sha256
role = aws_iam_role.lambda_exec.arn

environment {
variables = {
JOKE_TABLE_SUFFIX = "${var.resource_suffix}"
}
}

tags = {
Application = local.application
Environment = var.environment
}
}

data "archive_file" "jokester_code" {
type = "zip"
source_dir = "${path.module}/../unit-tests"
output_path = "/tmp/${local.application}-code${var.resource_suffix}.zip"
}

resource "aws_cloudwatch_log_group" "jokester" {
name = "/aws/lambda/${aws_lambda_function.jokester.function_name}"
retention_in_days = 30

tags = {
Application = local.application
Environment = var.environment
}
}

resource "aws_iam_role" "lambda_exec" {
name = "${local.application}-exec${var.resource_suffix}"

assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Sid = ""
Principal = {
Service = "lambda.amazonaws.com"
}
}]
})

tags = {
Application = local.application
Environment = var.environment
}
}

resource "aws_iam_role_policy_attachment" "lambda_policy" {
role = aws_iam_role.lambda_exec.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

resource "aws_iam_policy" "read_jokes_db_table" {
name = "${local.application}-jokes${var.resource_suffix}"
policy = data.aws_iam_policy_document.access_jokes_table.json
}

data "aws_iam_policy_document" "access_jokes_table" {
statement {
actions = [
"dynamodb:Scan",
"dynamodb:Query",
"dynamodb:BatchGetItem",
"dynamodb:GetItem",
]
resources = [aws_dynamodb_table.jokes.arn]
}
}

resource "aws_iam_role_policy_attachment" "dynamodb_policy" {
role = aws_iam_role.lambda_exec.name
policy_arn = aws_iam_policy.read_jokes_db_table.arn
}
26 changes: 26 additions & 0 deletions e2e-tests/jokes_table.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
resource "aws_dynamodb_table" "jokes" {
name = "jokes${var.resource_suffix}"
read_capacity = 1
write_capacity = 1
hash_key = "ID"

attribute {
name = "ID"
type = "N"
}

tags = {
Application = local.application
Environment = var.environment
}
}

resource "aws_dynamodb_table_item" "joke_1" {
table_name = aws_dynamodb_table.jokes.name
hash_key = aws_dynamodb_table.jokes.hash_key

item = jsonencode({
"ID" : { "N" : "1" },
"Text" : { "S" : "A biologist, a chemist, and a statistician are out hunting. The biologist shoots at a deer and misses five feet to the left. The chemist shoots at the same deer and misses five feet to the right. The statistician shouts, 'We got him!'" }
})
}
14 changes: 14 additions & 0 deletions e2e-tests/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
terraform {
required_version = "~> 1.0"

required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
archive = {
source = "hashicorp/archive"
version = "~> 2.0"
}
}
}
14 changes: 14 additions & 0 deletions e2e-tests/outputs.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
output "function_name" {
description = "Name of the Lambda function."
value = aws_lambda_function.jokester.function_name
}

output "table_name" {
description = "Name of the DynamoDB table"
value = aws_dynamodb_table.jokes.name
}

output "invoke_url" {
description = "URL where the Lambda can be accessed through HTTP"
value = aws_apigatewayv2_stage.main.invoke_url
}
Loading

0 comments on commit f0e7b6b

Please sign in to comment.