DEV Community

Cover image for Angular Unit Tests: Common Pain Points
Stephen Whitmore
Stephen Whitmore

Posted on

Angular Unit Tests: Common Pain Points

One of the biggest struggles I see when people are first learning Angular is dealing with unit tests. They can be painful when you're new to the framework or are used to dealing with backend code.

The biggest pain points I think people run into are:

  • The setup
  • The scope
  • Testing Observables
  • Dealing with lifecycle hooks

I wanted to post a quick blurb that may come in handy for those who are struggling today. Hopefully after reading this you'll feel a bit more comfortable when facing Angular unit tests in the future.

The Setup

Keep it simple! This tends to be the first place people get tangled up. Only set up what you absolutely need.

Golden rules to follow for setting up your tests:

  1. Keep it simple.
  2. Don't import modules unless absolutely necessary (e.g. ReactiveFormsModule if you have form elements in the component or RouterModule if you have routerLinks).
  3. Never use the NO_ERRORS_SCHEMA.
  4. Never provide real services when testing components, even if they don't make http calls.

I can't overemphasize the first rule. Tests, and their setup, should be simple. Regardless of how complicated an application or its components may be.

Let's say we have a component that looks something like this:

export class CustomerSummaryComponent implements OnInit, OnDestroy {
  updateListenerSub = new Subscription();
  customerData$: Observable<CustomerDataModel> | undefined;

  constructor(
    private activatedRoute: ActivatedRoute,
    private customerDataService: CustomerDataService,
    private mmmToastService: MmmToastService,
  ) {}

  ngOnInit(): void {
    this.getCustomerData();
    this.listenForUpdates();
  }

  getCustomerData() {
    const customerNumber = this.activatedRoute.snapshot.params['customerNumber'];
    const customerLastName = this.activatedRoute.snapshot.params['lastName'];

    this.customerData$ = this.customerDataService.getCustomerData(customerNumber, customerLastName);
  }

  listenForUpdates() {
    this.updateListenerSub = this.customerDataService.customerUpdated$
      .subscribe({
        next: (response: CustomerUpdateModel) => this.showToastMessage(response),
        error: (error: any) => this.showToastMessage(error),
      });
  }

  showToastMessage(response: CustomerUpdateModel) {
    const type = response.status === 200 ? 'success' : 'error';

    this.mmmToastService.addToast({ type, message: response.message });
  }

  ngOnDestroy(): void {
    this.updateListenerSub.unsubscribe();
  }
}
Enter fullscreen mode Exit fullscreen mode

There are a few things going on here. We have a couple services, we're dealing with the ActivatedRoute, and we have a couple lifecycle hooks.

Here's what the setup should look like for our tests:

describe('CustomerSummaryComponent', () => {
  let component: CustomerSummaryComponent;
  let fixture: ComponentFixture<CustomerSummaryComponent>;

  beforeEach(() => {
    TestBed.configureTestingModule({
      declarations: [ CustomerSummaryComponent ],
      providers: [
        { provide: ActivatedRoute, useValue: activatedRouteStub },
        { provide: CustomerDataService, useValue: customerDataServiceStub },
        { provide: MmmToastServiceStub, useValue: mmmToastServiceStub },
      ],
    });

    fixture = TestBed.createComponent(CustomerSummaryComponent);
    component = fixture.componentInstance;
  });
Enter fullscreen mode Exit fullscreen mode

That's it. We have stubs for our services and we're loading up our component into the test harness. We don't need to care about anything else at this point.

You should be mindful of importing modules because doing so can put way more into your test harness than what's needed and can ultimately lead to unintended side-effects.

The NO_ERRORS_SCHEMA is a big blanket that'll mask real errors in your application code. You can read more about what it does here, but I'd recommend just staying away from it altogether.

Services generally either have http calls or business logic in them. Your unit tests for this component shouldn't know or care about any of that. Which leads me to my next point...

The Scope

Keep the scope of your tests as narrow as possible. The more narrow your test focus is, the bigger the return you'll get. They'll also be easier to write and manage.

Let's take another look at the getCustomerData() method from our component above.

getCustomerData() {
    const customerNumber = this.activatedRoute.snapshot.params['customerNumber'];
    const customerLastName = this.activatedRoute.snapshot.params['lastName'];

    this.customerData$ = this.customerDataService.getCustomerData(customerNumber, customerLastName);
}
Enter fullscreen mode Exit fullscreen mode

In this method we're grabbing some data from the ActivatedRoute, passing it to our service, and storing the result in an Observable.

Before we go any further let me outline some things that we're NOT trying to test:

  • How ActivatedRoute behaves
  • What happens in customerDataService.getCustomerData()

We only care that we're making a call to our service, what we're passing to it, and what we do in response to that call.

That's why we use stubs. Our stubs will just give us what we want so we can focus on what we do care about.

Here's a good set of tests for our getCustomersData() method:

beforeEach(() => {
    // ...
    activatedRouteStub.testParams = { customerNumber: '1234', customerLastName: 'Smith' };
});

describe('getCustomerData()', () => {
    it('should call "customerDataService.getCustomerData()" with the route params', () => {
        const spy = spyOn(customerDataServiceStub, 'getCustomerData');

        component.getCustomerData();

        expect(spy).toHaveBeenCalledOnceWith('1234', 'Smith');
    });

    it('should put the returned value in an observable', () => {
      spyOn(customerDataServiceStub, 'getCustomerData').and.returnValue(of());
      component.getCustomerData();

      expect(component.customerData$).toBeTruthy();
    });
});
Enter fullscreen mode Exit fullscreen mode

Each test has a narrow focus and is following that AAA structure (Assemble, Act, Assert). Our activatedRouteStub and customerDataServiceStub are just bouncing back what we need to make our assertions. They would look something like this:

export class ActivatedRouteStub {
    private subject = new BehaviorSubject(this.testParams);
    private _testParams: any;

    get testParams() {
        return this._testParams;
    }

    set testParams(queryParams: any) {
        this._testParams = queryParams;
        this.subject.next(queryParams);
    }

    get snapshot() {
        return {
          queryParams: this.testParams,
          params: this.testParams,
        };
      }    
}
Enter fullscreen mode Exit fullscreen mode
export class CustomerDataServiceStub {
    getCustomerData(customerNumber: string, customerLastName: string): any {}
}
Enter fullscreen mode Exit fullscreen mode

Put the bare minimum in your stubs. They should be "stupid"!

Testing Observables

This is a big pain point. When we're dealing in async space we risk subjecting ourselves to test bleedover and unintended side-effects. That's why it's important to wrap our tests in fakeAsync and to always flush when we're done 😁.

Let's take a look at a couple scenarios where we're forced to deal with an Observable:

With that first method getCustomerData() we originally just asserted that our customerData$ property wasn't falsy. That's fine, but it would be better if we could crack open that Observable and make sure what we expect is inside:

const mockCustomer = {
    firstName: 'John',
    lastName: 'Doe',
    customerNumber: '12345',
    memberSince: new Date('11/11/2011'),
    email: 'johndoe@email.com',
    phone: '555-777-9999',
};

describe('getCustomerData()', () => {
    //...

    it('should put the returned value in an observable', fakeAsync(() => {
      spyOn(customerDataServiceStub, 'getCustomerData').and.returnValue(of(mockCustomer));

      component.getCustomerData();

      component.customerData$
        .subscribe({
          next: (response: CustomerDataModel) => {
            expect(response).toEqual(mockCustomer)
          }
        });

      flush();
    }));
});
Enter fullscreen mode Exit fullscreen mode

By wrapping our test in fakeAsync we're able to test asynchronous code in a synchronous way. There are plenty of articles out there describing how this works in more detail and I encourage you to read up on it. In the meantime, just think of it as a nice tool to keep headaches in our tests to a minimum.

Calling flush() either at the end of each test wrapped in fakeAsync or in a afterEach() method will ensure that anything going on in async space is wrapped up. This will help prevent test bleedover.


Our listenForUpdates() is another good scenario for testing in async space.

listenForUpdates() {
    this.updateListenerSub = this.customerDataService.customerUpdated$
        .subscribe({
            next: (response: CustomerUpdateModel) => this.showToastMessage(response),
            error: (error: any) => this.showToastMessage(error),
        });
}
Enter fullscreen mode Exit fullscreen mode

We can maintain that AAA structure if customerUpdated$ is a BehaviorSubject.

  describe('listenForUpdates()', () => {
    it('should call "showToastMessage()" with response', fakeAsync(() => {
      const spy = spyOn(component, 'showToastMessage');
      customerDataServiceStub.update(); // trigger ".next()" on the observable to make it emit

      component.listenForUpdates();

      expect(spy).toHaveBeenCalledTimes(1);
    }));
  });
Enter fullscreen mode Exit fullscreen mode

customer-data-service.stub.ts

export class CustomerDataServiceStub {
    customerUpdated$ = new BehaviorSubject({message: 'Yay!', status: 200});

    getCustomerData(customerNumber: string, customerLastName: string): any {}

    update() {
        this.customerUpdated$.next({message: 'updated', status: 200});
    }
}
Enter fullscreen mode Exit fullscreen mode

This is because BehaviorSubjects immediately emit values when they're subscribed to. If customerUpdated$ was a regular Subject we'd need to break that AAA structure a little bit:

  describe('listenForUpdates()', () => {
    it('should call "showToastMessage()" with response', fakeAsync(() => {
      const spy = spyOn(component, 'showToastMessage');
      customerDataServiceStub.update();

      customerDataServiceStub.customerUpdated$
        .subscribe({
          next: () => expect(spy).toHaveBeenCalledTimes(1)
        });

      component.listenForUpdates();
    }));
  });
Enter fullscreen mode Exit fullscreen mode

The sooner you can get comfortable with Observables, the better. They can feel daunting at first but once you get used to them they really are magical. Some good points to focus on would be:

  • Learning the differences between the more widely used Subject types (Subject, BehaviorSubject, ReplaySubject)
  • Learning the most commonly used operators (map, mergeMap/switchMap, combineLatest, takeUntil, to name a few)

Dealing with lifecycle hooks

In short, don't test these. We want to test our code - not the framework we're using.

Let's change our CustomerSummary component a bit so that it's recieving data from a parent instead of fetching said data. We'll have it capture
the customer's first name into a class property as well:

export class CustomerSummaryComponent implements OnInit, OnDestroy {
  updateListenerSub = new Subscription();
  @Input() customerData: CustomerDataModel;
  firstName: string;

  constructor(
    private activatedRoute: ActivatedRoute,
    private customerDataService: CustomerDataService,
    private mmmToastService: MmmToastService,
  ) {}

  ngOnChanges(changes: SimpleChanges): void {
    const customerChange = changes['customerData'];

    if (customerChange && customerChange.currentValue) {
        this.firstName = customerChange.currentValue.firstName;
    }
  }

  //...
}
Enter fullscreen mode Exit fullscreen mode

I see a lot of tests like below:

describe('ngOnChanges()', () => {
    it('should capture the first name of the customer', () => {
        const changes = { customerData: new SimpleChange(null, mockCustomer, true) };

        component.ngOnChanges(changes);

        expect(component.firstName).toBe('John');
    });
});
Enter fullscreen mode Exit fullscreen mode

You're not really getting any real value out of a test like this. If there's logic in the lifecycle hook you should break the logic out into their own methods. It'll make the application code easier to test, manage, and read!

ngOnChanges(changes: SimpleChanges): void {
    const customerChange = changes['customerData'];

    if (customerChange && customerChange.currentValue) {
        const currentCustomer = customerChange.currentValue;
        this.firstName = currentCustomer.firstName;

        this.updateListenerSub = this.customerDataService.customerUpdated$
            .subscribe({
                next: (response: CustomerUpdateModel) => this.showToastMessage(response),
                error: (error: any) => this.showToastMessage(error),
            });
    }
  }
Enter fullscreen mode Exit fullscreen mode

becomes

ngOnChanges(changes: SimpleChanges): void {
    const customerChange = changes['customerData'];

    if (customerChange && customerChange.currentValue) {
        this.captureFirstName(customerChange.currentValue);
        this.listenForupdates();
    }
}

captureFirstName(currentCustomer) {
    this.firstName = currentCustomer.firstName;
}

listenForUpdates() {
    this.updateListenerSub = this.customerDataService.customerUpdated$
        .subscribe({
            next: (response: CustomerUpdateModel) => this.showToastMessage(response),
            error: (error: any) => this.showToastMessage(error),
        });
}
Enter fullscreen mode Exit fullscreen mode

Summary

Your key takeways from this post should be

  • Keep your setup simple
  • Keep your test scope narrow
  • Observables are manageable with fakeAsync and flush
  • Be mindful of what you're testing and why

Happy testing!

Top comments (0)