diff --git a/Examples/README.md b/Examples/README.md index eca88df8..0057e380 100644 --- a/Examples/README.md +++ b/Examples/README.md @@ -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. diff --git a/Examples/Testing/Package.swift b/Examples/Testing/Package.swift new file mode 100644 index 00000000..79aab087 --- /dev/null +++ b/Examples/Testing/Package.swift @@ -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) + ] +} diff --git a/Examples/Testing/README.md b/Examples/Testing/README.md new file mode 100644 index 00000000..1b4ce984 --- /dev/null +++ b/Examples/Testing/README.md @@ -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"}} +``` \ No newline at end of file diff --git a/Examples/Testing/Sources/Business.swift b/Examples/Testing/Sources/Business.swift new file mode 100644 index 00000000..af95b8e5 --- /dev/null +++ b/Examples/Testing/Sources/Business.swift @@ -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 + } +} diff --git a/Examples/Testing/Sources/main.swift b/Examples/Testing/Sources/main.swift new file mode 100644 index 00000000..af76e02c --- /dev/null +++ b/Examples/Testing/Sources/main.swift @@ -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() diff --git a/Examples/Testing/Tests/BusinessTests.swift b/Examples/Testing/Tests/BusinessTests.swift new file mode 100644 index 00000000..85f821e1 --- /dev/null +++ b/Examples/Testing/Tests/BusinessTests.swift @@ -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) + } +} diff --git a/Examples/Testing/Tests/HandlerTests.swift b/Examples/Testing/Tests/HandlerTests.swift new file mode 100644 index 00000000..7fa245f9 --- /dev/null +++ b/Examples/Testing/Tests/HandlerTests.swift @@ -0,0 +1,62 @@ +//===----------------------------------------------------------------------===// +// +// 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 +import Logging +import Testing + +@testable import APIGatewayLambda // to access the business code +@testable import AWSLambdaRuntimeCore // to access the LambdaContext + +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + +@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!") + } +} diff --git a/Examples/Testing/Tests/event.json b/Examples/Testing/Tests/event.json new file mode 100644 index 00000000..213f8bee --- /dev/null +++ b/Examples/Testing/Tests/event.json @@ -0,0 +1,35 @@ +{ + "version": "2.0", + "rawPath": "/", + "body": "aGVsbG8gd29ybGQgb2YgU1dJRlQgTEFNQkRBIQ==", + "requestContext": { + "domainPrefix": "a5q74es3k2", + "stage": "$default", + "timeEpoch": 1727726558220, + "http": { + "protocol": "HTTP/1.1", + "method": "GET", + "userAgent": "curl/8.7.1", + "path": "/", + "sourceIp": "81.0.0.43" + }, + "apiId": "a5q74es3k2", + "accountId": "012345678901", + "requestId": "e72KxgsRoAMEMSA=", + "domainName": "a5q74es3k2.execute-api.us-east-1.amazonaws.com", + "time": "30/Sep/2024:20:02:38 +0000" + }, + "rawQueryString": "", + "routeKey": "$default", + "headers": { + "x-forwarded-for": "81.0.0.43", + "user-agent": "curl/8.7.1", + "host": "a5q74es3k2.execute-api.us-east-1.amazonaws.com", + "accept": "*/*", + "x-amzn-trace-id": "Root=1-66fb03de-07533930192eaf5f540db0cb", + "content-length": "0", + "x-forwarded-proto": "https", + "x-forwarded-port": "443" + }, + "isBase64Encoded": false +} diff --git a/Examples/Testing/template.yaml b/Examples/Testing/template.yaml new file mode 100644 index 00000000..c981c978 --- /dev/null +++ b/Examples/Testing/template.yaml @@ -0,0 +1,31 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: SAM Template for APIGateway Lambda Example + +Resources: + # Lambda function + APIGatewayLambda: + Type: AWS::Serverless::Function + Properties: + CodeUri: .build/plugins/AWSLambdaPackager/outputs/AWSLambdaPackager/APIGatewayLambda/APIGatewayLambda.zip + Timeout: 60 + Handler: swift.bootstrap # ignored by the Swift runtime + Runtime: provided.al2 + MemorySize: 512 + Architectures: + - arm64 + Environment: + Variables: + # by default, AWS Lambda runtime produces no log + # use `LOG_LEVEL: debug` for for lifecycle and event handling information + # use `LOG_LEVEL: trace` for detailed input event information + LOG_LEVEL: trace + Events: + HttpApiEvent: + Type: HttpApi + +Outputs: + # print API Gateway endpoint + APIGatewayEndpoint: + Description: API Gateway endpoint UR" + Value: !Sub "https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com"