Client Tests
If you are new to client testing, it is highly recommended that you work through the testing part of the angular tutorial.
We use Jest as our client testing framework.
There are different tools available to support client testing. We try to limit ourselves to Jest as much as possible. We use NgMocks for mocking the dependencies of an angular component.
The most basic test looks similar to this:
import { ComponentFixture, TestBed } from '@angular/core/testing';
describe('SomeComponent', () => {
let someComponentFixture: ComponentFixture<SomeComponent>;
let someComponent: SomeComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
SomeComponent,
MockPipe(SomePipeUsedInTemplate),
MockComponent(SomeComponentUsedInTemplate),
MockDirective(SomeDirectiveUsedInTemplate),
],
providers: [
MockProvider(SomeServiceUsedInComponent),
],
})
.compileComponents();
someComponentFixture = TestBed.createComponent(SomeComponent);
someComponent = someComponentFixture.componentInstance;
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should initialize', () => {
someComponentFixture.detectChanges();
expect(SomeComponent).not.toBeUndefined();
});
});
Some guidelines:
A component should be tested in isolation without any dependencies if possible. Do not simply import the whole production module. Only import real dependencies if it is essential for the test that the real dependency is used. Instead, use mock pipes, mock directives and mock components that the component under test depends upon. A very useful technique is writing stubs for child components. This has the benefit of being able to test the interaction with the child components.
Example of a bad test practice:
describe('ParticipationSubmissionComponent', () => { ... beforeEach(async () => { await TestBed.configureTestingModule({ imports: [ ArtemisTestModule, NgxDatatableModule, ArtemisResultModule, ArtemisSharedModule, TranslateModule.forRoot(), ParticipationSubmissionComponent, MockComponent(UpdatingResultComponent), MockComponent(AssessmentDetailComponent), MockComponent(ComplaintsForTutorComponent), ], providers: [ provideRouter([]), ], }) .overrideModule(ArtemisTestModule, { set: { declarations: [], exports: [] } }) .compileComponents(); }); });
Running time: 313.931s:
PASS src/test/javascript/spec/component/participation-submission/participation-submission.component.spec.ts (313.931 s, 625 MB heap size)
Example of a good test practice:
describe('ParticipationSubmissionComponent', () => { ... beforeEach(async () => { await TestBed.configureTestingModule({ imports: [ ArtemisTestModule, RouterTestingModule, NgxDatatableModule, ParticipationSubmissionComponent, MockComponent(UpdatingResultComponent), MockComponent(AssessmentDetailComponent), MockComponent(ComplaintsForTutorComponent), MockTranslateValuesDirective, MockPipe(ArtemisTranslatePipe), MockPipe(ArtemisDatePipe), MockPipe(ArtemisTimeAgoPipe), MockDirective(DeleteButtonDirective), MockComponent(ResultComponent), ], providers: [ provideRouter([]), ], }) .compileComponents(); }); });
Running time: 13.685s:
PASS src/test/javascript/spec/component/participation-submission/participation-submission.component.spec.ts (13.685 s, 535 MB heap size)
Now the whole testing suite is running ~25 times faster!
Here are the improvements for the test above:
Removed production module imports:
- ArtemisResultModule - ArtemisSharedModule - TranslateModule.forRoot()
Mocked pipes, directives and components that are not supposed to be tested:
+ MockTranslateValuesDirective + MockPipe(ArtemisTranslatePipe) + MockPipe(ArtemisDatePipe) + MockPipe(ArtemisTimeAgoPipe) + MockDirective(DeleteButtonDirective) + MockComponent(ResultComponent) + MockComponent(FaIconComponent)
More examples on test speed improvement can be found in the following PR.
Services should be mocked if they simply return some data from the server. However, if the service has some form of logic included (for example converting dates to datejs instances), and this logic is important for the component, do not mock the service methods, but mock the HTTP requests and responses from the API. This allows us to test the interaction of the component with the service and in addition test that the service logic works correctly. A good explanation can be found in the official angular documentation.
import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; describe('SomeComponent', () => { beforeEach(() => { TestBed.configureTestingModule({ imports: [...], providers: [ provideHttpClient(), provideHttpClientTesting(), ], }); ... httpMock = injector.get(HttpTestingController); }); afterEach(() => { ... httpMock.verify(); jest.restoreAllMocks(); }); it('should make get request', fakeAsync(() => { const returnedFromApi = {some: 'data'}; component.callServiceMethod() .subscribe((data) => expect(data.body).toEqual(returnedFromApi)); const req = httpMock.expectOne({ method: 'GET', url: 'urlThatMethodCalls' }); req.flush(returnedFromApi); tick(); })); });
Do not use
NO_ERRORS_SCHEMA
(angular documentation). This tells angular to ignore the attributes and unrecognized elements, prefer to use component stubs as mentioned above.Calling jest.restoreAllMocks() ensures that all mocks created with Jest get reset after each test. This is important if they get defined across multiple tests. This will only work if the mocks were created with jest.spyOn. Manually assigning jest.fn() should be avoided with this configuration.
Make sure to have at least 80% line test coverage. Run
npm test
to create a coverage report. You can also simply run the tests in IntelliJ IDEA with coverage activated.It is preferable to test a component through the interaction of the user with the template. This decouples the test from the concrete implementation used in the component. For example, if you have a component that loads and displays some data when the user clicks a button, you should query for that button, simulate a click, and then assert that the data has been loaded and that the expected template changes have occurred.
Here is an example of such a test for exercise-update-warning component
it('should trigger saveExerciseWithoutReevaluation once', () => { const emitSpy = jest.spyOn(comp.confirmed, 'emit'); const saveExerciseWithoutReevaluationSpy = jest.spyOn(comp, 'saveExerciseWithoutReevaluation'); const button = fixture.debugElement.nativeElement.querySelector('#save-button'); button.click(); fixture.detectChanges(); expect(saveExerciseWithoutReevaluationSpy).toHaveBeenCalledOnce(); expect(emitSpy).toHaveBeenCalledOnce(); });
Do not remove the template during tests by making use of
overrideTemplate()
. The template is a crucial part of a component and should not be removed during test. Do not do this:describe('SomeComponent', () => { let someComponentFixture: ComponentFixture<SomeComponent>; let someComponent: SomeComponent; beforeEach(async () => { await TestBed.configureTestingModule({ imports: [SomeComponent], providers: [ ... ], }) .overrideTemplate(SomeComponent, '') // DO NOT DO THIS .compileComponents(); someComponentFixture = TestBed.createComponent(SomeComponent); someComponent = someComponentFixture.componentInstance; }); });
Name the variables properly for test doubles:
const clearSpy = jest.spyOn(someComponent, 'clear'); const getNumberStub = jest.spyOn(someComponent, 'getNumber').mockReturnValue(42); // This always returns 42
Spy: Doesn’t replace any functionality but records calls
Mock: Spy + returns a specific implementation for a certain input
Stub: Spy + returns a default implementation independent of the input parameters.
Try to make expectations as specific as possible. If you expect a specific result, compare to this result and do not compare to the absence of some arbitrary other value. This ensures that no faulty values you didn’t expect can sneak in the codebase without the tests failing. For example
toBe(5)
is better thannot.toBeUndefined()
, which would also pass if the value wrongly changes to 6.When expecting results use
expect
for client tests. That call must be followed by another assertion statement liketoBeTrue()
. It is best practice to use more specific expect statements rather than always expecting boolean values. It is also recommended to extract as much as possible from the expect statement.For example, instead of
expect(course == undefined).toBeTrue(); expect(courseList).toHaveLength(4);
extract as much as possible:
expect(course).toBeUndefined(); expect(courseList).toHaveLength(4);
If you have minimized
expect
, use the verification function that provides the most meaningful error message in case the verification fails. You can use verification functions from core Jest or from Jest Extended.For situations described below, only use the uniform solution to keep the codebase as consistent as possible.
Situation
Solution
Expecting a boolean value
expect(value).toBeTrue();
expect(value).toBeFalse();
Two objects should be the same reference
expect(object).toBe(referenceObject);
A CSS element should exist
A CSS element should not exist
expect(element).not.toBeNull();
expect(element).toBeNull();
A value should be undefined
expect(value).toBeUndefined();
A value should be either null or undefined
Use
expect(value).toBeUndefined();
for internal calls.If an external library uses null value, use
expect(value).toBeNull();
and if not avoidableexpect(value).not.toBeNull();
.Never use
expect(value).not.toBeDefined()
orexpect(value).toBeNil()
as they might not catch all failures under certain conditions.A class object should be defined
Always try to test for certain properties or entries.
expect(classObject).toContainEntries([[key, value]]);
expect(classObject).toEqual(expectedClassObject);
Never use
expect(value).toBeDefined()
as it might not catch all failures under certain conditions.A class object should not be undefined
Try to test for a defined object as described above.
A spy should not have been called
expect(spy).not.toHaveBeenCalled();
A spy should have been called once
expect(spy).toHaveBeenCalledOnce();
A spy should have been called with a value
Always test the number of calls as well:
expect(spy).toHaveBeenCalledOnce(); expect(spy).toHaveBeenCalledWith(value);If you have multiple calls, you can verify the parameters of each call separately:
expect(spy).toHaveBeenCalledTimes(3); expect(spy).toHaveBeenNthCalledWith(1, value0); expect(spy).toHaveBeenNthCalledWith(2, value1); expect(spy).toHaveBeenNthCalledWith(3, value2);