Movatterモバイル変換


[0]ホーム

URL:


Skip to content
DEV Community
Log in Create account

DEV Community

Preston Lamb
Preston Lamb

Posted on • Originally published atprestonlamb.com on

     

Unit Testing in Angular

A few months ago I wrote anintro to unit testing in Angular article. In it, I promised that I'd be writing more unit tests and would follow up with another article and more details after I had some more experience. So, here I am! It's a few months later and I've written many, many tests for an internal Angular library for work. The library has a mix of services, pipes, guards, components, and directives. There are isolated tests, shallow tests, and deep tests. I've learned a lot, and had to ask a lot of questions aboutunit testing on Twitter and StackOverflow to figure everything out. Luckily for me, I received a lot of help from the community, and I'm really grateful for everyone that helped! Now, hopefully, this will help someone else out as well. We'll go over testing several pieces of Angular. Let's go!

Testing Utilities

It's not uncommon in an Angular app to have utility files full of functions to manipulate data throughout our applications. It's important to test these utilities to make sure that they are predictable and give us the correct output each time they're used. Even those these functions aren't exactlyAngular, we can still test them in the same way we would any other Angular piece. Let's use the following function in ourarray.util.ts file:

exportfunctionstringArrayContainsPartialStringMatch(arr:string[],strMatch:string){returnarr.filter(item=>item.includes(strMatch)).length>0;}
Enter fullscreen modeExit fullscreen mode

The function takes in a string array and a string to match. It returns true if any items in the array contain any portion of thestrMatch variable. It's important to note that the array doesn't need to contain the entire string. Here are some tests for this utility function:

it('should return true if the value is included in the string array',()=>{constarr=['value 1'];conststr='value 1';constincludedInArray=stringArrayContainsPartialStringMatch(arr,str);expect(includedInArray).toBe(true);});it('should return true if the value is included in the string array',()=>{constarr=['value 1'];conststr='val';constincludedInArray=stringArrayContainsPartialStringMatch(arr,str);expect(includedInArray).toBe(true);});
Enter fullscreen modeExit fullscreen mode

We can also do the inverse of each of those tests to make sure the function returns false when the value is not included in the array. For consistency's sake, these tests can be placed in a*.spec.ts file, just like the Angular CLI creates for directives, pipes, etc.

Testing Pipes

Testing Angular pipes may be one of the better places to start, because pipes have fairly straightforward inputs and outputs. Let's take the following pipe for an example:

exportclassStatusDisplayPipeimplementsPipeTransform{transform(id:number):string{if(typeofid!=='number'){thrownewError('The ID passed in needs to be a number.');}switch(id){case2:return'Sent';case3:return'Delivered';default:return'Pending';}}}
Enter fullscreen modeExit fullscreen mode

The pipe takes in a status ID for an item and returns whether the item's status is Pending, Sent, or Delivered. Testing this will not be too involved; it will be an isolated test and we can test it like this:

describe('StatusDisplayPipe',()=>{it('should return Sent for an ID of 2',()=>{constpipe=newStatusDisplayPipe();conststatusDisplay=pipe.transform(2);expect(statusDisplay).toBe('Sent');});});
Enter fullscreen modeExit fullscreen mode

Let's break it down line by line. We start by creating an instance of the pipe, by using thenew keyword. Next, we call thetransform method on the pipe and pass it the ID we want to test. Then, we use theexpect statement to check if the return value from the pipe is what we expect it to be.

We can continue on this pipe by checking the other conditions, like an ID of 1, or what happens when we don't pass in a number to the pipe. Now, due to using TypeScript and because we typed the input to thetransform method, we will likely see an error in our IDE if we didn't pass in a number for the ID, but it's still worth testing it in my opinion.

That's pretty much all it takes to test a pipe! Not too bad, right?

Testing Services

Testing services, in many cases, will be very similar to pipes. We'll be able to test them in isolation by creating an instance of the service and then calling methods on it. It may be more complicated than a pipe, because it may include aSubject, for example, but it's still fairly straightforward. Some services do have dependencies and require us to pass those dependencies in when creating the instance of the service. We'll look at a service like that, as it's more complicated than one that doesn't have any dependencies.

It's important to remember though that we don't want to test those dependencies; we are assuming they're being tested elsewhere. That's where using Jasmine to mock these services will come in handy. Take the following service as an example:

exportclassConfigurationService{constructor(private_http:HttpClient){}loadConfiguration(){returnthis._http.get('https://my-test-config-url.com').toPromise().then((configData:any)=>{this.configData=configData;}).catch((err:any)=>{this.internalConfigData=null;});}}
Enter fullscreen modeExit fullscreen mode

This function is one that is used to load configuration for the Angular application before the application bootstraps using theAPP_INITIALIZER token. That's why there's the.toPromise() method on the Observable.

So, how do we test this? We'll start by creating a mockHttpClient application and then create an instance of the service. We can use the mockedHttpClient app to return the data we want it to or to throw an error or whatever we may need to test. The other thing to keep in mind is that many functions in a service are asynchronous. Because of that, we need to use thefakeAsync andtick methods from@angular/core/testing or just theasync method from the same library. Let's look at some examples:

describe('ConfigurationService',()=>{constmockConfigObject={apiUrl:'https://apiurl.com'};letmockHttpService;letconfigurationService;beforeEach(()=>{mockHttpService=jasmine.createSpyObj(['get']);configurationService=newConfigurationService(mockHttpService);});it('should load a configuration object when the loadConfiguration method is called',()=>{mockHttpService.get.and.returnValue(of(mockConfigObject));configurationService.loadConfiguration();tick();expect(Object.keys(configurationService.configData).length).toBe(Object.keys(mockConfigObject).length);});it('should handle the error when the loadConfiguration method is called and an error occurs',()=>{mockHttpService.get.and.returnValue(throwError(newError('test error')));configurationService.loadConfiguration();tick();expect(configurationService.configData).toBe(null);});});
Enter fullscreen modeExit fullscreen mode

Let's break these tests down a little. At the top of thedescribe, we set some "global" variables for the service. ThemockHttpService and theconfigurationService are initialized in thebeforeEach method. We usejasmine.createSpyObj to create a mockHttpClient instance. We tell it that we are going to mock theget method. If we needed other functions fromHttpClient, we would add them to that array.

In each of the two unit tests, we tell themockHttpService what to return when theget method is called. In the first one, we tell it to return ourmockConfigObject as an Observable. In the second, we usethrowError from RxJS. Again in both, we call theloadConfiguration method. We then do a check to see if the internalconfigData variable for the service is set to what we expect it to be.

Now, a real service for our app likely does many other things, like having a method to return thatconfigData object, or an attribute on the object, or any number of other functions. All of them can be tested in the same way as the above functions. If the service requires more dependencies, you can create each of them just like we created theHttpClient dependency.

I learned something else while writing the tests for a service in the library, and it came from Joe Eames. By default, the CLI creates the*.spec.ts file with theTestBed imported and set up. But many times you don't need that. As Joe put it, all these things in Angular are just classes and you can create instances of them. Many times that is sufficient and more simple than using theTestBed. What I've learned is that you'll know when you need theTestBed when you need it; until then just do what we've done here.

Testing Directives

The next Angular element we're going to go test is a directive. In this example, the test does get more complicated here. But don't worry, it's only overwhelming at first. I had someone demonstrate this to me and then I was able to use that example on a couple other directives and components. Hopefully this can be that example for you going forward.

The directive we're going to use here turns a text input into a typeahead input, outputting the new value after a specified debounce time. Here's that directive:

exportclassTypeaheadInputDirectiveimplementsAfterContentInit{@Input()debounceTime:number=300;@Output()valueChanged:EventEmitter<string>=newEventEmitter<string>();constructor(privatesearchInput:ElementRef){}ngAfterContentInit(){this.setupTypeaheadObservable();}setUpTypeaheadObservable(){fromEvent(this.searchInput.nativeElement,'keyup').pipe(debounceTime(this.debounceTime),distinctUntilChanged(),tap(()=>this.valueChanged.emit(this.searchInput.nativeElement.value)),).subscribe();}}
Enter fullscreen modeExit fullscreen mode

The goal in testing this directive is that when something is typed into theinput element, the value is emitted. So let's take a look at what the test looks like. This one will be different; to test that typing in theinput emits a value means creating aTestHostComponent which has theinput element and the directive. We'll create a typing event, and then check that the value is output.

@Component({selector:'app-test-host',template:`        <input typeaheadInput [debounceTime]="debounceTime" (valueChanged)="valueChanged($event)" type="text" />    `,})classTestHostComponent{@ViewChild(TypeaheadInputDirective)typeaheadInputDirective:TypeaheadInputDirective;publicdebounceTime:number=300;valueChanged(newValue:string){}}
Enter fullscreen modeExit fullscreen mode

This is just theTestHostComponent. We have access to the directive via the@ViewChild decorator. Then we use thedebounceTime input to control that in case we want to test what happens when we change that. Lastly we have avalueChanged function that will handle the output from the directive. We will use spy on that function for our test. Now for an actual test of the directive:

describe('TypeaheadInputDirective',()=>{letcomponent:TestHostComponent;letfixture:ComponentFixture<TestHostComponent>;beforeEach(async(()=>{TestBed.configureTestingModule({declarations:[TestHostComponent,TypeaheadInputDirective],}).compileComponents();}));beforeEach(()=>{fixture=TestBed.createComponent(TestHostComponent);component=fixture.componentInstance;fixture.detectChanges();});it('should emit value after keyup and debounce time',fakeAsync(()=>{spyOn(component,'valueChanged');constinput=fixture.debugElement.query(By.css('input'));input.nativeElement.value='Q';input.nativeElement.dispatchEvent(newKeyboardEvent('keyup',{bubbles:true,cancelable:true,key:'Q',shiftKey:true}),);tick(component.debounceTime);expect(component.valueChanged).toHaveBeenCalledWith('Q');}));});
Enter fullscreen modeExit fullscreen mode

In the twobeforeEach functions, we use theTestBed to create the testing fixture and get access to the component. Then, in the test, we add thespyOn on ourvalueChanged function. We then find theinput element and set the value to 'Q', and then dispatch theKeyboardEvent. We usetick to wait for thedebounceTime to pass, and then we check that thevalueChanged function has called with the stringQ.

As I said before, testing this directive was more involved than the other tests. But it's not too bad once we learn what's going on. We can use this same methodology on many other tests for more complicated components and directives. But remember: we should shoot for the most simple tests we can write to start. It will make it easier to maintain and write the tests and more likely for us to continue writing them.

Testing Components

The next Angular item we'll test is a component. This is going to be very similar to the directive we just tested. But, even though it'll look almost the exact same, I think it'll be worth going through the exercise of testing the component.

This component's purpose is to display a list of alerts that we want to show to our users. There is a related service that adds and removes the alerts and passes them along using aSubject. It is slightly complicated because we're going to use aTemplateRef to pass in the template that thengFor loop should use for the alerts. That way the implementing application can determine what the alerts should look like. Here's the component:

@Component({selector:'alerts-display',template:'<ng-template ngFor let-alert [ngForOf]="alerts$ | async" [ngForTemplate]="alertTemplate"></ng-template>',styleUrls:['./alerts-display.component.scss'],})exportclassAlertsDisplayComponentimplementsOnInit{publicalerts$:Subject<Alert[]>;@ContentChild(TemplateRef)alertTemplate:TemplateRef<NgForOfContext<Alert>>;constructor(private_alertToaster:AlertToasterService){}ngOnInit(){this.alerts$=this._alertToaster.alerts$;}}
Enter fullscreen modeExit fullscreen mode

That's all the component consists of. What we want to test is that when theSubject emits a new value, the template updates and shows that many items. We'll be able to simulate all this in our test. Let's look at ourTestHostComponent again in this test:

@Component({selector:'app-test-host',template:`        <alerts-display>            <ng-template let-alert>                <p>{{ alert.message }}</p>            </ng-template>        </alerts-display>    `,})classTestHostComponent{@ViewChild(AlertsDisplayComponent)alertsDisplayComponent:AlertsDisplayComponent;}
Enter fullscreen modeExit fullscreen mode

In thisTestHostComponent, we put the<alerts-display> component in the template, and provide the template for thengFor loop. Now let's look at the test itself:

describe('AlertsDisplayComponent',()=>{letcomponent:TestHostComponent;letfixture:ComponentFixture<TestHostComponent>;letmockAlertsToasterService:AlertToasterService;beforeEach(async(()=>{mockAlertsToasterService=jasmine.createSpyObj(['toString']);mockAlertsToasterService.alerts$=newSubject<Alert[]>();TestBed.configureTestingModule({declarations:[AlertsDisplayComponent,TestHostComponent],providers:[{provide:AlertToasterService,useValue:mockAlertsToasterService}],}).compileComponents();}));beforeEach(()=>{fixture=TestBed.createComponent(TestHostComponent);component=fixture.componentInstance;fixture.detectChanges();});it('should show an element for each item in the array list',fakeAsync(()=>{mockAlertsToasterService.alerts$.next([{message:'test message',level:'success'}]);tick();fixture.detectChanges();constpTags=fixture.debugElement.queryAll(By.css('p'));expect(pTags.length).toBe(1);}));});
Enter fullscreen modeExit fullscreen mode

Let's break down what we've got here. We're going to mock theAlertToasterService and get access to the fixture and component in thebeforeEach functions. Then in the test we emit a new array of alerts. This is what will happen in the service after theaddAlert function is called. Then all the places where theSubject is subscribed to will get the new list and output the results. We throw in atick to make sure that any necessary time has passed, and then (and this is important) we tell thefixture todetectChanges. It took me a while to remember that part, but if you forget it then the template won't update. After that, we can query thefixture to find all thep tags. Now, because we emitted an array with only one alert item, we will expect there to only be onep tag visible.

Again, this is a little more complicated than some components may be. Maybe on some components we don't want to test what the output in the template will be. We just want to test some functions on the component. In those cases, just create the component like this:

constcomponent=newMyComponent();
Enter fullscreen modeExit fullscreen mode

We can still mock services if needed, and pass them in to the constructor, but that should be our goal whenever possible. But don't be afraid when your test requires a more complicated test setup. It looks scary at first but after doing it a couple of times you'll get the hang of it.

Testing Guards

I debated whether or not I should include this section, because guards are essentially specialized services, but figured if I was going to spend all this time mapping out how to test all these different parts of our Angular app I might as well include this one specifically. So let's take a look at a guard. Here it is:

@Injectable()exportclassAuthenticationGuardimplementsCanActivate{constructor(private_authenticationService:AuthenticationService){}canActivate(next:ActivatedRouteSnapshot,state:RouterStateSnapshot):Observable<boolean>{constpreventIfAuthorized:boolean=next.data['preventIfAuthorized']asboolean;constredirectUrl:string=state.url&&state.url!=='/'?state.url:null;returnpreventIfAuthorized?this._authenticationService.allowIfAuthorized(redirectUrl):this._authenticationService.allowIfNotAuthorized(redirectUrl);}}
Enter fullscreen modeExit fullscreen mode

There's only one function we're going to test here: thecanActivate function. We'll need to mock theAuthenticationService] and theActivatedRouteSnapshot andRouterStateSnapshots for these tests. Let's take a look at the tests for this guard:

describe('AuthenticationGuard',()=>{letauthenticationGuard:AuthenticationGuard;letmockAuthenticationService;letmockNext:Partial<ActivatedRouteSnapshot>={data:{preventIfAuthorized:true,},};letmockState:Partial<RouterStateSnapshot>={url:'/home',};beforeEach(()=>{mockAuthenticationService=jasmine.createSpyObj(['allowIfAuthorized','allowIfNotAuthorized']);authenticationGuard=newAuthenticationGuard(mockAuthenticationService);});describe('Prevent Authorized Users To Routes',()=>{beforeEach(()=>{mockNext.data.preventIfAuthorized=true;});it('should return true to allow an authorized person to the route',async(()=>{mockAuthenticationService.allowIfAuthorized.and.returnValue(of(true));authenticationGuard.canActivate(<ActivatedRouteSnapshot>mockNext,<RouterStateSnapshot>mockState).subscribe((allow:boolean)=>{expect(allow).toBe(true);});}));it('should return false to not allow an authorized person to the route',async(()=>{mockAuthenticationService.allowIfAuthorized.and.returnValue(of(false));authenticationGuard.canActivate(<ActivatedRouteSnapshot>mockNext,<RouterStateSnapshot>mockState).subscribe((allow:boolean)=>{expect(allow).toBe(false);});}));});}
Enter fullscreen modeExit fullscreen mode

To begin with we have some mock data that we will use, like the for theRouterStateSnapshot and such. We create the mockAuthenticationService and create an instance of theAuthenticationGuard. We then test thecanActivate function whenallowIfAuthorized returns true and when it returns false. We call thecanActivate function, subscribe to the value, and then check that value to make sure it is what we expect it to be. To run these tests, since they're asynchronous, we can't forget to import and useasync from@angular/core/testing.

Conclusion

I hope that if you've made it this far, you've learned something new. I know I have over the past couple weeks as I've written these tests. It took me a long time to get started on writing unit tests for Angular because I felt overwhelmed. I didn't know where to start or what to test or how to write the tests. But I will absolutely say that I feel so much more confident in my Angular library with the tests than I've ever felt about any other application. I know immediately when I make a change if it's broken anything or not. It feels good to have that level of confidence. Hopefully this article can be a good reference for many people. I know it will be a good reference for me!

Top comments(0)

Subscribe
pic
Create template

Templates let you quickly answer FAQs or store snippets for re-use.

Dismiss

Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment'spermalink.

For further actions, you may consider blocking this person and/orreporting abuse

Preston Lamb is a full stack JavaScript developer, Angular GDE, ngChampion writer for the ng-conf blog, & co-organizer of the Angular Community Meetup.
  • Location
    Roy, UT
  • Education
    BS in Computer Science from Utah State University
  • Work
    Software Developer at MotivHealth
  • Joined

More fromPreston Lamb

DEV Community

We're a place where coders share, stay up-to-date and grow their careers.

Log in Create account

[8]ページ先頭

©2009-2025 Movatter.jp