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. For mocking Angular dependencies, we use NgMocks for mocking the dependencies of an angular component.

General Test Pattern

The most basic test looks like 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();
    });
});

Guidelines Overview

The following sections outline best practices for writing client tests in Artemis.

1. Test Isolation

  • Always test a component in isolation.

  • Do not import entire production modules.

  • Use mock pipes, directives, and components where possible.

  • Only use real dependencies if absolutely necessary.

  • Prefer stubs for child components.

Example: Bad 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();
    });
});

Imports large modules → test runtime: 313.931s:

PASS  src/test/javascript/spec/component/participation-submission/participation-submission.component.spec.ts (313.931 s, 625 MB heap size)
Example: Good 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();
    });
});

Mocks dependencies → test runtime: 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.

2. Mocking Rules

  • Services:
    • Mock services if they just return data from the server.

    • If the service has important logic → keep the real service but mock HTTP requests and responses instead.

    • 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 = TestBed.inject(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();
        }));
    });
    
  • Never use NO_ERRORS_SCHEMA (angular documentation). Use stubs/mocks instead.

  • Reset mocks with jest.restoreAllMocks() in afterEach.
    • This is important if they get defined across multiple tests

    • Only works if mocks are created with jest.spyOn.

    • Avoid manually assigning jest.fn().

3. Coverage & Quality

  • Ensure at least 80% line coverage.
  • Prefer user-interaction tests instead of testing internal methods directly.
    • 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 use overrideTemplate() to remove templates.
    • The template is part of the component and must be tested.

    • 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;
        });
    });
    

4. Naming Test Doubles

Use clear terminology for test doubles:

  • Spy → observes calls, no replacement.

  • Mock → spy + returns custom values for specific inputs.

  • Stub → spy + returns fixed values regardless of input.

Example:

const clearSpy = jest.spyOn(component, 'clear');
const getNumberStub = jest.spyOn(component, 'getNumber').mockReturnValue(42);

5. Expectations

  • Make expectations as specific as possible. * expect(value).toBe(5) is better than expect(value).not.toBeUndefined().

  • Always follow expect with a matcher.
    • Use meaningful, specific assertions instead of generic booleans.

    • 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. Use matchers from Jest and Jest Extended.

  • Use a uniform style for common cases 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 avoidable expect(value).not.toBeNull();.

    Never use expect(value).not.toBeDefined() or expect(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);