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 NgMocks for mocking the dependencies of an angular component.
You can run all tests by invoking pnpm test in the root directory of the Artemis project.
If you want to run individual tests, you can use the following commands:
- Run all tests in a file:
pnpm run prebuild && pnpm exec vitest run src/main/webapp/app/fileupload/manage/assess/file-upload-assessment.component.spec.ts - Run all tests in a module or folder:
pnpm run prebuild && pnpm exec vitest run src/main/webapp/app/fileupload/
The prebuild step builds the UI kit in packages/tum-aet-ui/dist, which the tests import as @tumaet/ui-angular. Without it, a checkout that has not built the kit
fails to resolve that import. It is only needed once per checkout and after a change of the kit, and pnpm run test:one <path> does both steps.
Guidelines Overview
The following sections outline best practices for writing client tests in Artemis.
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,
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,
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.
Testing Directives
Test a directive that acts on its own element with TestBed.createDirective instead of declaring a host component
only to apply it. TestBed creates the host element, applies the directive to it and returns a DirectiveFixture. The
fixture exposes the directive as directiveInstance and the host element as nativeElement, and offers
detectChanges(), whenStable() and destroy() like a component fixture. Inputs, outputs and models are bound with
inputBinding, outputBinding and twoWayBinding from @angular/core: inputBinding reads a signal or a getter
function, outputBinding calls a handler, and twoWayBinding writes a model() back into a writable signal.
import { WritableSignal, inputBinding, outputBinding, signal } from '@angular/core';
import { DirectiveFixture, TestBed } from '@angular/core/testing';
import { MarkdownDirective } from 'app/foundation/directives/markdown.directive';
describe('MarkdownDirective', () => {
let fixture: DirectiveFixture<MarkdownDirective>;
let text: WritableSignal<string | undefined>;
let renderCount: number;
beforeEach(() => {
text = signal<string | undefined>(undefined);
renderCount = 0;
fixture = TestBed.createDirective(MarkdownDirective, {
tagName: 'div',
bindings: [inputBinding('jhiMarkdown', text), outputBinding('markdownRendered', () => renderCount++)],
});
});
it('renders markdown into the host element', async () => {
text.set('some **bold** text');
fixture.detectChanges();
await vi.waitFor(() => {
fixture.detectChanges();
expect(fixture.nativeElement.innerHTML).toContain('<strong>bold</strong>');
});
expect(renderCount).toBe(1);
});
});
Pass tagName whenever the selector does not name exactly one element: createDirective throws for [jhiMarkdown],
which names none, and for a selector that names two different tags. table[tumAetUiTable] creates a <table> on its
own. Bind an input by its public name, which is the alias where the directive declares one; a name the directive does
not have throws NG0315 in development mode. Register providers with TestBed.configureTestingModule before calling
createDirective; compileComponents() is not needed.
Output bindings are subscribed as soon as the directive exists, but input bindings are applied on the first
fixture.detectChanges(). Until then an input holds its default value, and reading a required input throws. After
that, a changed signal reaches the directive on the next fixture.detectChanges().
The host element starts out empty, directly under <body>, and the directive's constructor has already run when
createDirective returns. That is no different from a template: Angular creates a directive before the child elements
of its host, so a constructor sees no children there either. Content the directive reads in ngOnInit or later can be
appended to fixture.nativeElement before the first fixture.detectChanges(). fixture.debugElement is the host
element, and it belongs to an internal empty component, so debugElement.query() does not find elements inside it.
Query them through fixture.nativeElement instead.
Keep a host component where the directive needs a template around it:
- Structural directives, which inject a
TemplateRef, such as*jhiHasAnyAuthorityand*jhiExtensionPoint. - Directives that need something around or beside them, or content Angular keeps rendering inside them:
jhiSortByneedsjhiSorton an enclosing element and reads the icon projected into it, a validator tested throughngModelneeds theNgModelbeside it, andinfinite-scrollhas to keep its sentinels around a@forlist. - Directives whose constructor reads a static attribute the template writes on the host, such as the
hrefthatjhiSecureLinkmust leave alone. Angular writes static attributes before it creates the directive, whichcreateDirectivecannot do. - Inputs that take a
TemplateRefor a component instance declared in the same template, such asjhiStickyPopoverandtumAetUiPopoverTrigger.
An abstract base directive is still tested through a concrete subclass. One spec can use both: createDirective for
the cases that fit, and a small host component, with a comment saying why, for the case that needs a template.
secure-link.directive.spec.ts does this for its href case.
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();
vi.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
vi.restoreAllMocks()inafterEach.- This is important if they get defined across multiple tests
- Only works if mocks are created with
vi.spyOn. - Avoid manually assigning
vi.fn().
Coverage & Quality
-
Ensure at least 80% line coverage.
- Run
pnpm run vitest:coveragefor a coverage report. - Or run the tests in IntelliJ IDEA with coverage activated.
- Run
-
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 = vi.spyOn(comp.confirmed, 'emit');
const saveExerciseWithoutReevaluationSpy = vi.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;
});
});
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 = vi.spyOn(component, 'clear');
const getNumberStub = vi.spyOn(component, 'getNumber').mockReturnValue(42);
Expectations
-
Make expectations as specific as possible.
expect(value).toBe(5)is better thanexpect(value).not.toBeUndefined().
-
Always follow
expectwith a matcher.- Use meaningful, specific assertions instead of generic booleans.
- Extract as much as possible from the
expectstatement - 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 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 | expect(element).not.toBeNull(); |
| A CSS element should not exist | 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); |