Skip to content
/ Relay Public

Swift Dynamic Dependency Injection for modern testing

License

Notifications You must be signed in to change notification settings

mindbody/Relay

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

43 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Relay

Release Build Status Carthage Compatible CocoaPods Compatible Platform

Relay is a dynamic dependency injection framework that uses IoC (Inversion of Control) Containers and builds upon them to make integration testing dependable, focused, and efficient.

By passing a list of command line arguments, a driver program can instruct the system to inject specific dependencies (usually stubs) to keep integration predetermined and to maximize the number of valid assertions. For example, when running an iOS UI test via XCTest, we can instruct the application to inject stubbed backend services so that our UI tests only validate frontend behavior and layout.

This framework aims to champion the Test Pyramid. For most applications, this allows the separation of UI tests from End-to-End tests.

Requirements

  • iOS 8.0+ / macOS 10.10+ / tvOS 9.0+ / watchOS 2.0+ / Linux
  • Xcode 10+ / Swift 4.2+

Installation

Carthage

Add Relay to your Cartfile:

github 'mindbody/Relay'

CocoaPods

Add Relay to your Podfile:

pod 'Relay'

Swift Package Manager

Add Relay to your Package.swift

// swift-tools-version:5.0
import PackageDescription

let package = Package(
    dependencies: [
        .package(url: "https://github.com/mindbody/Relay.git", from: "0.3.1")
    ]
)

Getting Started

While Relay provides powerful tools, it is up to developers to structure code in order to use them. See Best Practices for ways to achieve this.

Relay uses IoC Containers at its foundation. For detailed information, see Relay Architecture.

Create a Dependency Registry

A DependencyRegistry is responsible for registering concrete factories to resolvable types within the application. Each registry does so by interacting with DependencyContainers (IoC Containers). Before a driver can inject its own dependencies, you must set up your project for dependency injection. Usually, a single registry is sufficient.

/// DefaultDependencyRegistry.swift

final class DefaultDependencyRegistry: DependencyRegistryType {

  func registerDependencies() throws {
    DependencyContainer.global.register(MyBackendServiceType.self) { _ in
      MyBackendService()
    }
    /// Recursive dependencies are lazily resolved
    DependencyContainer.global.register(MyViewControllerDataStoreType.self) { container in
        MyViewControllerDataStore(backendService: container.resolve())
    }
    /// etc.
  }

}

Register Dependencies

Your default DependencyRegistryType should execute at the very start of the program. In an application, this typically belongs in your AppDelegate:

/// AppDelegate.swift

final class AppDelegate: UIResponder, UIApplicationDelegate {
  func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    registerDependencies();
    /// Configure app...
  }

  private func registerDependencies() {
    do {
      let defaultRegistry = DefaultDependencyRegistry()
      try defaultRegistry.registerDependencies()
    }
    catch {
      fatalError(error.localizedDescription)
    }
  }
}

Consume Registered Dependencies

If you've followed our Best Practices, then this should be fairly simple. Where dependencies are manually resolved or injected, they should be replaced with resolutions from an IoC Container:

/// SampleViewController.swift

final class SampleViewController {

  private func showNextViewController() {
    let container = DependencyContainer.global
    let dataStore = container.resolve(MyViewControllerDataStoreType.self)
    let viewController = MyViewController(dataStore: dataStore)
    navigationController?.pushViewController(viewController, animated: true)
  }

}

With Swift 5.1, Relay supports auto-injection via a custom property wrapper. This allows your interface to be more declarative and for dependency access semantics to be more prescriptive:

final class MyDependentComponent {

  @Injected var foo: FooType
  @Injected(scope: .custom) var bar: BarType

}

This is equivalent to:

final class MyDependentComponent {

  lazy var foo: FooType = DependencyContainer.global.resolve()
  lazy var bar: BarType = DependencyContainer.container(for: .custom).resolve()

}

Dynamic Dependencies

Relay has its own custom type of DependencyRegistry for dynamic dependencies, known as DynamicDependencyRegistry. It does so by resolving a list of identifiable types and factories from a provided DynamicDependencyIndex, which defaults to DynamicDependencyIndex.shared. In most cases, you won't need to interact directly with DynamicDependencyRegistry.

Index Types and Factories

In Relay, abstract types are uniquely identifiable by a DependencyTypeKey, and concrete factories are uniquely identifiable by a DependencyFactoryKey. In order to index types and factories for dynamic registration, you must provide the target DynamicDependencyIndex a list of DependencyTypeIndexable and DependencyFactoryIndexable. Similar to a DependencyRegistryType, these types define a list of types and factories, except they do not register them.

Since types are universal, you'll likely only need a single DependencyTypeIndexable:

/// DefaultDependencyTypeIndex.swift

final class DefaultDependencyTypeIndex: DependencyTypeIndexable {
    let index: [DependencyTypeKey: Any.Type] = [
        .myViewControllerDataStore: MyViewControllerDataStoreType.self,
        .myBackendService: MyBackendServiceType.self,
        /// etc.
    ]
}

Types to factories are one-to-many, so you may end up creating multiple types that implement DependencyFactoryIndexable. One method of organization is to separate factory indices based on the same purpose as defined in each DependencyFactoryKey, if following our Best Practices. This purpose may describe a unit of functionality, describe a specific behavior, or identify a specific test or test suite:

/// TestSuite12345DependencyFactoryIndex.swift

final class TestSuite12345DependencyFactoryIndex: DependencyFactoryIndexable {
  let index: [DependencyFactoryKey: (DependencyContainer) -> Any] = TestSuite12345DependencyFactoryIndex.makeIndex()

  private static func makeIndex() -> [DependencyFactoryKey: (DependencyContainer) -> Any] {
    let backendServiceFactory: (DependencyContainer) -> Any = { _ in
      let backendServiceStub = MyBackendServiceStub()
      backendServiceStub.responseUnderTest = [SampleData(), SampleData()]
      return backendServiceStub
    }

    return [
      .testCase56789BackendService: backendServiceFactory
    ]
  }
}

Command Line Injection

Pulling it all together, Relay provides tools to register these dynamic dependencies via command line arguments. This means that a driver, such as a UI Test runner, has the ability to instruct the target application to inject specific dependencies.

These arguments are formatted as such:

[program-run] [-d, --dependency] type=<type>,factory=<factory>[,scope=<scope>][,lifecycle=<lifecyle>]

The value passed to --dependency is called a DependencyInjectionInstruction. The input parameters describe:

  • type: The type identifier, which should match the target DependencyTypeKey
  • factory: The factory identifier, which should match the target DependencyFactoryKey
  • scope: The scope identifier, or "global" if not specified
  • lifecycle: The dependency lifecycle type (singleton|transient), or "singleton" if not specified

These arguments are provided to an InjectDependenciesArgumentParser, which communicates with a DynamicDependencyRegistry. For Xcode projects, you'll need to update your AppDelegate:

/// AppDelegate.swift

final class AppDelegate: UIResponder, UIApplicationDelegate {
  func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    registerDependencies();
    /// Configure app...
  }

  private func registerDependencies() {
    do {
      let defaultRegistry = DefaultDependencyRegistry()
      try defaultRegistry.registerDependencies()

      #if DEBUG
      /// Release builds likely should avoid promoting this behavior
      DynamicDependencyIndex.shared.add(DefaultDependencyTypeIndex())
      DynamicDependencyIndex.shared.add(TestSuite12345DependencyFactoryIndex())
      DynamicDependencyIndex.shared.add(AnotherTestFactoryIndex())
      /// etc.

      try CommandLine.parse(with: [InjectDependenciesArgumentParser()])
      #endif
    }
    catch {
      fatalError(error.localizedDescription)
    }
  }
}

Driver Tests

For efficiency and clean formatting, Relay provides a LaunchArgumentBuilder, which is useful for building a list of dependency instructions to send to the command line. LaunchArgumentBuilder takes in a list of types that conform to LaunchArgument. For most common scenarios, Relay provides DependencyInstructionLaunchArgument.

For example, if our Xcode-built application has a suite of UI tests that need stubbed services, we can structure it as such:

final class Suite12345Tests: XCTestCase {

  func testCase56789ShowsCorrectNumberOfCells() throws {
    let injectionInstructionArgument = DependencyInstructionLaunchArgument(type: .myBackendService, factory: .testCase56789BackendService)
    let builder = LaunchArgumentBuilder(arguments: [injectionInstructionArgument])

    let app = XCUIApplication()
    app.launchArguments = builder.build()
    app.launch()

    /// Add assertions, etc.
  }

}

This is just a rough example, but a starting point for converting end-to-end tests into focused UI tests.


Advanced Topics

Dependency Lifecycles

By default, Relay dependencies are lazily created using their type-mapped concrete factories. Since they are created once and only once per DependencyContainer, they have a LifecycleType of singleton.

While this tends to be sufficient for services, more-granular DI, especially in the weeds of your application, will prefer short-lived dependencies. These are known as transient dependencies, which is configurable only upon dependency registration:

final class DefaultDependencyRegistry: DependencyRegistryType {

  func registerDependencies() throws {
    /// This factory will be called every time MyViewControllerDataStoreType is resolved
    DependencyContainer.global.register(MyViewControllerDataStoreType.self, lifecycle: .transient) { _ in
      MyViewControllerDataStore()
    }
    /// etc.
  }

Dependency lifecycles are also configurable via command line. See Command Line Injection for documentation on the lifecycle parameter.

Container Scope

Since Relay uses type-mapping for strongly-typed dependency resolution, your code might start to become a bit ugly once a concrete factory does not satisfy all callers. For these cases, we can instead use nested containers, keyed by a DependencyContainerScope.

Generally, container scope becomes more useful as dependencies become more granular. For example, we might have a view controller whose presented data can consume a number of different services. These details can be handled by injecting multiple data sources for the view controller:

/// MyDependencyScopes.swift

extension DependencyContainerScope {

  static var useCase1 = DependencyContainerScope("useCase1")
  static var useCase2 = DependencyContainerScope("useCase2")

}

/// MyScopedDependencyRegistry.swift

MyScopedDependencyRegistry: DependencyRegistryType {

  func registerDependencies() throws {
    DependencyContainer.useCase1.register(MyViewControllerDataStoreType.self, lifecycle: .transient) { _ in
      MyViewControllerFirstUseCaseDataStore()
    }
    DependencyContainer.useCase2.register(MyViewControllerDataStoreType.self, lifecycle: .transient) { _ in
      MyViewControllerSecondUseCaseDataStore()
    }
    /// etc.
  }

}

When a scoped container fails to resolve a type, resolution falls back to the global container. Together, scoped and global containers form a dependency graph. To enforce simple dependency graphs, and for sensibility in Relay architecture, dependency scope only extends one layer beneath the global container. For detailed information, see Relay Architecture.

Dependency scope is also configurable via command line. See Command Line Injection for documentation on the scope parameter.

Credits

mindbody-logo

Relay is owned by MINDBODY, Inc. and continuously maintained by our contributors.