Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[examples] add an example project to show test strategies #438

Merged
merged 2 commits into from
Dec 25, 2024
Merged
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
2 changes: 2 additions & 0 deletions Examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ This directory contains example code for Lambda functions.

- **[Streaming]**: create a Lambda function exposed as an URL. The Lambda function streams its response over time. (requires [AWS SAM](https://aws.amazon.com/serverless/sam/)).

- **[Testing](Testing/README.md)**: a test suite for Lambda functions.

## AWS Credentials and Signature

This section is a short tutorial on the AWS Signature protocol and the AWS credentials.
Expand Down
64 changes: 64 additions & 0 deletions Examples/Testing/Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// swift-tools-version:6.0

import PackageDescription

// needed for CI to test the local version of the library
import struct Foundation.URL

let package = Package(
name: "swift-aws-lambda-runtime-example",
platforms: [.macOS(.v15)],
products: [
.executable(name: "APIGatewayLambda", targets: ["APIGatewayLambda"])
],
dependencies: [
// during CI, the dependency on local version of swift-aws-lambda-runtime is added dynamically below
.package(url: "https://github.com/swift-server/swift-aws-lambda-runtime.git", branch: "main"),
.package(url: "https://github.com/swift-server/swift-aws-lambda-events.git", branch: "main"),
],
targets: [
.executableTarget(
name: "APIGatewayLambda",
dependencies: [
.product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"),
.product(name: "AWSLambdaEvents", package: "swift-aws-lambda-events"),
],
path: "Sources"
),
.testTarget(
name: "LambdaFunctionTests",
dependencies: ["APIGatewayLambda"],
path: "Tests",
resources: [
.process("event.json")
]
),
]
)

if let localDepsPath = Context.environment["LAMBDA_USE_LOCAL_DEPS"],
localDepsPath != "",
let v = try? URL(fileURLWithPath: localDepsPath).resourceValues(forKeys: [.isDirectoryKey]),
v.isDirectory == true
{
// when we use the local runtime as deps, let's remove the dependency added above
let indexToRemove = package.dependencies.firstIndex { dependency in
if case .sourceControl(
name: _,
location: "https://github.com/swift-server/swift-aws-lambda-runtime.git",
requirement: _
) = dependency.kind {
return true
}
return false
}
if let indexToRemove {
package.dependencies.remove(at: indexToRemove)
}

// then we add the dependency on LAMBDA_USE_LOCAL_DEPS' path (typically ../..)
print("[INFO] Compiling against swift-aws-lambda-runtime located at \(localDepsPath)")
package.dependencies += [
.package(name: "swift-aws-lambda-runtime", path: localDepsPath)
]
}
170 changes: 170 additions & 0 deletions Examples/Testing/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
# Swift Testing Example

This is a simple example to show different testing strategies for your Swift Lambda functions.
For this example, we developed a simple Lambda function that returns the body of the API Gateway payload in lowercase, except for the first letter, which is in uppercase.

In this document, we describe four different testing strategies:
* [Unit Testing your business logic](#unit-testing-your-business-logic)
* [Integration testing the handler function](#integration-testing-the-handler-function)
* [Local invocation using the Swift AWS Lambda Runtime](#local-invocation-using-the-swift-aws-lambda-runtime)
* [Local invocation using the AWS SAM CLI](#local-invocation-using-the-aws-sam-cli)

> [!IMPORTANT]
> In this example, the API Gateway sends a payload to the Lambda function as a JSON string. Your business payload is in the `body` section of the API Gateway payload. It is base64-encoded. You can find an example of the API Gateway payload in the `event.json` file. The API Gateway event format is documented in [Create AWS Lambda proxy integrations for HTTP APIs in API Gateway](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html).

To include a sample event in your test targets, you must add the `event.json` file from the `Tests` directory to the binary bundle. To do so, add a `resources` section in your `Package.swift` file:

```swift
.testTarget(
name: "LambdaFunctionTests",
dependencies: ["APIGatewayLambda"],
path: "Tests",
resources: [
.process("event.json")
]
)
```

## Unit Testing your business logic

You can test the business logic of your Lambda function by writing unit tests for your business code used in the handler function, just like usual.

1. Create your Swift Test code in the `Tests` directory.

```swift
let valuesToTest: [(String, String)] = [
("hello world", "Hello world"), // happy path
("", ""), // Empty string
("a", "A"), // Single character
]

@Suite("Business Tests")
class BusinessTests {

@Test("Uppercased First", arguments: valuesToTest)
func uppercasedFirst(_ arg: (String,String)) {
let input = arg.0
let expectedOutput = arg.1
#expect(input.uppercasedFirst() == expectedOutput)
}
}```

2. Add a test target to your `Package.swift` file.
```swift
.testTarget(
name: "BusinessTests",
dependencies: ["APIGatewayLambda"],
path: "Tests"
)
```

3. run `swift test` to run the tests.

## Integration Testing the handler function

You can test the handler function by creating an input event, a mock Lambda context, and calling the handler function from your test.
Your Lambda handler function must be declared separatly from the `LambdaRuntime`. For example:

```swift
public struct MyHandler: Sendable {

public func handler(event: APIGatewayV2Request, context: LambdaContext) async throws -> APIGatewayV2Response {
context.logger.debug("HTTP API Message received")
context.logger.trace("Event: \(event)")

var header = HTTPHeaders()
header["content-type"] = "application/json"

if let payload = event.body {
// call our business code to process the payload and return a response
return APIGatewayV2Response(statusCode: .ok, headers: header, body: payload.uppercasedFirst())
} else {
return APIGatewayV2Response(statusCode: .badRequest)
}
}
}

let runtime = LambdaRuntime(body: MyHandler().handler)
try await runtime.run()
```

Then, the test looks like this:

```swift
@Suite("Handler Tests")
public struct HandlerTest {

@Test("Invoke handler")
public func invokeHandler() async throws {

// read event.json file
let testBundle = Bundle.module
guard let eventURL = testBundle.url(forResource: "event", withExtension: "json") else {
Issue.record("event.json not found in test bundle")
return
}
let eventData = try Data(contentsOf: eventURL)

// decode the event
let apiGatewayRequest = try JSONDecoder().decode(APIGatewayV2Request.self, from: eventData)

// create a mock LambdaContext
let lambdaContext = LambdaContext.__forTestsOnly(
requestID: UUID().uuidString,
traceID: UUID().uuidString,
invokedFunctionARN: "arn:",
timeout: .milliseconds(6000),
logger: Logger(label: "fakeContext")
)

// call the handler with the event and context
let response = try await MyHandler().handler(event: apiGatewayRequest, context: lambdaContext)

// assert the response
#expect(response.statusCode == .ok)
#expect(response.body == "Hello world of swift lambda!")
}
}
```

## Local invocation using the Swift AWS Lambda Runtime

You can test your Lambda function locally by invoking it with the Swift AWS Lambda Runtime.

You must pass a payload to the Lambda function. You can use the `Tests/event.json` file for this purpose. The return value is a `APIGatewayV2Response` object in this example.

Just type `swift run` to run the Lambda function locally.

```sh
LOG_LEVEL=trace swift run

# from another terminal
# the `-X POST` flag is implied when using `--data`. It is here for clarity only.
curl -X POST "http://127.0.0.1:7000/invoke" --data @Tests/event.json
```

This returns the following response:

```text
{"statusCode":200,"headers":{"content-type":"application\/json"},"body":"Hello world of swift lambda!"}
```

## Local invocation using the AWS SAM CLI

The AWS SAM CLI provides you with a local testing environment for your Lambda functions. It deploys and invoke your function locally in a Docker container designed to mimic the AWS Lambda environment.

You must pass a payload to the Lambda function. You can use the `event.json` file for this purpose. The return value is a `APIGatewayV2Response` object in this example.

```sh
sam local invoke -e Tests/event.json

START RequestId: 3270171f-46d3-45f9-9bb6-3c2e5e9dc625 Version: $LATEST
2024-12-21T16:49:31+0000 debug LambdaRuntime : [AWSLambdaRuntimeCore] LambdaRuntime initialized
2024-12-21T16:49:31+0000 trace LambdaRuntime : lambda_ip=127.0.0.1 lambda_port=9001 [AWSLambdaRuntimeCore] Connection to control plane created
2024-12-21T16:49:31+0000 debug LambdaRuntime : [APIGatewayLambda] HTTP API Message received
2024-12-21T16:49:31+0000 trace LambdaRuntime : [APIGatewayLambda] Event: APIGatewayV2Request(version: "2.0", routeKey: "$default", rawPath: "/", rawQueryString: "", cookies: [], headers: ["x-forwarded-proto": "https", "host": "a5q74es3k2.execute-api.us-east-1.amazonaws.com", "content-length": "0", "x-forwarded-for": "81.0.0.43", "accept": "*/*", "x-amzn-trace-id": "Root=1-66fb03de-07533930192eaf5f540db0cb", "x-forwarded-port": "443", "user-agent": "curl/8.7.1"], queryStringParameters: [:], pathParameters: [:], context: AWSLambdaEvents.APIGatewayV2Request.Context(accountId: "012345678901", apiId: "a5q74es3k2", domainName: "a5q74es3k2.execute-api.us-east-1.amazonaws.com", domainPrefix: "a5q74es3k2", stage: "$default", requestId: "e72KxgsRoAMEMSA=", http: AWSLambdaEvents.APIGatewayV2Request.Context.HTTP(method: GET, path: "/", protocol: "HTTP/1.1", sourceIp: "81.0.0.43", userAgent: "curl/8.7.1"), authorizer: nil, authentication: nil, time: "30/Sep/2024:20:02:38 +0000", timeEpoch: 1727726558220), stageVariables: [:], body: Optional("aGVsbG8gd29ybGQgb2YgU1dJRlQgTEFNQkRBIQ=="), isBase64Encoded: false)
END RequestId: 5b71587a-39da-445e-855d-27a700e57efd
REPORT RequestId: 5b71587a-39da-445e-855d-27a700e57efd Init Duration: 0.04 ms Duration: 21.57 ms Billed Duration: 22 ms Memory Size: 512 MB Max Memory Used: 512 MB

{"body": "Hello world of swift lambda!", "statusCode": 200, "headers": {"content-type": "application/json"}}
```
33 changes: 33 additions & 0 deletions Examples/Testing/Sources/Business.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the SwiftAWSLambdaRuntime open source project
//
// Copyright (c) 2024 Apple Inc. and the SwiftAWSLambdaRuntime project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

extension String {
/// Returns a new string with the first character capitalized and the remaining characters in lowercase.
///
/// This method capitalizes the first character of the string and converts the remaining characters to lowercase.
/// It is useful for formatting strings where only the first character should be uppercase.
///
/// - Returns: A new string with the first character capitalized and the remaining characters in lowercase.
///
/// - Example:
/// ```
/// let example = "hello world"
/// print(example.uppercasedFirst()) // Prints "Hello world"
/// ```
func uppercasedFirst() -> String {
let firstCharacter = prefix(1).capitalized
let remainingCharacters = dropFirst().lowercased()
return firstCharacter + remainingCharacters
}
}
48 changes: 48 additions & 0 deletions Examples/Testing/Sources/main.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the SwiftAWSLambdaRuntime open source project
//
// Copyright (c) 2024 Apple Inc. and the SwiftAWSLambdaRuntime project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

import AWSLambdaEvents
import AWSLambdaRuntime

#if canImport(FoundationEssentials)
import FoundationEssentials
#else
import Foundation
#endif

public struct MyHandler: Sendable {

public func handler(event: APIGatewayV2Request, context: LambdaContext) async throws -> APIGatewayV2Response {
context.logger.debug("HTTP API Message received")
context.logger.trace("Event: \(event)")

var header = HTTPHeaders()
header["content-type"] = "application/json"

// API Gateway sends text or URL encoded data as a Base64 encoded string
if let base64EncodedString = event.body,
let decodedData = Data(base64Encoded: base64EncodedString),
let decodedString = String(data: decodedData, encoding: .utf8)
{

// call our business code to process the payload and return a response
return APIGatewayV2Response(statusCode: .ok, headers: header, body: decodedString.uppercasedFirst())
} else {
return APIGatewayV2Response(statusCode: .badRequest)
}
}
}

let runtime = LambdaRuntime(body: MyHandler().handler)
try await runtime.run()
40 changes: 40 additions & 0 deletions Examples/Testing/Tests/BusinessTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the SwiftAWSLambdaRuntime open source project
//
// Copyright (c) 2024 Apple Inc. and the SwiftAWSLambdaRuntime project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

import Testing

@testable import APIGatewayLambda // to access the business code

let valuesToTest: [(String, String)] = [
("hello world", "Hello world"), // happy path
("", ""), // Empty string
("a", "A"), // Single character
("A", "A"), // Single uppercase character
("HELLO WORLD", "Hello world"), // All uppercase
("hello world", "Hello world"), // All lowercase
("hElLo WoRlD", "Hello world"), // Mixed case
("123abc", "123abc"), // Numeric string
("!@#abc", "!@#abc"), // Special characters
]

@Suite("Business Tests")
class BusinessTests {

@Test("Uppercased First", arguments: valuesToTest)
func uppercasedFirst(_ arg: (String, String)) {
let input = arg.0
let expectedOutput = arg.1
#expect(input.uppercasedFirst() == expectedOutput)
}
}
Loading
Loading