Using Angular’s Dependency Injection Sparingly

Our unit tests were suffering because of excessive abstraction

Vlad Sabev
5 min readOct 1, 2018

--

Our team had been working on a mid-sized Angular application for the past few months. Unfortunately, we hadn’t started with testing in mind at first. As the project kept growing and at the same time we were slowly approaching our arbitrary goal of > 90% code coverage, we started asking these questions more and more often:

  • Were the tests we were writing meaningful?
  • Were they sufficiently decoupled from our code?
  • Was code coverage really an adequate metric for reliable software?

To find an answer, we had an internal team discussion about our unit testing strategy and using Angular’s built-in Dependency Injection mechanism in particular.

In this post we’ll now go over some of the points we touched.

Requirements

In the interest of brevity, I’ll skip most of the “plumbing” code like imports and @Injectable decorators. In doing so, I hope that both those of you who are familiar with Angular and those who aren’t will be able to follow along.

Use Case

As an example, let’s take a very simple service for working with strings:

export class TextService {
startsWith(text: string, query: string): boolean {
return text.indexOf(query) === 0;
}
}

Now inject TextService into another service:

export class UrlService {
constructor(private textService: TextService) {
}
isSecure(url: string): boolean {
return this.textService.startsWith(url, 'https:');
}
}

Finally, let’s write some tests for our UrlService:

describe(`UrlService`, () => {
// ... create the instance of urlService
it(`should return true if URL starts with 'https:'`, () => {
expect(urlService.isSecure('https://medium.com')).toBe(true);
});
it(`should return false if URL starts with 'http:'`, () => {
expect(urlService.isSecure('http://medium.com')).toBe(false);
});
});

But how was the instance of urlService created? There are a few different ways to do it, each with its trade-offs.

Angular’s TestBed

This is the approach most often recommended by tutorials and documentation, and the one we started with.

Our experience was that there were lots of mysterious bugs using it, for example this issue when calling fixture.detectChanges():

SyntaxError
at viewWrappedDebugError (node_modules/@angular/core/bundles/core.umd.js:9844:15)
at callWithDebugContext (node_modules/@angular/core/bundles/core.umd.js:15146:15)
at Object.debugCheckAndUpdateView [as checkAndUpdateView] (node_modules/@angular/core/bundles/core.umd.js:14673:12)
at ViewRef_.Object.<anonymous>.ViewRef_.detectChanges (node_modules/packages/core/esm5/src/view/refs.js:508:14)
at ComponentFixture.Object.<anonymous>.ComponentFixture._tick (node_modules/@angular/core/bundles/core-testing.umd.js:220:32)
at node_modules/packages/core/esm5/testing/src/component_fixture.js:108:10
at ZoneDelegate.Object.<anonymous>.ZoneDelegate.invoke (node_modules/zone.js/dist/zone-node.js:388:26)
at ProxyZoneSpec.Object.<anonymous>.ProxyZoneSpec.onInvoke (node_modules/zone.js/dist/proxy.js:128:39)
at ZoneDelegate.Object.<anonymous>.ZoneDelegate.invoke (node_modules/zone.js/dist/zone-node.js:387:32)
at Object.onInvoke (node_modules/packages/core/esm5/src/zone/ng_zone.js:594:10)
at ZoneDelegate.Object.<anonymous>.ZoneDelegate.invoke (node_modules/zone.js/dist/zone-node.js:387:32)
at Zone.Object.<anonymous>.Zone.run (node_modules/zone.js/dist/zone-node.js:138:43)
at NgZone.Object.<anonymous>.NgZone.run (node_modules/@angular/core/bundles/core.umd.js:4615:69)
at ComponentFixture.Object.<anonymous>.ComponentFixture.detectChanges (node_modules/packages/core/esm5/testing/src/component_fixture.js:108:10)

You can imagine exactly how much fun debugging that was. Spoiler alert: we had forgotten to provide a service when configuring the TestBed. A similarly weird issue was caused by passing {} instead of [] to a component’s input.

Anyway, let’s use the TestBed properly:

describe(`UrlService`, () => {
let urlService: UrlService;
beforeEach(() => {
TestBed.configureTestingModule({ providers: [UrlService] });
urlService = TestBed.get(UrlService);
});
it(`should return true if URL starts with 'https:'`, () => {
expect(urlService.isSecure('https://medium.com')).toBe(true);
});
it(`should return false if URL starts with 'http:'`, () => {
expect(urlService.isSecure('http://medium.com')).toBe(false);
});
});

After figuring out all other weird issues, it looks simple enough — we configure what the different providers in our application are, then use TestBed.get(UrlService) to get a reference to the instance of the injected service.

However, after using it for a while, we noticed TestBed was painfully slow on Windows — for some of our developers up to 3 times slower than on UNIX systems. We looked for ways to speed the test runs up, but in the end even switching to Jest didn’t seem to help much.

This was a no go — unit tests should only take a few seconds to run, not 30! If tests are slow they lose their ability to provide rapid feedback during development.

So TestBed was out of the game.

Manually injecting dependencies

While looking for other approaches besides TestBed we found something in the Angular docs:

There’s another school of testing that… prefers to create classes explicitly rather than use the TestBed.

Here’s an example with our UrlService:

describe(`UrlService`, () => {
let urlService: UrlService;
beforeEach(() => {
const textService = new TextService();
urlService = new UrlService(textService);
});
it(`should return true if URL starts with 'https:'`, () => {
expect(urlService.isSecure('https://medium.com')).toBe(true);
});
it(`should return false if URL starts with 'http:'`, () => {
expect(urlService.isSecure('http://medium.com')).toBe(false);
});
});

Overall, this approach looked straightforward (and it was also running much faster), but it did raise a question — what would happen if we added a dependency to TextService itself?

Injecting secondary dependencies

Let’s get another level of Dependency Injection going by adding a SecurityService that checks the application for some common security errors:

export class SecurityService {
constructor(private urlService: UrlService) {
}
getSecurityErrors(url: string): boolean {
const errors = [];
if (!this.urlService.isSecure(url)){
errors.push('This application is insecure!');
}
// ... some other security checks return errors;
}
}

Now let’s add some tests using the approach where we instantiate services manually — and here’s where things get unwieldy:

describe(`SecurityService`, () => {
let securityService: SecurityService;
beforeEach(() => {
const textService = new TextService();
const urlService = new UrlService(textService);
securityService = new SecurityService(urlService);
});
it(`should return no errors if URL starts with 'https:'`, () => {
expect(
securityService.getSecurityErrors('https://medium.com')
).toHaveLength(0);
});
it(`should return one error if URL starts with 'http:'`, () => {
expect(
securityService.getSecurityErrors('http://medium.com')
).toHaveLength(1);
});
});

Great, so now we also have to instantiate TextService - a piece of code the SecurityService doesn’t use directly and shouldn’t really know anything about?!

Me and my colleague called this “mocking the world”. Sure, we’ve gained speed and (at first look) simplicity by going around Angular’s TestBed, but we’d completely lost any benefits of the inversion of control that Dependency Injection provides!

No Dependency Injection

Of course, we could write all those services without using DI altogether. That’s exactly what we ended up doing in many parts of our application that were dealing with simple data processing — replacing the service class with a simple object:

export const textService = {
startsWith(text: string, query: string): boolean {
return text.indexOf(query) === 0;
},
};

Then our tests wouldn’t have to deal with DI at all.

Of course, the trade-off is that we can no longer inject any Angular dependencies in those services. Once we start using the DI pattern anywhere in our application, mixing it into code that doesn’t becomes hard.

Conclusion

So is it that dependency injection is just bad?

I believe as with most other tools it should be used sparingly. DI is meant to make things simpler, and if it doesn’t — use something else. For example Jest mocks virtually eliminate the need for using DI when unit testing.

We also probably wouldn’t write a unit test to check whether clicking on a button in one of our components calls some method. Perhaps we should invest more time in writing E2E tests with Cypress or Integration tests instead.

How has your experience with Dependency Injection and unit testing turned out? Let me know in the comments 👇

--

--