-
Notifications
You must be signed in to change notification settings - Fork 9
5. Dependency injection
Dependency injection requires an interface. Below is a simple example:
public interface ICounterService
{
void IncrementCount();
int GetCount();
}
public class CounterService : ICounterService
{
private int _count;
public void IncrementCount() => _count++;
public int GetCount() => _count;
}
The [Dependency]
attribute is used to specify a dependency and its default implementation. Then, UIComponent's protected Provide<T>
method
is used to inject it.
using UIComponents;
// First, define the interface we have a dependency to.
// Second, define the concrete implementation of that
// interface we want. Since source generation is used,
// our class must be declared as partial.
[Dependency(typeof(ICounterService), provide: typeof(CounterService))]
public partial class CounterComponent : UIComponent
{
private readonly ICounterService _counterService;
private readonly Label _countLabel;
public CounterComponent()
{
// Provide<T> gives you an instance of the dependency, which
// in this case is CounterService, unless it is overridden
// i.e. in a test
_counterService = Provide<ICounterService>();
_countLabel = new Label(_counterService.GetCount().ToString());
Add(_countLabel);
var incrementButton = new Button(IncrementCount);
incrementButton.text = "Increment";
Add(incrementButton);
}
private void IncrementCount()
{
_counterService.IncrementCount();
_countLabel.text = _counterService.GetCount().ToString();
}
}
This creates a component which can be used to increment a number.
Dependencies act as singletons by default. This means that every UIComponent will receive the same instance of the dependency.
See below how CounterComponent
's count remains unchanged after its editor window is closed. This is because the
count itself is stored in the singleton CounterService
.
[Provide]
will generate the Provide<T>
calls for you and do them in the inherited constructor.
using UIComponents;
[Dependency(typeof(ISettingsService), provide: typeof(SettingsService))]
[Dependency(typeof(IDataService), provide: typeof(DataService))]
public partial class ComponentWithDependencies : UIComponent, IOnAttachToPanel
{
[Provide]
private ISettingsService SettingsService;
// You can tell the attribute to cast from your interface
// and use a concrete type in your field.
// This can be useful when using a version of C# which does not
// support all of the shiny new features related to interfaces.
// Note that this will make testing difficult, so use it as
// a last resort.
[Provide(CastFrom = typeof(IDataService))]
private DataService DataService;
public void OnAttachToPanel(AttachToPanelEvent evt)
{
SettingsService.LoadSettings();
DataService.DoSomethingWithData();
}
}
Each consumer of a transient dependency receives its own instance.
To mark a dependency as transient, pass Scope.Transient
as the third argument to [Dependency]
.
using UIComponents;
[Dependency(typeof(ICounterService), provide: typeof(CounterService), Scope.Transient)]
public class CounterComponent : UIComponent
{
// snip
}
Transient dependencies are ideal when they contain data that should be destroyed alongside the consumer, e.g. a handle to some resource.
Provide<T>
will throw a MissingProviderException
if no providers exist for the dependency.
If you're unsure whether a provider exists, use TryProvide<T>
:
if (TryProvide<ICounterService>(out var counterService))
counterService.IncrementCount();
UIComponents inherit dependencies. Such dependencies can be overridden by specifying a different provider for them.
[Dependency(typeof(IStringDependency), provide: typeof(StringDependency))]
[Dependency(typeof(IScriptableObjectDependency), provide: typeof(HeroProvider))]
public class MyComponent : UIComponent {}
[Dependency(typeof(IScriptableObjectDependency), provide: typeof(VillainProvider))]
public class OtherComponent : MyComponent {}
[Dependency]
can be applied to an assembly. As a result, all UIComponents declared in the same assembly will
have the dependencies configured. Individual classes can override the assembly-level dependency.
[assembly: Dependency(typeof(IMyDependency), provide: typeof(MyDependency))]
// Will receive MyDependency if IMyDependency is requested.
public class FirstComponent : UIComponent {}
// Will receive SecondDependency if IMyDependency is requested.
[Dependency(typeof(IMyDependency), provide: typeof(SecondDependency))]
public class SecondComponent : UIComponent {}
// Will receive own instance of MyDependency, instead of the shared one.
[Dependency(typeof(IMyDependency), provide: typeof(MyDependency), Scope.Transient)]
public class ThirdComponent : UIComponent {}
TestBed<T>
is used to test UIComponents with dependency injection. After configured
with the desired dependencies, its CreateComponent
and CreateComponentAsync
methods
should be used to instantiate components.
using NUnit.Framework;
using UIComponents.Testing;
[TestFixture]
public class MyTests
{
private TestBed<CounterComponent> _testBed;
private ICounterService _counterService;
[SetUp]
public void SetUp()
{
// You may want to use a mocking library like NSubstitute.
_counterService = new MockCounterService();
// A singleton is set using WithSingleton<T> during configuration.
_testBed = new TestBed<CounterComponent>()
.WithSingleton<ICounterService>(_counterService);
}
[UnityTest]
public IEnumerator It_Works()
{
var counterComponent = _testBed.CreateComponent();
// Since component initialization is asynchronous,
// we must wait for it to finish.
yield return counterComponent.WaitForInitializationEnumerator();
// Do your assertions...
}
}
When CounterComponent
asks for ICounterService
in the test, it will receive the instance of MockCounterService
created
in the SetUp
function.