-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit 003c279
Showing
30 changed files
with
8,752 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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@v3 | ||
- name: Checkout repo | ||
uses: actions/checkout@v4 | ||
- name: Install Prettier | ||
run: npm install --global prettier | ||
- name: Check formatting | ||
run: prettier --check . |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
node_modules | ||
coverage | ||
*.lcov | ||
|
||
**/.terraform/* | ||
*.tfstate | ||
*.tfstate.* |
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,109 @@ | ||
# 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. Implement a CodeDeploy deployment for one of the functions you created. |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 ./... |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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!'" } | ||
}) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
Oops, something went wrong.