Note: This post is part of an eleven-part series on Angular development. You can find the series overview here.

Overview

Angular comes built-in with a testing suite to help build and run automated testing, with some help from Karma (test runner) and Jasmine (test framework). This article assumes general knowledge of the Angular testing suite, if you aren’t familiar you can see the Angular Guide. This article aims to cover some complex testing scenarios that arise with component interaction using the techniques provided in the previous articles in this series and how to accurately test the situation.

To follow along with the articles there a two repositories created: a starting point and final solution.

The Situation

Let’s say you have read and are following the 1- Smart vs. Dumb Components article… because you should!  When creating the smart components, you will be faced with some challenges during testing.  As a recap, the smart component functions like a controller, it’s job is to be aware of the application state and bind that information to the dumb (or other nested smart) components, and to process the output from those components.  If we want to test the smart component in a unit test, how can we mock the dumb components to give ourselves enough control to easily trigger and interact with the inputs and outputs of those components?

Let’s say we have a reporting dashboard with 3 components, a criteria component, a results component, and an input component.  The input component provides the trigger to run or update the report, but we need to get the criteria from a separate component in order to know what to run.  That builds a request to send to a service and displays the resulting data in the results component.  We have some unique situation that prevents the input component from interacting with the criteria component, for example, the input is a toolbar and the criteria is a filter section in another location.  We will need to get the criteria as it currently is set at any point – this means we need to use ViewChild instead of an Output event:

@Component({
	selector: 'app-report',
	template: `
        <app-report-criteria #criteria></app-report-criteria>
        <app-report-input (runReport)="refresh()"></app-report-input>
        <app-report-results [data]="reportResults"></app-report-results>`
})
export class ReportComponent {
	public reportResults: ItemInterface[];
	@ViewChild('criteria') criteriaComponent: ReportCriteriaComponent;

	constructor(private searchService: SearchService) { }

	public refresh() {
		this.reportResults = null;
		if (this.criteriaComponent.validateCriteria()) {
			const criteria = this.criteriaComponent.getSearchCriteria();
			this.searchService.search(criteria).subscribe(data => this.reportResults = data);
		}
	}
}

The Solution

Component Interface

To test this component, we will need to generate a testing component that we can use to render instead of the actual component.  The first step we can implement is leveraging TypeScript to provide some insurance that our mock implementation aligns with our actual implementation.  We can do this by using an interface that we implement for the component and the mock component.  Let’s implement an interface now.  We can even make it generic to the type of data we get from the service so we could re-use this for other criteria or forms:

export interface ReportCriteriaFormInterface<T> {
    validateCriteria(): boolean;
    getSearchCriteria(): T;
}
export class ReportComponent {
    public reportResults: ItemInterface[];
    @ViewChild('criteria') criteriaComponent: ReportCriteriaFormInterface<SearchInterface>;

    //SNIP...
}
Now we know we are interacting with a consistent interface that we need to mock.  This is not a required element, but it certainly helps ensure our tests stay in sync with the implementation.

Mock Component

Let’s start by creating our test spec, if there is anything unfamiliar here then review the testing documentation from Angular.  We set up a standard test with a service spy providing some dummy data.

describe('ReportComponent', () => {
   let component: ReportComponent;
   let fixture: ComponentFixture<ReportComponent>;
   let searchService: jasmine.SpyObj<SearchService>;

   const testData: ItemInterface[] = [
      { name: 'Apples', description: 'Juicy, red, and delicious' },
      { name: 'Socks', description: 'So your feet don't get cold' },
      { name: 'Tires', description: 'Because we sell everything' }];

   beforeEach(() => {
      searchService = jasmine.createSpyObj(['search']);
      searchService.search.and.returnValue(of(testData));

      TestBed.configureTestingModule({
         declarations: [ReportComponent],
         providers: [
            { provide: SearchService, useValue: searchService }
         ],
         schemas: [NO_ERRORS_SCHEMA]
      }).compileComponents();
      fixture = TestBed.createComponent(ReportComponent);
      component = fixture.componentInstance;

      fixture.detectChanges();
   });

   it('should create the component', () => {
      expect(component).toBeTruthy();
   });
});
Sometimes the easiest option is to use the actual component. For our user input that is just a button that emits an event, we can set that up for this test case. If you expect or implement additional complications later this may not be the best approach, but sometimes there is no need to reinvent the wheel just to have a pure unit test. Let’s bring the component into our test module and create a test that clicks the submit button:
describe('ReportComponent', () => {
   // SNIP
   TestBed.configureTestingModule({
      declarations: [ReportComponent, ReportInputComponent],
      providers: [
         { provide: SearchService, useValue: searchService }
      ],
      
      // SNIP

      it('should run a search', () => {
      fixture.debugElement.query(By.css('button')).triggerEventHandler('click', {});
      expect(searchService.search).toHaveBeenCalled();
   });
});

The test fails, our search method is never called! What gives? The component method is checking the validation of the ReportCritiera component before running the search. Since we aren’t declaring the component, the NO_ERRORS_SCHEMA lets us run the test, but the criteriaComponent property is null, so the validateCriteria() is undefined. We need a way to easily test what happens when the criteria is valid and is not valid, but this is not a case where we want to use the existing component as there can be too much logic that bleeds into our test. Let’s create a mock component we can use instead. Thankfully we have an Interface to implement so we can match the component design!

@Component({
    selector: 'app-report-criteria',
    template: ''
})
export class MockReportCriteriaComponent implements ReportCriteriaFormInterface<SearchInterface> {
    public isFormValid = true;
    public searchData: SearchInterface;

    validateCriteria(): boolean {
        return this.isFormValid;
    }
    getSearchCriteria(): SearchInterface {
        return this.searchData;
    }
}

A few items to note about this.  First, we can create this component anywhere, but there are two places that I suggest.  The first option is to create this inline with the test, just add it before the describe method – this works best when the test component is only used for that specific test.  The other option is to create a test folder, generally under the src folder and alongside the app folder.  This folder is a good location for any testing utilities such as mock data, components, helpers, etc.

Secondly, notice the selector of the component.  This must match the selector for the actual component.  This is how Angular finds the component to inject in the template, so the selector (and Inputs and Outputs) must match.  We just need to declare the component in our test to use it:

describe('ReportComponent', () => {
   // SNIP
   TestBed.configureTestingModule({
      declarations: [ReportComponent, ReportInputComponent, MockReportCriteriaComponent],
      providers: [
         { provide: SearchService, useValue: searchService }
      ],
      // SNIP
});
Finally, you can see we implement the interface, but have added some public properties to provide the return values. We just need to perform a cast operation when accessing the component reference through our component to have access to those properties. We can now easily interact with the mock component to update our test:
it('should run a search', () => {
   const mock = component.criteriaComponent as MockReportCriteriaComponent;
   mock.isFormValid = true;
   const testSearch = {searchTerm: 'foo'};
   mock.searchData = testSearch;

   fixture.debugElement.query(By.css('button')).triggerEventHandler('click', {});
   expect(searchService.search).toHaveBeenCalledWith(testSearch);
});
We can also easily create a test for invalid criteria:
it('should not run a search for invalid criteria', () => {
   const mock = component.criteriaComponent as MockReportCriteriaComponent;
   mock.isFormValid = false;

   fixture.debugElement.query(By.css('button')).triggerEventHandler('click', {});
   expect(searchService.search).not.toHaveBeenCalled();
}); 

Conclusion

Writing tests is one of the best ways to evaluate your application architecture to ensure you are adequately using abstraction and isolation of application functionality.  There are many techniques that can greatly enhance your abilities when building an application, but if you can’t test or isolate the functionality you are going to pay the price in the long term with maintenance.  The modular nature of Angular can greatly support that initiative by allowing for on the fly replacement of components and other dependencies with simple and easy to control alternatives.

About Intertech

Founded in 1991, Intertech delivers software development consulting to Fortune 500, Government, and Leading Technology institutions, along with real-world based corporate education services. Whether you are a company looking to partner with a team of technology leaders who provide solutions, mentor staff and add true business value, or a developer interested in working for a company that invests in its employees, we’d like to meet you. Learn more about us.