The Dependency Injection (DI) pattern is a subset of Inversion of Control, and is a useful technique for decoupling the creation of a dependency from the object itself. Don’t let the terminology scare you though! DI is really just giving an object its instance variables.
For example, below is a simple example of the Player
class being implicitly coupled to the Bag
class. The Player
is responsible for creating the dependent objects.
export default class Player {
constructor() {
this.inventory = new Bag({ /* ... */ });
}
add(item) {
return this.inventory.add(item);
}
}
let bob = new Player();
Although the example is simple, it’s fairly easy to see that this implementation could be difficult to test in isolation, as you now need to know about the Bag
class in the Player
class’ test. DI can help us here:
export default class Player {
constructor({ storageObject }) {
this.inventory = storageObject;
}
add(item) {
return this.inventory.add(item);
}
}
let bob = new Player({ storageObject: new Bag({ /* ... */ }));
In the second example, we “inverted control” of the player’s inventory, and now we pass the storage object instance at runtime. This means that in our test, we can simply stub the inventory object out:
test('it adds an item', function(assert) {
let dummyStorage = { /* ... */ };
let dummy = new Player({ storageObject: dummyStorage });
assert.ok(/* ... */);
});
The Player
class no longer needs to know anything about the Bag
, and also allows other kinds of storage object classes to be used. Great!
Component Dependency Injection
I recently realized that the DI pattern can also be used to great effect in Ember components. For example, let’s say you have a container or parent component that uses multiple child components:
<!-- templates/application.hbs -->
{{edit-location location=location}}
<!-- components/edit-location.hbs -->
{{google-map
lat=location.lat
lng=location.lng
setLatLng=(action "setLatLng")
setMarkerRadius=(action "setMarkerRadius")
}}
{{edit-location-form location=location}}
{{location-activity location=location}}
The parent component edit-location
’s primary responsibility is to provide UI to edit a location. It could have actions defined on it, like so:
// components/edit-location.js
export default Component.extend({
actions: {
setLatLng(latLng) {
// logic
},
setMarkerRadius(radius) {
// logic
}
}
});
The google-map
component provides UI for the user to drop a marker on a map, and adjust the radius around the marker by using the radius control. Needless to say, that UI interaction is quite difficult to test, and is tested in the google-map
component test itself. Because the edit-location
component is tightly coupled to its child components, testing it is no easy task. We need to make sure all the child components are setup just right, which introduces a lot of boilerplate in our component integration test.
Not my concern
In this scenario, the edit-location
component itself shouldn’t need to concern itself with how the latLng
and radius
arguments are passed into its actions. The drag and drop UI is a concern of the google-map
component, and as such should be tested in its own component integration test.
Using DI, we can decouple the edit-location
component from its child components, and clean up our tests. This technique is currently only possible with contextual components due to the use of the component
and hash
helpers, which were made available in Ember 2.3.0.
<!-- application.hbs -->
{{edit-location
location=location
ui=(hash
location-map=(component "google-map")
location-form=(component "edit-location-form")
location-activity=(component "location-activity"))
}}
We’ve passed in a hash of the child components using the hash
and component
helpers. This effectively inverts control to the template that calls the edit-location
form:
<!-- components/edit-location.hbs -->
{{ui.location-map
lat=location.lat
lng=location.lng
setLatLng=(action "setLatLng")
setMarkerRadius=(action "setMarkerRadius")
}}
{{ui.location-form location=location}}
{{ui.location-activity location=location}}
Now, in our tests, we’ll need to write a little test helper to create a dummy component we can use to no-op (do nothing). Credit goes to @runspired for nudging me in the right direction:
// tests/helpers/dummy-component.js
import Ember from 'ember';
const {
Component,
assign,
getOwner
} = Ember;
export default function registerDummyComponent(context, name = 'dummy-component', opts = {}) {
let owner = getOwner(context);
let options = assign({ tagName: 'dummy' }, opts);
let DummyComponent = Component.extend(options);
unregisterDummyComponent(context);
owner.register(`component:${name}`, DummyComponent);
}
export function unregisterDummyComponent(context, name = 'dummy-component') {
let owner = getOwner(context);
if (owner.resolveRegistration(`component:${name}`)) {
owner.unregister(`component:${name}`);
}
}
This test helper registers a fake component in the container, making it available for us to use in our component integration test:
// tests/integration/edit-location-test.js
test('it ...', function(assert) {
registerDummyComponent(this);
this.set('location', {});
this.render(hbs`
{{edit-location
location=location
ui=(hash
location-map=(component "dummy-component")
location-form=(component "dummy-component")
location-activity=(component "dummy-component"))
}}
`);
assert.ok(/* ... */);
});
Now we can test the edit-location
component itself without worrying about setting up child components. That said, DI still allows us to test those child components integrating with edit-location
, in a more controlled environment:
// tests/integration/edit-location-test.js
test('it updates location via the form', function(assert) {
registerDummyComponent(this);
this.set('location', {});
this.render(hbs`
{{edit-location
location=location
ui=(hash
location-map=(component "dummy-component")
location-form=(component "edit-location-form")
location-activity=(component "dummy-component"))
}}
`);
assert.ok(/* ... */);
});
I hope you find this useful! It’s not a silver bullet by any stretch, but DI can help you write better isolated components and simplify your tests.