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.
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
}
}
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 dependencyregisterCached(Token('myToken'), MyClass)
registerCached(MyClass, MyClass)
registerSingleton
- Registers a class as a singleton dependencyregisterSingleton(Token('myToken'), MyClass)
registerSingleton(MyClass, MyClass)
registerTransient
- Registers a class as a transient dependencyregisterTransient(Token('myToken'), MyClass)
registerTransient(MyClass, MyClass)
registerSingletonValue
- Registers a value as a singleton dependencyregisterSingletonValue(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.