Skip to content

Commit cef85b9

Browse files
authored
[examples] add an example project to show test strategies (#438)
This new example project show four testing strategies for Swift Lambda function - Unit testing the business logic (not specific to Swift Lambda) - Integration testing the handler method - Local invocation with the Swift Lambda Runtime - Local invocation with SAM **[IMPORTANT]** To allow testing the handler, I had to change visibility of a method in the Runtime project. This method is clearly marked for testing only, so it should not be a problem. Happy to read feedback and discuss however.
1 parent 31a7eda commit cef85b9

File tree

9 files changed

+485
-0
lines changed

9 files changed

+485
-0
lines changed

Examples/README.md

+2
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ This directory contains example code for Lambda functions.
3232

3333
- **[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/)).
3434

35+
- **[Testing](Testing/README.md)**: a test suite for Lambda functions.
36+
3537
## AWS Credentials and Signature
3638

3739
This section is a short tutorial on the AWS Signature protocol and the AWS credentials.

Examples/Testing/Package.swift

+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// swift-tools-version:6.0
2+
3+
import PackageDescription
4+
5+
// needed for CI to test the local version of the library
6+
import struct Foundation.URL
7+
8+
let package = Package(
9+
name: "swift-aws-lambda-runtime-example",
10+
platforms: [.macOS(.v15)],
11+
products: [
12+
.executable(name: "APIGatewayLambda", targets: ["APIGatewayLambda"])
13+
],
14+
dependencies: [
15+
// during CI, the dependency on local version of swift-aws-lambda-runtime is added dynamically below
16+
.package(url: "https://github.com/swift-server/swift-aws-lambda-runtime.git", branch: "main"),
17+
.package(url: "https://github.com/swift-server/swift-aws-lambda-events.git", branch: "main"),
18+
],
19+
targets: [
20+
.executableTarget(
21+
name: "APIGatewayLambda",
22+
dependencies: [
23+
.product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"),
24+
.product(name: "AWSLambdaEvents", package: "swift-aws-lambda-events"),
25+
],
26+
path: "Sources"
27+
),
28+
.testTarget(
29+
name: "LambdaFunctionTests",
30+
dependencies: ["APIGatewayLambda"],
31+
path: "Tests",
32+
resources: [
33+
.process("event.json")
34+
]
35+
),
36+
]
37+
)
38+
39+
if let localDepsPath = Context.environment["LAMBDA_USE_LOCAL_DEPS"],
40+
localDepsPath != "",
41+
let v = try? URL(fileURLWithPath: localDepsPath).resourceValues(forKeys: [.isDirectoryKey]),
42+
v.isDirectory == true
43+
{
44+
// when we use the local runtime as deps, let's remove the dependency added above
45+
let indexToRemove = package.dependencies.firstIndex { dependency in
46+
if case .sourceControl(
47+
name: _,
48+
location: "https://github.com/swift-server/swift-aws-lambda-runtime.git",
49+
requirement: _
50+
) = dependency.kind {
51+
return true
52+
}
53+
return false
54+
}
55+
if let indexToRemove {
56+
package.dependencies.remove(at: indexToRemove)
57+
}
58+
59+
// then we add the dependency on LAMBDA_USE_LOCAL_DEPS' path (typically ../..)
60+
print("[INFO] Compiling against swift-aws-lambda-runtime located at \(localDepsPath)")
61+
package.dependencies += [
62+
.package(name: "swift-aws-lambda-runtime", path: localDepsPath)
63+
]
64+
}

Examples/Testing/README.md

+170
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
# Swift Testing Example
2+
3+
This is a simple example to show different testing strategies for your Swift Lambda functions.
4+
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.
5+
6+
In this document, we describe four different testing strategies:
7+
* [Unit Testing your business logic](#unit-testing-your-business-logic)
8+
* [Integration testing the handler function](#integration-testing-the-handler-function)
9+
* [Local invocation using the Swift AWS Lambda Runtime](#local-invocation-using-the-swift-aws-lambda-runtime)
10+
* [Local invocation using the AWS SAM CLI](#local-invocation-using-the-aws-sam-cli)
11+
12+
> [!IMPORTANT]
13+
> 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).
14+
15+
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:
16+
17+
```swift
18+
.testTarget(
19+
name: "LambdaFunctionTests",
20+
dependencies: ["APIGatewayLambda"],
21+
path: "Tests",
22+
resources: [
23+
.process("event.json")
24+
]
25+
)
26+
```
27+
28+
## Unit Testing your business logic
29+
30+
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.
31+
32+
1. Create your Swift Test code in the `Tests` directory.
33+
34+
```swift
35+
let valuesToTest: [(String, String)] = [
36+
("hello world", "Hello world"), // happy path
37+
("", ""), // Empty string
38+
("a", "A"), // Single character
39+
]
40+
41+
@Suite("Business Tests")
42+
class BusinessTests {
43+
44+
@Test("Uppercased First", arguments: valuesToTest)
45+
func uppercasedFirst(_ arg: (String,String)) {
46+
let input = arg.0
47+
let expectedOutput = arg.1
48+
#expect(input.uppercasedFirst() == expectedOutput)
49+
}
50+
}```
51+
52+
2. Add a test target to your `Package.swift` file.
53+
```swift
54+
.testTarget(
55+
name: "BusinessTests",
56+
dependencies: ["APIGatewayLambda"],
57+
path: "Tests"
58+
)
59+
```
60+
61+
3. run `swift test` to run the tests.
62+
63+
## Integration Testing the handler function
64+
65+
You can test the handler function by creating an input event, a mock Lambda context, and calling the handler function from your test.
66+
Your Lambda handler function must be declared separatly from the `LambdaRuntime`. For example:
67+
68+
```swift
69+
public struct MyHandler: Sendable {
70+
71+
public func handler(event: APIGatewayV2Request, context: LambdaContext) async throws -> APIGatewayV2Response {
72+
context.logger.debug("HTTP API Message received")
73+
context.logger.trace("Event: \(event)")
74+
75+
var header = HTTPHeaders()
76+
header["content-type"] = "application/json"
77+
78+
if let payload = event.body {
79+
// call our business code to process the payload and return a response
80+
return APIGatewayV2Response(statusCode: .ok, headers: header, body: payload.uppercasedFirst())
81+
} else {
82+
return APIGatewayV2Response(statusCode: .badRequest)
83+
}
84+
}
85+
}
86+
87+
let runtime = LambdaRuntime(body: MyHandler().handler)
88+
try await runtime.run()
89+
```
90+
91+
Then, the test looks like this:
92+
93+
```swift
94+
@Suite("Handler Tests")
95+
public struct HandlerTest {
96+
97+
@Test("Invoke handler")
98+
public func invokeHandler() async throws {
99+
100+
// read event.json file
101+
let testBundle = Bundle.module
102+
guard let eventURL = testBundle.url(forResource: "event", withExtension: "json") else {
103+
Issue.record("event.json not found in test bundle")
104+
return
105+
}
106+
let eventData = try Data(contentsOf: eventURL)
107+
108+
// decode the event
109+
let apiGatewayRequest = try JSONDecoder().decode(APIGatewayV2Request.self, from: eventData)
110+
111+
// create a mock LambdaContext
112+
let lambdaContext = LambdaContext.__forTestsOnly(
113+
requestID: UUID().uuidString,
114+
traceID: UUID().uuidString,
115+
invokedFunctionARN: "arn:",
116+
timeout: .milliseconds(6000),
117+
logger: Logger(label: "fakeContext")
118+
)
119+
120+
// call the handler with the event and context
121+
let response = try await MyHandler().handler(event: apiGatewayRequest, context: lambdaContext)
122+
123+
// assert the response
124+
#expect(response.statusCode == .ok)
125+
#expect(response.body == "Hello world of swift lambda!")
126+
}
127+
}
128+
```
129+
130+
## Local invocation using the Swift AWS Lambda Runtime
131+
132+
You can test your Lambda function locally by invoking it with the Swift AWS Lambda Runtime.
133+
134+
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.
135+
136+
Just type `swift run` to run the Lambda function locally.
137+
138+
```sh
139+
LOG_LEVEL=trace swift run
140+
141+
# from another terminal
142+
# the `-X POST` flag is implied when using `--data`. It is here for clarity only.
143+
curl -X POST "http://127.0.0.1:7000/invoke" --data @Tests/event.json
144+
```
145+
146+
This returns the following response:
147+
148+
```text
149+
{"statusCode":200,"headers":{"content-type":"application\/json"},"body":"Hello world of swift lambda!"}
150+
```
151+
152+
## Local invocation using the AWS SAM CLI
153+
154+
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.
155+
156+
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.
157+
158+
```sh
159+
sam local invoke -e Tests/event.json
160+
161+
START RequestId: 3270171f-46d3-45f9-9bb6-3c2e5e9dc625 Version: $LATEST
162+
2024-12-21T16:49:31+0000 debug LambdaRuntime : [AWSLambdaRuntimeCore] LambdaRuntime initialized
163+
2024-12-21T16:49:31+0000 trace LambdaRuntime : lambda_ip=127.0.0.1 lambda_port=9001 [AWSLambdaRuntimeCore] Connection to control plane created
164+
2024-12-21T16:49:31+0000 debug LambdaRuntime : [APIGatewayLambda] HTTP API Message received
165+
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)
166+
END RequestId: 5b71587a-39da-445e-855d-27a700e57efd
167+
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
168+
169+
{"body": "Hello world of swift lambda!", "statusCode": 200, "headers": {"content-type": "application/json"}}
170+
```
+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the SwiftAWSLambdaRuntime open source project
4+
//
5+
// Copyright (c) 2024 Apple Inc. and the SwiftAWSLambdaRuntime project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
extension String {
16+
/// Returns a new string with the first character capitalized and the remaining characters in lowercase.
17+
///
18+
/// This method capitalizes the first character of the string and converts the remaining characters to lowercase.
19+
/// It is useful for formatting strings where only the first character should be uppercase.
20+
///
21+
/// - Returns: A new string with the first character capitalized and the remaining characters in lowercase.
22+
///
23+
/// - Example:
24+
/// ```
25+
/// let example = "hello world"
26+
/// print(example.uppercasedFirst()) // Prints "Hello world"
27+
/// ```
28+
func uppercasedFirst() -> String {
29+
let firstCharacter = prefix(1).capitalized
30+
let remainingCharacters = dropFirst().lowercased()
31+
return firstCharacter + remainingCharacters
32+
}
33+
}

Examples/Testing/Sources/main.swift

+48
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the SwiftAWSLambdaRuntime open source project
4+
//
5+
// Copyright (c) 2024 Apple Inc. and the SwiftAWSLambdaRuntime project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import AWSLambdaEvents
16+
import AWSLambdaRuntime
17+
18+
#if canImport(FoundationEssentials)
19+
import FoundationEssentials
20+
#else
21+
import Foundation
22+
#endif
23+
24+
public struct MyHandler: Sendable {
25+
26+
public func handler(event: APIGatewayV2Request, context: LambdaContext) async throws -> APIGatewayV2Response {
27+
context.logger.debug("HTTP API Message received")
28+
context.logger.trace("Event: \(event)")
29+
30+
var header = HTTPHeaders()
31+
header["content-type"] = "application/json"
32+
33+
// API Gateway sends text or URL encoded data as a Base64 encoded string
34+
if let base64EncodedString = event.body,
35+
let decodedData = Data(base64Encoded: base64EncodedString),
36+
let decodedString = String(data: decodedData, encoding: .utf8)
37+
{
38+
39+
// call our business code to process the payload and return a response
40+
return APIGatewayV2Response(statusCode: .ok, headers: header, body: decodedString.uppercasedFirst())
41+
} else {
42+
return APIGatewayV2Response(statusCode: .badRequest)
43+
}
44+
}
45+
}
46+
47+
let runtime = LambdaRuntime(body: MyHandler().handler)
48+
try await runtime.run()
+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the SwiftAWSLambdaRuntime open source project
4+
//
5+
// Copyright (c) 2024 Apple Inc. and the SwiftAWSLambdaRuntime project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import Testing
16+
17+
@testable import APIGatewayLambda // to access the business code
18+
19+
let valuesToTest: [(String, String)] = [
20+
("hello world", "Hello world"), // happy path
21+
("", ""), // Empty string
22+
("a", "A"), // Single character
23+
("A", "A"), // Single uppercase character
24+
("HELLO WORLD", "Hello world"), // All uppercase
25+
("hello world", "Hello world"), // All lowercase
26+
("hElLo WoRlD", "Hello world"), // Mixed case
27+
("123abc", "123abc"), // Numeric string
28+
("!@#abc", "!@#abc"), // Special characters
29+
]
30+
31+
@Suite("Business Tests")
32+
class BusinessTests {
33+
34+
@Test("Uppercased First", arguments: valuesToTest)
35+
func uppercasedFirst(_ arg: (String, String)) {
36+
let input = arg.0
37+
let expectedOutput = arg.1
38+
#expect(input.uppercasedFirst() == expectedOutput)
39+
}
40+
}

0 commit comments

Comments
 (0)