DEV Community

loading...
Cover image for Easier Angular Unit Testing

Easier Angular Unit Testing

wescopeland profile image Wes Copeland Updated on ・14 min read

MAY 2020 NOTICE: While the tooling presented in this article is (mostly) great, I no longer agree with the testing strategy as written. Yes, testing implementation details can increase your coverage and turn some green lights on, but it is usually not particularly valuable in validating the correctness of your code. Please read here for more details, and be sure to check out what I now recommend: Angular Testing Library.


In this article, we're going to learn how to level-up our Angular unit testing experience with an alternate testing toolkit. I'll walk you through how to switch your project from Karma to Jest, how to set up Wallaby in your Angular project, and how to test your components and services with a combination of Spectator and shallow rendering.

Warning: My unit testing approach is opinionated. As with anything opinionated in software development, there are lots of folks who agree and disagree. My role is to present the approach that has worked well for my teams and projects, and to give you a roadmap of how to get started with it yourself.


Intro

Hard to test code is hard to love code.

Testing your applications with the toolkit provided by the Angular CLI is hard. With a little bit of legwork, we can make some dramatic improvements to our testing dev experience, which might include:

  • 🔥 Tests can potentially run much faster!
  • 👀 We can automatically see when tests pass or fail in our editor without even needing to run tests in the command line!
  • ✨ We can easily only run tests that would be impacted by our upcoming git commit!
  • 🥳 We can write tests with much less boilerplate, helping us move faster as a team or engineering organization!

This is all possible by leveraging alternative tooling in our Angular CLI projects. Let's get started.


👉 Improvement #1: Use Jest as a test runner.

Jest logo

Jest, Facebook's speedy Node-based test runner, can dramatically improve the testing dev experience of your Angular app in a few key ways:

  • Tests are executed in parallel, not synchronously, which can result in much faster test execution for large projects.
  • Tests are run directly in your command-line in a simulated DOM environment. You no longer need to launch a browser instance to run your tests.
  • Tests impacted by your current git diff can be easily and intelligently isolated.

By switching to Jest, we'll no longer have a need for Jasmine in our project. This is nothing to worry about though! Jest's API is almost identical to Jasmine's.

While there are schematics to instantly switch a project over to Jest, at the time of this writing they have known issues with Angular 8 and don't yet properly reconfigure your ng test command. Because of these woes, we'll convert our Angular 8 project manually.

Jest Setup Guide

1. Remove all Karma dependencies and configuration files.

Execute the following commands in your workspace's root folder:

rm karma.conf.js src/test.ts

npm rm -D karma karma-chrome-launcher karma-coverage-istanbul-reporter karma-jasmine karma-jasmine-html-reporter

2. Remove dead test.ts file references in your tsconfigs.

In tsconfig.app.json and tsconfig.spec.json, remove the reference to "src/test.ts".

3. Add Jest dependencies.

Execute the following command in your workspace's root folder:

npm i -D jest jest-preset-angular @angular-builders/jest @types/jest

4. Add Jest types to your tsconfig.json.

In app root, open tsconfig.json and add a types array to your compilerOptions object:

"compilerOptions": {
  ...
  "types": ["jest"]
}
Enter fullscreen mode Exit fullscreen mode

5. Add a jest.config.js in your root folder (alongside your tsconfigs).

Note that we are using a moduleNameMapper object in this config. This directly correlates to your tsconfig's paths object. Jest will be unaware of any tsconfig paths until we specify them in jest.config.js.

If paths is unfamiliar to you, don't fret! Angular projects typically have a CoreModule and SharedModule, and it's reasonable when we import stuff from them, we'd want to reference things from those modules using @shared/my-service as opposed to ../../../../shared/my-service. We set up these aliases with paths. If you'd like to do this now, modify your tsconfig.json like so:

"compilerOptions": {
 ...
 "paths": {
   "@core/*": ["src/app/core/*"],
   "@shared/*": ["src/app/shared/*"]
 }
Enter fullscreen mode Exit fullscreen mode

6. Modify your project in angular.json to use Jest instead of Karma.

"projects" {
  ...
  "your-app-name": {
  ...
    "test": {
      "builder": "@angular-builders/jest:run",
      "options": {
        "configPath": "./jest.config.js"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

7. Run ng test!

ng test running with Jest in the terminal

You should see something close to this.

Awesome! But what if we only want to run tests that are impacted by our current change? This can be done with:

ng test --onlyChanged

ng test --onlyChanged checking against our git uncommitted changes

Only our uncommitted changes are being tested!

✅ Improvement #2: Use Wallaby.js in your editor.

Wallaby.js logo

Wallaby.js is an integrated continuous test runner for JavaScript-based projects. It supports the most popular editors, including VSCode, Atom, Sublime Text, and Visual Studio. Wallaby is not free, so if cost is an issue, it's safe to skip this section. I have a Wallaby personal license and use it in all my projects due to the productivity boost I get from it, and for the sake of full disclosure in sharing my testing workflow, I want to also share my Wallaby configuration for Angular projects.

Wallaby.js in action

Wallaby.js in action.

Wallaby.js Setup Guide for Angular+Jest

1. Install dependencies.

In your root directory, run the following command:

npm i -D ngx-wallaby-jest

2. Add wallaby.js in your root directory, alongside your tsconfig files.

Notice we have some boilerplate in our setup function that tells Wallaby how to interpret our Jest moduleNameMapper (aka tsconfig paths) values.

3. Add the Wallaby extension to your editor and start it.

What you see from here will vary by what editor you're using, but you should now see pass/fail in your gutter and console logging similar to the gif above.


On to writing tests...

Now that we have our tooling set up, let's talk about the actual contents of our tests.

Most Angular tests are written with the help of TestBed. In fact, the most popular libraries that help eliminate Angular testing boilerplate still rely on TestBed under the hood.

Not only is a large amount of TestBed boilerplate required to set up a test's dependency injection (none of which is reusable), TestBed can be used (or abused, depending on who you ask) to blur the line between unit and integration tests. It is not unheard of for a beforeEach using TestBed to have more lines of code than all of a component's unit tests combined.

Angular unit tests will rely on TestBed for the foreseeable future, even if it's hidden beneath the fold by a helper library. I'll show you how to use one such helper library to abstract some of the TestBed heavy lifting away and make a positive shift in the boilerplate-to-test ratio in our Angular .spec files.


😎 Improvement #3: Test components and services with Spectator.

Spectator logo

Spectator is an Angular unit testing library built on top of TestBed and developed by the same folks who built Akita. It can aid with dramatically reducing your test suite boilerplate, and it provides a very clean API for writing your unit tests.

For most Angular applications I've worked with, there are two types of services: those that make HTTP calls and those that do not. We'll use Spectator to test both types.

Non-HTTP Injectables

Let's pretend we have a notification service in our app that wraps around the Angular Material MatSnackBar. It looks like this:

This service has one dependency: MatSnackBar, and one function: notify. Let's tackle the spec with Spectator.

First, install some needed dependencies:

npm i -D @netbasal/spectator ng-mocks

A spec file for this might ultimately look like:

Woah, there is a lot going on here. Let's talk about everything happening in the initialization of our test suite, step by step.

let snackBar: SpyObject<MatSnackBar>;
Enter fullscreen mode Exit fullscreen mode

How MatSnackBar works is irrelevant to us. Injecting the real MatSnackBar into this test suite would break unit isolation and would be considered an anti-pattern. There are a few approaches to maintaining unit isolation in instances like this, and my favorite is to make each of the injectable's dependencies into a SpyObject. SpyObject gives us the means to easily test that NotificationService is calling MatSnackBar's functions with the expected arguments, regardless of what MatSnackBar might do with those arguments once they're received.

let spectator: SpectatorService<NotificationService> = createService({
  service: NotificationService,
  mocks: [MatSnackBar]
});
Enter fullscreen mode Exit fullscreen mode

Here's where the magic starts happening. We use createService to stand up a SpectatorService instance for NotificationService. We don't have to worry about manually stubbing or mocking out MatSnackBar, the magical mocks array takes care of that automatically for us.

We can now condense our beforeEach contents to one line of code:

beforeEach(() => {
  snackBar = spectator.get(MatSnackBar);
});
Enter fullscreen mode Exit fullscreen mode

Before every test, (re)initialize our SpyObject. Simple! Now, time to write some tests!

it('exists', () => {
  expect(spectator.service).toBeDefined();
});
Enter fullscreen mode Exit fullscreen mode

No matter what you're testing, it's never a bad idea for the first test in your suite to be a sanity check. This may wind up saving you a lot of time down the road, and combined with Wallaby you can instantly see in your editor if you forgot to mock a dependency. Note that we use spectator.service to access our NotificationService instance.

it('can pop open a snackbar notification', () => {
  spectator.service.notify('mock notification');
  expect(snackBar.open).toHaveBeenCalledWith('mock notification', 'CLOSE', {
    duration: 7000
  });
});
Enter fullscreen mode Exit fullscreen mode

Digging a little deeper, we call our notify function and ensure it tells MatSnackBar to do what it's supposed to do. Notice we don't need to write any code for spyOn in order to use toHaveBeenCalledWith. Our SpyObject boilerplate takes care of that for us automagically. This is a test that is both understandable and easy on the eyes.

Injectables with HTTP calls

We need to grab some generic entities from our back-end. Here's what a very simple HTTP service injectable (with no error handling) might look like:

Our test needs to validate that the GET call is actually being made. With Spectator, this is pretty easy!

Hopefully this is a little more straightforward than our previous test suite, but it doesn't hurt to break it down anyway.

const httpService: () => SpectatorHTTP<EntityDataService> = createHTTPFactory<
  EntityDataService
>(EntityDataService);
Enter fullscreen mode Exit fullscreen mode

This is the toughest part of the suite to unpack, and you really don't need to understand what's happening here, other than knowing this is a recipe for building an httpService that you can use to test this Angular injectable.

SpectatorHTTP is a fancy wrapper around a few of the classes the Angular team provides for testing HTTP API calls.

it('exists', () => {
  const { dataService } = httpService();
  expect(dataService).toBeDefined();
});
Enter fullscreen mode Exit fullscreen mode

Like our previous test suite, it's never a bad idea to start with a sanity check. dataService is the actual EntityDataService instance that is generated by createHTTPFactory.

it('can get entities from the server', () => {
  const { dataService, expectOne } = httpService();

  dataService.getEntities().subscribe();
  expectOne('/api/entities', HTTPMethod.GET);
});
Enter fullscreen mode Exit fullscreen mode

This should be very readable. We get our service instance and expectOne, an assertion that accepts a URL path and an HTTP method. We subscribe to the observable our getEntities function returns in order to make the API call, then we use expectOne to assert that the call itself was made and sent correctly.

Non-HTTP injectables that depend on HTTP injectables

For a third and final service-layer example, get ready for injectable-ception as we test an injectable that uses our API service from the previous example. This service will be responsible for interacting with our API service, and then sending the API response to our state management system's store. In other words, this injectable will have two dependencies. See below:

This service directly interacts with EntityDataService and AwesomeEntityStore. When we get a response from our API call, we send the entities to the store, presumably to be consumed by some component somewhere. Thankfully, testing this with Spectator is fairly simple!

Like the previous tests, let's walk through this step by step.

let dataService: SpyObject<EntityDataService>;
let store: SpyObject<AwesomeEntityStore>;

let spectator: SpectatorService<EntityService> = createService({
  service: EntityService,
  mocks: [EntityDataService, AwesomeEntityStore]
});
Enter fullscreen mode Exit fullscreen mode

Things should start feeling more familiar now! We declare some SpyObject instances for our two dependencies, and pass those same dependencies into the mocks array.

beforeEach(() => {
  dataService = spectator.get(EntityDataService);
  store = spectator.get(AwesomeEntityStore);
});
Enter fullscreen mode Exit fullscreen mode

Initialize those SpyObject instances so we can use them in our tests. Since they're in the beforeEach, they'll reinitialize after every unit test.

it('can try to get all the awesome entities and put them in the store', () => {
  dataService.getEntities.andReturn(
    of(
      [{ id: 1 }]
    )
  );

  spectator.service.getAllEntities().subscribe();

  expect(store.set).toHaveBeenCalledWith(
    [{ id: 1 }]
  );
});
Enter fullscreen mode Exit fullscreen mode

Here's where the magic happens! We tell dataService that if the getEntities function is called, it will return an observable with an array containing an AwesomeEntity. We can then monitor store's set function, and ensure the correct argument is passed.

This is all awesome, but things really start to get interesting when we move over to testing our components. A typical application has two different types of components: presentational (dumb/stateless) components and container (smart/stateful) components. Spectator makes testing both types pretty easy.

Presentational components

Tests for presentational components are some of the easiest to write with Spectator, but the recipe for testing components is overall a little different than testing services.

In the example below, we have PresentationalButtonComponent. It has one input: a label, and one output: a string emission.

If you're not using presentational components in your Angular app, now would be a great time to consider them! It is an ideal design pattern to have root components managing state, while their templates assemble your app views using small stateless presentational components. One of the benefits of this pattern is these stateless components are very easy to test. Take a look:

Another woah. It's very easy to stand up this test using Spectator! Our total boilerplate is down to a mere seven lines, and our tests are all very readable thanks to Spectator's super-clean API! Let's do another walkthrough.

let spectator: Spectator<PBComponent>;
const createComponent = createTestComponentFactory<PBComponent>({
  component: PBComponent
});

beforeEach(() => {
  spectator = createComponent();
});
Enter fullscreen mode Exit fullscreen mode

This is the recipe to stand up any presentational component test suite with Spectator. The only thing that createTestComponentFactory's options object needs is a component value with your component class specified. Then, beforeEach test, run createComponent().

it('exists', () => {
  expect(spectator.component).toBeDefined();
});
Enter fullscreen mode Exit fullscreen mode

Our sanity check. The component class instance is accessed through spectator.component. There are other things available through our spectator variable too, such as spectator.fixture for the component fixture.

// JSDOM
it('renders a button with a default label if no label is given', () => {
  expect(spectator.query('button')).toHaveText('Submit');
});
Enter fullscreen mode Exit fullscreen mode

In this test suite, I have two JSDOM tests and one test against the component class. Here, we use spectator.query to find a <button> element in the template, and assert that it should have the text "Submit".

There are those who believe the JSDOM tests would be better handled by tools such as Protractor or Cypress and these two specs have no place in the unit test suite. My opinion on that is outside the scope of this article. I'm offering the JSDOM tests as an example if this is an approach you'd like to take. Remember, your JSDOM tests will have no impact on code coverage because they are testing against a simulated DOM rather than your TypeScript code.

// JSDOM
it('renders a button with an input label if one is given', () => {
  spectator.component.buttonLabel = 'Mock Label';
  spectator.detectChanges();

  expect(spectator.query('button')).toHaveText('Mock Label');
});
Enter fullscreen mode Exit fullscreen mode

There's a little more going on here, but it's still not too tough to follow. First, we provide a value to our buttonLabel input. Then, we kick off detectChanges() so it'll show up in the DOM (otherwise the value will still be "Submit"). After the test arrangement is done, we can run our toHaveText assertion just like the previous test!

// Component Class
it('can emit a message', () => {
  const pressEmitSpy = spyOn(spectator.component.press, 'emit');

  spectator.component.handleButtonClick();
  expect(pressEmitSpy).toHaveBeenCalledWith('Submit was pressed!');
});
Enter fullscreen mode Exit fullscreen mode

As the comment states, this is a test against our actual TypeScript code in the component class. We aren't simulating a button press and ensuring handleButtonClick() was called (this would start crossing into integration test territory), we're just making sure than when it is called, it emits the message we'd expect.

To do this, we spyOn our EventEmitter's emit function, and use the toHaveBeenCalledWith assertion like we did with our injectable tests.

😱 Container + stateful components

Last but not least, we'll write some tests for a stateful component. I've found stateful component unit tests to be the most stressful tests to write for Angular apps, but Spectator greatly simplifies things. Check it out:

Our stateful component is deceptively simple. Two key takeaways here:

  • We're injecting a service: AwesomeEntitiesQuery, which is called in the ngOnInit().
  • Our template uses the presentational component from the previous example with the <app-presentational-button> tag.

It would normally take a lot of boilerplate to set up a test suite for this component, but Spectator can make this very clean for us. Here's the spec:

Our friends SpyObject and mocks have returned, but we also have a new addition to the fray: shallow: true. Time for another walkthrough!

let query: SpyObject<AwesomeEntitiesQuery>;

let spectator: Spectator<StatefulComponent>;
const createComponent = createTestComponentFactory<StatefulComponent>({
  component: StatefulComponent,
  mocks: [AwesomeEntitiesQuery],
  shallow: true
});
Enter fullscreen mode Exit fullscreen mode

Similar to our previous tests where we had service dependencies, our stateful component does too: AwesomeEntitiesQuery. And just like our previous tests, we have a magical mocks array that takes care of everything for us.

We also see shallow: true. Why is this here? Well, our child component <app-presentational-button> in the template complicates things. We also don't actually care what this component is doing or how it works. Shallow rendering mocks child components. By taking advantage of shallow rendering, we can pretend <app-presentational-button> is transformed into something like an empty div. For a more detailed explanation of shallow rendering, the React documentation offers a short blurb on the topic.

beforeEach(() => {
  spectator = createComponent();
  query = spectator.get<AwesomeEntitiesQuery>(AwesomeEntitiesQuery);
});
Enter fullscreen mode Exit fullscreen mode

This is a combination of the beforeEach calls in our previous examples with services and components. Here, we create our component and we also set up our SpyObject.

it('gets all the awesome entities on initialization', done => {
  query.selectAll.andReturn(of([]));
  spectator.component.ngOnInit();

  spectator.component.awesomeEntities$.subscribe(val => {
    expect(val).toEqual([]);
    done();
  });
});
Enter fullscreen mode Exit fullscreen mode

We specify that when selectAll() from our service is called, it's going to return an observable that contains an empty array. We then kick off our ngOnInit() and make an assertion that this value actually made it into our stateful component's awesomeEntities$ variable.


🎉 Where to go from here

If you've decided to utilize Jest, Wallaby, and/or Spectator in your projects, here are some great resources to continue on:

Thanks for reading!

Discussion

pic
Editor guide
Collapse
lysofdev profile image
Esteban Hernández

Awesome post! I especially liked the Wallaby library. I'd love to cut down my tests.

Do you know if the fakeAsync tool is compatible? It's part of the @angular/core/testing package. I'm wondering if Jest might not be compatible.

Collapse
wescopeland profile image
Wes Copeland Author

Hi Esteban, thank you!

fakeAsync is compatible. The Spectator GitHub repo goes above and beyond the docs for examples, here's one I was able to find that also uses Jest which might fit your use case.

Collapse
tobang profile image
Torben Bang

Very nice article. I started using Spectator a couple of month back. I really struggled getting started, as the documentation lacks useful testing recipes. I wished your articles was written before I started. Many useful recipes for testing. Thank you for contributing this awesome article. Do you have more Spectator recipes you would like to share? Maybe a github repo.
Once again, thank you!

Collapse
rudolfolah profile image
Rudolf Olah

Writing some Angular tests and starting to slowly realize how much mocking and boilerplate is involved even for simple things like router links and for using a UI library like Angular Material. I wonder how many spec files are in Google's projects that use Angular and whether they just have their own equivalent of spectator. This looks like a cool library btw, haven't heard of it before!

Collapse
layzee profile image
Lars Gyrup Brink Nielsen

Awesome article! It's difficult to combine all these tools. They are also my favorite combination except I prefer Sinon for test doubles and Chai for expectations.

I'm also happy to see someone use the Jest subpackage we created for Spectator 😊

Collapse
panoschal profile image
panoschal

Thanks, this is very helpful.

Collapse
carlillo profile image