Skip to main content

Dependency Injection

Introduction

Autometa supports the dependency injection pattern, which it uses to automatically assemble complex relationships between, in particular, classes.

A class can be marked as injectable using the Fixture decorator:

@Fixture()
class Foo {}

By default, fixtures are marked as Cached, meaning that a single instance is shared between all dependants. This can be changed by passing and Injection Scope, of which there are Cached, as discussed, Transient, and Singleton.

A transient dependency is never cached. Each dependant property will be instantiated with a new instance of the transient dependency.

A cached depenenency is cached for the lifetime of the test container. A new Test container is created for each Scenario being tested. Once instantiated, that cached instance will be used in all other dependant properties. However, a new instance is created for each new Scenario and cannot be shared between them

A singleton dependency behaves similar to a cached dependency, except that the instance created is reused across all test containers. This means for each Feature file, only one copy of this dependency will exist across all test scenarios.

@Fixture(InjectionScope.Singleton)
class SingletonFoo {}

@Fixture(InjectionScope.Transient)
class TransientFoo {}

Dependencies

Dependencies are injected into a class using one of the Inject decorators. There are presently 3 such decorators:

  • Inject.class - Injects an instance of a class into the property. If the class is marked singleton or cached, the existing instance will be reused.
  • Inject.factory - Accepts a factory function whose return value will be injected into the property.
  • Inject.value - Injects a literal value, such as a string, number, array or anonymous object.
@Fixture()
class Foo {
@Inject.class(Bar)
bar: Bar;

@Inject.factory(() => new Baz())
baz: Baz;

@Inject.value("Hello World")
message: string;
}

Constructor Injection

Autometa also supports constructor injection. This allows you to describe a constructor to behave as you wish.

To match the constructor to it's dependencies, the Constructor decorator may be used. This decorator accepts a list of fixture classes or tokens. The order of this list must match the order of the constructor arguments.

@Fixture()
@Constructor(Bar, Baz, Token("message"))
class Foo {
constructor(bar: Bar, baz: Baz, message: string) {}
}

Interacting with a Container

The container is available in Hooks and Step Definitions through the app, where it is attached as the di property. It can be used to register new dependencies without relying on the @Fixture syntax. Note that fixtures have already been constructed at this point, so dependencies defined here will not be available through static injection via decorators if that class is instantiated before the new dependency is registered.

Before("I register a value", async (app) => {
const asyncData = await app.myClient.getAsyncData();
app.di.registerValue(Token("asyncData"), asyncData);
});

After("I use the registered value", (app) => {
const asyncData = app.di.get(Token("asyncData"));
});

Dynamically adding dependencies

Dependencies can be defined or accessed during test execution using the app.di property. This property allows you to define types or singleton values to be injected.

note

A dependency can only be used in constructors if it is defined before the constructed class is instantiated. That means that a dependecy defined in a Setup hook is not available as a constructor argument to other Setup hooks, and a dependency defined in a Before or step (Given, When, Then) definition is not available as a constructor argument to that, or other, scenarios as constructor parameters, because by the time they are defined, all (or almost all) classes have already been instantiated.

i.e calling app.di.register in a Setup hook cannot be used to construct other Setup hook scope classes, but will be available to classes in the same feature file. Similarly, calling app.di.register in a Before hook will not be available to the constructor of the scenario, but will be available to the scenario itself via app.di.get.

// setup.hooks.ts
Setup("I register a value", async (app) => {
app.di.registerSingletonValue(Token('my token'), "Hello World");
});

// my-class.ts
@Fixture()
@Constructor(Token('my token'))
class MyClass {
constructor(myToken: string) {
console.log(myToken); // "Hello World"
}
}

// before.hooks.ts

Before("I use the registered value", async (app) => {
const myToken = app.di.registerSingleton(MyClass, MyClass);
});

// my-class.ts
@Fixture()
@Constructor(Token('my token'), MyClass)
class MyClass {
/**
* 'myToken' will be injected with the value "Hello World"
* 'myClass' will fail to be injected, as it was already created before the dependency was defined
*/
constructor(myToken: string, myClass: MyClass) {
console.log(myToken); // "Hello World"
console.log(myClass); // undefined or error thrown
}
}
info

The Token function is available from @autometa/injection which will be accessible when @autometa/runner is installed.

A dynamic dependency can always be accessed via app.di.get, provided it was performed before any attempt to access it. I.e, as long as hooks that depenend on it are executed after the defining hook, it is accessible;

Before("I register a value", async (app) => {
app.di.get(MyDynamicClass, MyDynamicClass); // fails, as no value has been registered
});

Before("I register a value", async (app) => {
app.di.registerSingletonValue(MyDynamicClass, new MyDynamicClass());
});

Before("I use the registered value", async (app) => {
const myDynamicClass = app.di.get(MyDynamicClass, MyDynamicClass); // succeeds
});

Registration methods

The di property supports the following methods (and/or overloads)

  • registerCached - Registers a class as a cached dependency
    • registerCached(Token('myToken'), MyClass)
    • registerCached(MyClass, MyClass)
  • registerSingleton - Registers a class as a singleton dependency
    • registerSingleton(Token('myToken'), MyClass)
    • registerSingleton(MyClass, MyClass)
  • registerTransient - Registers a class as a transient dependency
    • registerTransient(Token('myToken'), MyClass)
    • registerTransient(MyClass, MyClass)
  • registerSingletonValue - Registers a value as a singleton dependency
    • registerSingletonValue(Token('myToken'), "Hello World")
    • registerSingletonValue(MyClass, new MyClass())

Registering values outside of test flow

It is possible to define singleton values outside of all test flow by using the registerSingleton function from @autometa/injection. This function accepts a token and a value, and will make that value available to all classes that depend on it.

import { registerSingleton, Token } from '@autometa/injection';
import { Fixture } from '@autometa/runner';

registerSingleton(Token('myToken'), "Hello World");

@Fixture
@Constructor(
Token('myToken'),
MyClass
)
class MyOtherClass {
constructor(myToken: string, myClass: MyClass) {
console.log(myToken); // "Hello World"
}
}

Tokens

Under the hood, tokens are defined as symbol types, however unlike calling Symbol(key) directly, the Token function will always return the same symbol for the same key. This means that you can use the same token in different files and they will be treated as the same token.

However it might still be desirable to cache tokens, for example on a InjectionToken object:

import { Token } from '@autometa/injection';

const InjectionToken = {
myToken: Token('myToken');
}

Disposable Dependencies

A dependency can be made disposable by implementing the Disposable interface. This interface requires a method defined using the DisposeMethod symbol on the class or object. This method will be called at the end of a scenario.

Alternativiely, DisposeGlobal can be used to define a method that will be called at the end of the test run, when all tests have completed. This is used to clean up resources in the copy of the App available to the Feature scope and Setup and Teardown hooks, as well as BeforeFeature, AfterFeature, BeforeScenarioOutline etc. hooks.

import { DisposeMethod, DisposeGlobal } from '@autometa/injection';

@Fixture()
class DisposableClass {
[DisposeMethod]() {
console.log("I am being disposed");
}

[DisposeGlobalMethod]() {
console.log("I am being disposed globally");
}
}

Filtering Disposable Dependencies

A disposer method can be filtered by the test (or features) tags using tag expressions. This is added with the DisposeTagFilter decorator.

import { DisposeMethod, DisposeTagFilter } from '@autometa/injection';

@Fixture()
class DisposableClass {
@DisposeTagFilter("@tag1 and not @tag2")
[DisposeMethod]() {
console.log("I am being disposed");
}
}

Singletons

Singletons exist in all scopes within a test suite. Be careful when disposing of singletons, as they might have unexpected behavior.