Beginner’s Guide to Ionic Angular Unit Testing (Part 2) — Mocks and Spies
In our first part of this series, we saw how to set up the basic testing environment and got familiar with some of the terminologies for Unit Testing in the Angular world. Now you should be able to know — Karma, Jasmine tests, TestBed, Unit Testing: Mocks & Stubs etc.
In this part, we will move further and add some more functionality to our app with the help of Angular Services(Testing services). As service dependencies will be injected into the components they will increase the complexity of our tests. Let’s jump ahead to the coding part.
We have added some code in a new Service called ApiService
— this service will be used by our page to add jobs and store existing jobs. As you know in any complex application, services should have the business logic and components should do only view update related stuff. So we prefer to use Services as basic building blocks for business logic
The above code also has an AlertComponent
, which we will use to show that job has been added to the Job List successfully.
Now let’ see how our component/page code looks like :
Very simple code. this.jobs
still will have the job array — through the binding to service variable — But now data is stored in the service. addJob
function has the same structure except for the logic — which has been moved to the service.
At this moment if you will run your tests they will pass! Surprisingly 😮
As we only tested jobs
in our previous tests - which is still the same and function calls also remain the same.
However, now I want to add a more deep test which will check if addJob
function of apiService
is called or not. This function is now doing the main job of storing the data in jobs
array. Now let us move forward in Angular testing using Spies by looking at testing with mocks & spies.
SPIES in Testing🕵
For checking whether a function is called we create something called SPY. This spy is actually a fake implementation and it runs in place of the actual function. Test spy is an object that records its interaction with other objects all round the code base.
So we need something like this in our test code
spyOn(apiService, 'addJob');
The above statement means the object apiService
is the parent object which has addjob
target function. Only this target functions will be spied upon.
But wait !! how we got this apiService
in the Test file? We don’t have discussed any concept of service injection in Test files yet.
Here’s how we can inject the apiService
in our TestBed (for testing services with the TestBed) — very similar to as we do in the module. Under the providers
TestBed.configureTestingModule({ declarations: [Tab1Page], imports: [IonicModule.forRoot(), ExploreContainerComponentModule], providers: [ ApiService ] }).compileComponents();
However, I will not like to add the real Service here — WHY ?? 🤔
The reason is that your app may get too complicated and we might not want to understand what service is doing. We just want to test our component’s code independently.
So we MOCK our service also.
After having a closer look at test spies let us move to mocking with fake classes.
MOCKS in Testing 🐵
Mocks or Stubs as they are rightly called — represent a fake object or class which contains similar signature (objects and methods) to that of the original class. These functions may not do anything but their fake implementation is helpful in testing the unknown part of code.
Most people actually refer to Test Doubles while talking about Mocks. A Test Double is simply another object that can be passed in its place, which is similar to the interface of the required Collaborator.
Let us see mocking in testing. For example, in our component testing, we don’t need to know what getJobs
is doing and how. We just know that it returns an array so we can stub that method in our Mock.
Here is an example of ApiServiceStub :
const ApiServiceStub = { addJob: () => null, getJobs: () => [] };
Yes, it does nothing but returns something similar to what actual function returns. Now let’s use this Stub/Mock to create a fake ApiService
in our test file. We will use providers again to inject this. However this time we will use
providers: [{ provide: ApiService, useValue: ApiServiceStub },]
Above Syntax can read as ApiService
created from an Object value ApiServiceStub
. In Place of useValue
you can use another syntax useClass
sometimes — when you have a Class for the Mock/Stub.
So now we have a Service ApiService
also available in our Test file. But as we do it in components, we will instantiate a new object from Service. In components we use constructor this — here we will use TestBed’s inject
method. We will add a line :
apiService = TestBed.inject(ApiService)
Now let’s see our completed tests file. Look at how we added Service file, how we instantiated it, and how we changed the Last Test :
If you check the last test we added a SPY and also we expected that those functions will be called. We are using toHaveBeenCalled
and toHaveBeenCalledWith
to check if the Spied function was called. The two functions toHaveBeenCalled
and toHaveBeenCalledWith
only differ slightly. The later one also checks whether the argument passes also matches. We have just used both to showcase the difference. In the above scenario, You can only have to use the second one toHaveBeenCalledWith
— as it tells whether functions are being called and also the arguments.
If you run the test now — it will have a failure. And failure is in the Test which was passing earlier. WHY ?? Any guesses?
As you have stubbed the service with fake implementation — your component is always using the fake implementation and you are always getting an empty array in return from getJobs
. Above test expected more than 1 element.
Now you know testing is hard if you don’t think of side-effects.
So, I will not inject this Mock Service globally and inject it in a specific case only where it is needed. I am using Testbed.OverrideProvider
in this case.
But in real-life testing, you will want to have the only stub and not real service at all. So above ugly workaround is not something to be proud of.
Let’s rethink our code how we can make all tests work with Stubbed Service. Can there be a way that our fake getJobs
return something meaningful? The answer is YES
SPY methods
Till now we just used SpyOn, to test the component logic, to create a SPY for a function. But that spy was not doing anything expect capturing the call to function. However, we have a lot of methods available on SpyOn output object — which can provide some life to our spied on methods. These are :
- returnValue — this returns a value you mention from the spied method
- callFake — this provides a way to implement some fake function to be called when it is spied. You can even replace it will the code of the original method. But mostly a bad idea as testing should be simpler.
- callThrough — this is also a way in which you still want to use the SPY but leave the functionality un-touched and the original method can be called. In the case of our Test file — this original method is still not the one in service method — it is the one in the Stub Object -
getJobs
. So we can’t use it here.
Let’s use the easiest one returnValue
it('addJob should add the job string to jobs array', () => {
const job = 'Dummy Job'; spyOn(apiService, 'addJob'); spyOn(apiService, 'getJobs').and.returnValue([job]);
component.addJob(job); expect(component.jobs.length).toBeGreaterThan(0); expect(component.jobs).toContain(job);
});
So we have spied on both functions addJob
and getJobs
. However, getJobs
now is returning the value of an array [job]
. Yes, we have hardcoded the value here — but that’s ok for testing components, as we already believe services are working fine, they will be tested separately.
Now we have to change the file again — stub providers only at the top & no ugly overrides. However, there will be still a problem as getJobs
will run even before the spies work. Because it is written in the constructor.
We can use ngOnInit
which is more flexible and its call can be controlled in the Testing environment by
fixture.detectChanges();
This line controls the angular component loading activity and also calls ngOnInit
. So we will remove this line from the common section of beforeEach
and call only in the function we need to have ngOnInit
called.
Carefully see the line where we added fixture.detectChanges()
— Just after putting the spies. Be careful about placing the things in the test. If you put spies after the action — they will not act properly.
Now again let’s run tests.
All passing !!
But, we did cheating here. We have just passed something in getJobs
which was not called sent by addJob
— this means it’s not checking the actions of addJob
.
So can we do better? Let’s change the code a little bit.
const jobs = [];
spyOn(apiService, 'addJob').and.callFake((jb)=>{ jobs.push(jb); });
spyOn(apiService, 'getJobs').and.returnValue(jobs);
Now you can see — jobs
is the common array accessed by both getJobs
and addJob
. Also, we have used callFake
which give us more power to change the functionality of addJob
functions.
Test Again.
All Passing !! 😇