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

Additional Test Helpers #10

Merged
merged 10 commits into from
Jan 26, 2024
Merged
Show file tree
Hide file tree
Changes from 6 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
3 changes: 2 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ let targets: [Target] = [
.target(
name: "LambdaMocks",
dependencies: [
"LambdaExtrasCore"
"LambdaExtrasCore",
.product(name: "NIO", package: "swift-nio")
]
),
.testTarget(
Expand Down
29 changes: 19 additions & 10 deletions Sources/LambdaExtras/Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,29 @@ import Foundation
import LambdaExtrasCore
import NIOCore

extension Lambda {
/// Returns the value of the environment varialbe with the given name.
///
/// This method throws ``EventHandler.envError`` if a value for the given environment variable
/// name is not found.
///
/// - Parameter name: The name of the environment variable to return.
/// - Returns: The value of the given environment variable.
static func env(name: String) throws -> String {
guard let value = env(name) else {
throw HandlerError.envError(name)
}

return value
}
}

public extension EnvironmentValueProvider where EnvironmentVariable == String {
/// Returns the value of the given environment variable.
///
/// - Parameter environmentVariable: The environment variable whose value should be returned.
func value(for environmentVariable: EnvironmentVariable) throws -> String {
guard let value = Lambda.env(environmentVariable) else {
throw HandlerError.envError(environmentVariable)
}

return value
try Lambda.env(name: environmentVariable)
}
}

Expand All @@ -29,11 +42,7 @@ public extension EnvironmentValueProvider where EnvironmentVariable: RawRepresen
///
/// - Parameter environmentVariable: The environment variable whose value should be returned.
func value(for environmentVariable: EnvironmentVariable) throws -> String {
guard let value = Lambda.env(environmentVariable.rawValue) else {
throw HandlerError.envError(environmentVariable.rawValue)
}

return value
try Lambda.env(name: environmentVariable.rawValue)
}
}

Expand Down
3 changes: 3 additions & 0 deletions Sources/LambdaExtrasCore/Protocols/RuntimeContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,7 @@ public protocol RuntimeContext: Sendable {

/// `ByteBufferAllocator` to allocate `ByteBuffer`.
var allocator: ByteBufferAllocator { get }

/// Returns the time remaining before the deadline.
func getRemainingTime() -> TimeAmount
}
122 changes: 122 additions & 0 deletions Sources/LambdaMocks/ContextProvider.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
//
// ContextProvider.swift
// LambdaExtras
//
// Created by Mathew Gacy on 1/7/24.
//

import Foundation
import Logging
import NIOCore
import NIO

/// A helper to create and manage mock initialization and runtime contexts for testing.
///
/// Example usage:
///
/// ```swift
/// final class MyHandlerTests: XCTestCase {
/// var contextProvider: ContextProvider<MyEnvironment>
///
/// override func setUp() {
/// contextProvider.setUp()
/// }
///
/// override func tearDown() {
/// XCTAssertNoThrow(try contextProvider.shutdown())
/// }
///
/// func testMyHandler() async throws {
/// let sut = try await MyHandler(context: contextProvider.makeInitializationContext())
/// let actual = try await sut.handle(MockEvent(), context: contextProvider.makeContext())
/// ...
/// }
/// }
/// ```
public struct ContextProvider<E> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a nit that E may be too concise for a "environment variable".

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, especially when I have used EnvironmentVariable elsewhere

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like it is E on MockContext as well 🤷‍♂️

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated MockContext and MockInitializationContext

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does bring up some confusion on my part around why these types are generic. I realize this relates to code that is already approved/merged, however Lambda.env(_:) takes a concrete String type. Could you clarify the added flexibility?

Copy link
Collaborator Author

@mgacy mgacy Jan 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The basic idea is types > strings

Let's say I define 2 environment variables for my Lambda in my SAM template:

  • bar
  • baz

Using Lambda.env(_:) the compiler would allow:

guard let bar = Lambda.env("barrr") else {
    throw HandlerError.envError("bar")
}

With this I can do:

enum Environment: String {
    /// Explanation of `bar`
    case bar
    /// Explanation of `baz`
    case baz
}

extension LambdaInitializationContext: EnvironmentValueProvider {
    public typealias EnvironmentVariable = Environment
}

extension LambdaContext: EnvironmentValueProvider {
    public typealias EnvironmentVariable = Environment
}

...

let bar = try context.value(for: .bar)

While I do offer a LambdaExtras.DefaultEnvironment type, in most cases there will be a project-specific Environment type defined in a target using this package that represents the additional variables that are defined in the template or through the AWS console. The generics allow the contexts and other types to support those externally-defined types.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is supported by:

extension Lambda {
    /// Returns the value of the environment varialbe with the given name.
    ///
    /// This method throws ``EventHandler.envError`` if a value for the given environment variable
    /// name is not found.
    ///
    /// - Parameter name: The name of the environment variable to return.
    /// - Returns: The value of the given environment variable.
    static func env(name: String) throws -> String {
        guard let value = env(name) else {
            throw HandlerError.envError(name)
        }

        return value
    }
}

public extension EnvironmentValueProvider where EnvironmentVariable: RawRepresentable<String> {
    /// Returns the value of the given environment variable.
    ///
    /// - Parameter environmentVariable: The environment variable whose value should be returned.
    func value(for environmentVariable: EnvironmentVariable) throws -> String {
        try Lambda.env(name: environmentVariable.rawValue)
    }
}

Copy link
Collaborator Author

@mgacy mgacy Jan 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, there are other ways to get around the issues I pointed to above, but more generally, this setup means that instead of:

import AWSLambdaRuntimeCore

struct Foo {
    var stuff: (LambdaContext) async throws -> String
}

extension Foo: DependencyKey {
    static var liveValue = Foo(
        // To control environment variables we need to either export them before calling
        // `swift test` or access them inside handler dependencies that we'll mock
        // in tests
        stuff: { context in
            let bar = try Lambda.env(.bar)
                .unwrap(or: "Could not find bar in env vars.")
            
            ... other stuff having nothing to do with `context`
        }
    )
}

public struct MyHandler {
    @Dependency(\.foo) var foo
    
    public func run(with event: Event, context: LambdaContext) async throws {
        let stuff = try await foo.stuff(context)
        // `LambdaContext` gets this by subtracting now from `.deadline` so
        // we'd need to use delays to test
        if context.getRemainingTime() > 100000 {
            ...
        }
    }
}

You can do:

import LambdaExtrasCore // No AWS dependencies

struct Foo {
    // Doesn't need to know about the context
    var stuff: (String) async throws -> String
}

public struct MyHandler {
    @Dependency(\.foo) var foo
    
    public func run<C>(
        with event: Event,
        context: C
    ) async throws -> String where C: RuntimeContext & EnvironmentValueProvider<Environment> {
        /// Can just access this here and mock
        let bar = try context.value(for: .bar)
        let stuff = try await foo.stuff(bar)
        
        /// Can easily mock this via `MockContext.remainingTimeProvider`
        if context.getRemainingTime() > 100000 {
            ...
        }
    }
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, I'm tracking better now. Thanks!

/// The event loop group used to provide the contexts' event loops.
public private(set) var eventLoopGroup: EventLoopGroup!

/// The event loop for the contexts.
public private(set) var eventLoop: EventLoop!

/// The logger for the contexts.
public var logger: Logger

/// A closure returning the value of the given environment variable.
public var environmentValueProvider: @Sendable (E) throws -> String

/// Creates an instance.
///
/// - Parameter environmentValueProvider: A closure returning the value of the given
/// environment variable.
public init(
logger: Logger = .mock,
environmentValueProvider: @escaping @Sendable (E) throws -> String
) {
self.logger = logger
self.environmentValueProvider = environmentValueProvider
}

/// Sets up the event loop used for the provided initialization and runtime contexts.
///
/// Call this in your test class's `setUp()` method:
///
/// ```swift
/// final class MyHandlerTests: XCTestCase {
/// var contextProvider: ContextProvider<MyEnvironment>
/// ...
/// override func setUp() {
/// contextProvider.setUp()
/// ...
/// }
/// }
/// ```
public mutating func setUp() {
eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1)
eventLoop = eventLoopGroup.next()
}

/// Shuts the event loop group down.
///
/// Call this in your test class's `.tearDown()` method:
///
/// ```swift
/// final class MyHandlerTests: XCTestCase {
/// var contextProvider: ContextProvider<MyEnvironment>
/// ...
/// override func tearDown() {
/// XCTAssertNoThrow(try contextProvider.shutdown())
/// ...
/// }
/// }
/// ```
public mutating func shutdown() throws {
defer {
eventLoop = nil
eventLoopGroup = nil
}
try eventLoopGroup.syncShutdownGracefully()
}

/// Returns the mocked initialization context.
public func makeInitializationContext() -> MockInitializationContext<E> {
.init(
logger: logger,
eventLoop: eventLoop,
allocator: .init(),
environmentValueProvider: environmentValueProvider)
}

/// Returns the mocked runtime context.
///
/// - Parameter configuration: The configuration for the mocked runtime context.
public func makeContext(
configuration: MockContext<E>.Configuration = .init()
) -> MockContext<E> {
.init(
eventLoop: eventLoop,
configuration: configuration,
environmentValueProvider: environmentValueProvider)
}
}
15 changes: 15 additions & 0 deletions Sources/LambdaMocks/Extensions/Dispatch+Utils.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//
// Dispatch+Utils.swift
// LambdaExtras
//
// Created by Mathew Gacy on 1/19/24.
//

import Dispatch

extension DispatchWallTime {
/// The interval between the point and its reference point.
var millisecondsSinceEpoch: Int64 {
Int64(bitPattern: self.rawValue) / -1_000_000
}
}
16 changes: 16 additions & 0 deletions Sources/LambdaMocks/Extensions/Logger+Utils.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//
// Logger+Utils.swift
// LambdaExtras
//
// Created by Mathew Gacy on 1/7/24.
//

import Foundation
import Logging

public extension Logger {
/// A logger for use in ``MockContext`` and ``MockInitializationContext``.
static let mock = Logger(
label: "mock-logger",
factory: { _ in StreamLogHandler.standardOutput(label: "mock-logger") })
}
98 changes: 82 additions & 16 deletions Sources/LambdaMocks/MockContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ public struct MockContext<E>: RuntimeContext, EnvironmentValueProvider {
public var eventLoop: EventLoop
public var allocator: ByteBufferAllocator

/// A closure returning a `TimeAmount` from a given `DispatchWallTime`.
///
/// This is used to return the remaining time until the context's ``deadline``.
public var remainingTimeProvider: @Sendable (DispatchWallTime) -> TimeAmount

/// A closure returning the value of the given environment variable.
public var environmentValueProvider: @Sendable (E) throws -> String

Expand All @@ -38,6 +43,7 @@ public struct MockContext<E>: RuntimeContext, EnvironmentValueProvider {
/// - logger: The logger.
/// - eventLoop: The event loop.
/// - allocator: The byte buffer allocator.
/// - remainingTimeProvider: A closure returning a `TimeAmount` from a given `DispatchWallTime`.
/// - environmentValueProvider: A closure returning the value of the given environment
/// variable.
public init(
Expand All @@ -50,6 +56,7 @@ public struct MockContext<E>: RuntimeContext, EnvironmentValueProvider {
logger: Logger,
eventLoop: EventLoop,
allocator: ByteBufferAllocator,
remainingTimeProvider: @escaping @Sendable (DispatchWallTime) -> TimeAmount,
environmentValueProvider: @escaping @Sendable (E) throws -> String
) {
self.requestID = requestID
Expand All @@ -61,45 +68,104 @@ public struct MockContext<E>: RuntimeContext, EnvironmentValueProvider {
self.logger = logger
self.eventLoop = eventLoop
self.allocator = allocator
self.remainingTimeProvider = remainingTimeProvider
self.environmentValueProvider = environmentValueProvider
}

public func getRemainingTime() -> TimeAmount {
remainingTimeProvider(deadline)
}

public func value(for environmentVariable: E) throws -> String {
try environmentValueProvider(environmentVariable)
}
}

public extension MockContext {

/// Configuration data for ``MockContext``.
struct Configuration {
/// The request ID, which identifies the request that triggered the function invocation.
public var requestID: String

/// The AWS X-Ray tracing header.
public var traceID: String

/// The ARN of the Lambda function, version, or alias that's specified in the invocation.
public var invokedFunctionARN: String

/// The time interval before the context's deadline.
public var timeout: DispatchTimeInterval

/// For invocations from the AWS Mobile SDK, data about the Amazon Cognito identity provider.
public var cognitoIdentity: String?

/// For invocations from the AWS Mobile SDK, data about the client application and device.
public var clientContext: String?

/// Creates an instance.
///
/// - Parameters:
/// - requestID: The request ID.
/// - traceID: The AWS X-Ray tracing header.
/// - invokedFunctionARN: The ARN of the Lambda function.
/// - timeout: The time interval before the context's deadline.
/// - cognitoIdentity: Data about the Amazon Cognito identity provider.
/// - clientContext: Data about the client application and device.
public init(
requestID: String = "\(DispatchTime.now().uptimeNanoseconds)",
traceID: String = "Root=\(DispatchTime.now().uptimeNanoseconds);Parent=\(DispatchTime.now().uptimeNanoseconds);Sampled=1",
invokedFunctionARN: String = "arn:aws:lambda:us-east-1:\(DispatchTime.now().uptimeNanoseconds):function:custom-runtime",
timeout: DispatchTimeInterval = .seconds(5),
cognitoIdentity: String? = nil,
clientContext: String? = nil
) {
self.requestID = requestID
self.traceID = traceID
self.invokedFunctionARN = invokedFunctionARN
self.timeout = timeout
self.cognitoIdentity = cognitoIdentity
self.clientContext = clientContext
}
}

/// Returns the time interval between a given point in time and the current time.
///
/// - Parameter deadline: The time with which to compare now.
/// - Returns: The time interval between the given deadline and now.
@Sendable
static func timeAmountUntil(_ deadline: DispatchWallTime) -> TimeAmount {
.milliseconds(deadline.millisecondsSinceEpoch - DispatchWallTime.now().millisecondsSinceEpoch)
}

/// Creates a new instance.
///
/// - Parameters:
/// - timeout: The time interval at which the function will time out.
/// - requestID: The request ID.
/// - traceID: The tracing header.
/// - invokedFunctionARN: The ARN of the Lambda function.
/// - eventLoop: The event loop.
/// - configuration: The context configuration.
/// - logger: The logger.
/// - allocator: The byte buffer allocator.
/// - remainingTimeProvider:
/// - environmentValueProvider: A closure returning the value of the given environment
/// variable.
init(
timeout: DispatchTimeInterval = .seconds(3),
requestID: String = UUID().uuidString,
traceID: String = "abc123",
invokedFunctionARN: String = "aws:arn:",
eventLoop: EventLoop,
configuration: Configuration = .init(),
logger: Logger = .mock,
allocator: ByteBufferAllocator = .init(),
remainingTimeProvider: @escaping @Sendable (DispatchWallTime) -> TimeAmount = Self.timeAmountUntil,
environmentValueProvider: @escaping @Sendable (E) throws -> String
) {
self.requestID = requestID
self.traceID = traceID
self.invokedFunctionARN = invokedFunctionARN
self.deadline = .now() + timeout
self.logger = Logger(
label: "mock-logger",
factory: { _ in StreamLogHandler.standardOutput(label: "mock-logger") }
)
self.requestID = configuration.requestID
self.traceID = configuration.traceID
self.invokedFunctionARN = configuration.invokedFunctionARN
self.deadline = .now() + configuration.timeout
self.cognitoIdentity = configuration.cognitoIdentity
self.clientContext = configuration.clientContext
self.logger = logger
self.eventLoop = eventLoop
self.allocator = allocator
self.remainingTimeProvider = remainingTimeProvider
self.environmentValueProvider = environmentValueProvider
}
}
2 changes: 1 addition & 1 deletion Sources/LambdaMocks/MockInitializationContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public class MockInitializationContext<E>: InitializationContext, EnvironmentVal
/// - environmentValueProvider: A closure returning the value of the given environment
/// variable.
public init(
logger: Logger,
logger: Logger = .mock,
eventLoop: EventLoop,
allocator: ByteBufferAllocator,
handlers: [(EventLoop) -> EventLoopFuture<Void>] = [],
Expand Down
Loading