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

Additional Test Helpers #10

merged 10 commits into from
Jan 26, 2024

Conversation

mgacy
Copy link
Collaborator

@mgacy mgacy commented Jan 19, 2024

This includes multiple helpers intended to improve testing.

  • adds MockContext.Configuration to make it easier to create / pass around the the context's data
  • adds Logger.mock
  • adds our mocked() functions
  • adds a ContextProvider (not totally sure about the name) to abstract the creation of mock initialization and runtime contexts for testing and the management of their resources.

Other:

  • This also adds RuntimeContext.getRemainingTime() to fully represent LambdaContext.

Demonstration of ContextProvider:

The idea is to replace:

final class MyHandlerTests: XCTestCase {
    var eventLoopGroup: EventLoopGroup!

    var logger: Logger {
        .init(
            label: "mock-logger",
            factory: { _ in StreamLogHandler.standardOutput(label: "mock-logger") })
    }

    var environmentValueProvider: @Sendable (Environment) throws -> String = { variable in
        switch variable {
        default:
            return ""
        }
    }

    override func setUp() {
        self.eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1)
    }

    override func tearDown() {
        XCTAssertNoThrow(try self.eventLoopGroup.syncShutdownGracefully())
    }

    func makeInitializationContext() -> MockInitializationContext<Environment> {
        .init(
            logger: logger,
            eventLoop: eventLoopGroup.next(),
            allocator: .init(),
            environmentValueProvider: environmentValueProvider)
    }

    func makeContext() -> MockContext<Environment> {
        .init(
            eventLoop: eventLoopGroup.next(),
            environmentValueProvider: environmentValueProvider)
    }

    func testMyHandler() async throws {}
}

with:

final class MyBetterHandlerTests: XCTestCase {
    var contextProvider = ContextProvider<Environment>() { variable in
        switch variable {
        default:
            return ""
        }
    }

    override func setUp() {
        contextProvider.setUp()
    }

    override func tearDown() {
        XCTAssertNoThrow(try contextProvider.shutdown())
    }

    func testMyHandler() async throws {}
}

An XCTestCase subclass would be an alternative, but that's not really something I've seen much of.

/// }
/// }
/// ```
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!

@mgacy mgacy merged commit 5405f25 into main Jan 26, 2024
1 check passed
@mgacy mgacy deleted the feature/additional-test-support branch January 26, 2024 19:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants