Files
ISA-Frontend/docs/guidelines/testing.md
Lorenz Hilpert aff6d18888 feat: enhance filter input mappings with detailed documentation and schema validation
- Added comprehensive JSDoc comments to mapping functions for checkbox and text filter inputs, improving code readability and maintainability.
- Refactored checkboxFilterInputMapping and checkboxOptionMapping functions to enhance clarity and structure.
- Removed unused data-range-filter-input mapping files and tests to streamline the codebase.
- Introduced a new dateRangeFilterInputMapping function with detailed mapping logic for date range inputs.
- Updated filter input schemas to include descriptive comments for better understanding of properties.
- Implemented unit tests for date range filter input mapping to ensure correct functionality.
- Enhanced existing filter mapping functions with improved error handling and validation.
- Updated index exports to reflect the removal and addition of mapping files.
2025-04-11 16:13:11 +02:00

20 KiB

Testing Guidelines

Unit Testing Requirements

  • Test files should end with .spec.ts
  • Use Spectator for Component, Directive and Service tests
  • Use Jest as the test runner
  • Follow the Arrange-Act-Assert (AAA) pattern in tests
  • Mock external dependencies to isolate the unit under test
  • Mock child components to ensure true unit testing isolation

Spectator Overview

Spectator is a powerful library that simplifies Angular testing by:

  • Reducing boilerplate code in tests
  • Providing easy DOM querying utilities
  • Offering a clean API for triggering events
  • Supporting testing of components, directives, and services
  • Including custom matchers for clearer assertions

Best Practices

Component Testing

  • Use createComponentFactory for standalone components
  • Use createHostFactory when testing components with templates and inputs/outputs
  • Use createDirectiveFactory for testing directives
  • Use createServiceFactory for testing services
  • Test component inputs, outputs, and lifecycle hooks
  • Verify DOM rendering and component behavior separately

Mocking Child Components

Always mock child components to:

  • Isolate the component under test
  • Prevent unintended side effects
  • Reduce test complexity
  • Improve test performance
import { MockComponent } from 'ng-mocks';
import { ChildComponent } from './child.component';

describe('ParentComponent', () => {
  const createComponent = createComponentFactory({
    component: ParentComponent,
    declarations: [MockComponent(ChildComponent)],
  });
});

Spectator API Reference

Core Factory Methods

  1. For Components:

    const createComponent = createComponentFactory({
      component: MyComponent,
      imports: [SomeModule],
      declarations: [SomeDirective],
      providers: [SomeService],
      componentProviders: [], // Providers specific to the component
      componentViewProviders: [], // ViewProviders for the component
      mocks: [ServiceToMock], // Automatically mocks the service
      detectChanges: false, // Whether to run change detection initially
    });
    
  2. For Components with Host:

    const createHost = createHostFactory({
      component: MyComponent,
      template: `<app-my [prop]="value" (event)="handle()"></app-my>`,
      // ...other options similar to createComponentFactory
    });
    
  3. For Directives:

    const createDirective = createDirectiveFactory({
      directive: MyDirective,
      template: `<div myDirective [prop]="value"></div>`,
      // ...other options
    });
    
  4. For Services:

    const createService = createServiceFactory({
      service: MyService,
      providers: [DependencyService],
      mocks: [HttpClient],
      entryComponents: [],
    });
    
  5. For HTTP Services:

    const createHttpService = createHttpFactory({
      service: MyHttpService,
      providers: [SomeService],
      mocks: [TokenService],
    });
    

Querying Elements

Spectator offers multiple ways to query the DOM:

// Basic CSS selectors
const button = spectator.query('button.submit');
const inputs = spectator.queryAll('input');

// By directive/component type
const childComponent = spectator.query(ChildComponent);
const directives = spectator.queryAll(MyDirective);

// Advanced text-based selectors
spectator.query(byText('Submit'));
spectator.query(byLabel('Username'));
spectator.query(byPlaceholder('Enter your email'));
spectator.query(byValue('Some value'));
spectator.query(byTitle('Click here'));
spectator.query(byAltText('Logo image'));
spectator.query(byRole('button', { pressed: true }));

// Accessing native elements
const { nativeElement } = spectator.query('.class-name');

Working with Inputs and Outputs

// Setting component inputs
spectator.setInput('username', 'JohnDoe');
spectator.setInput({
  username: 'JohnDoe',
  isActive: true,
});

// For host components
spectator.setHostInput('propName', value);

// Working with outputs
const outputSpy = jest.fn();
spectator.output('statusChange').subscribe(outputSpy);

// Trigger and verify outputs
spectator.click('button.submit');
expect(outputSpy).toHaveBeenCalledWith({ status: 'submitted' });

Event Triggering API

Spectator provides a rich API for simulating user interactions:

// Mouse events
spectator.click('.button');
spectator.doubleClick('#item');
spectator.hover('.tooltip');
spectator.mouseEnter('.dropdown');
spectator.mouseLeave('.dropdown');

// Keyboard events
spectator.keyboard.pressEscape();
spectator.keyboard.pressEnter();
spectator.keyboard.pressKey('A');
spectator.keyboard.pressKeys('ctrl.a');

// Form interactions
spectator.typeInElement('New value', 'input.username');
spectator.blur('input.username');
spectator.focus('input.password');
spectator.selectOption(selectEl, 'Option 2');

// Custom events
spectator.triggerEventHandler(MyComponent, 'customEvent', eventObj);
spectator.dispatchFakeEvent(element, 'mouseover');
spectator.dispatchTouchEvent(element, 'touchstart');

Example Test Structures

Basic Component Test

import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { MyComponent } from './my-component.component';

describe('MyComponent', () => {
  let spectator: Spectator<MyComponent>;
  const createComponent = createComponentFactory(MyComponent);

  beforeEach(() => {
    spectator = createComponent();
  });

  it('should create', () => {
    expect(spectator.component).toBeTruthy();
  });

  it('should handle action correctly', () => {
    // Arrange
    spectator.setInput('inputProp', 'testValue');

    // Act
    spectator.click('button');

    // Assert
    expect(spectator.component.outputProp).toBe('expectedValue');
  });
});

Host Component Test with Child Components

import { createHostFactory, SpectatorHost } from '@ngneat/spectator/jest';
import { ParentComponent } from './parent.component';
import { ChildComponent } from './child.component';
import { MockComponent } from 'ng-mocks';

describe('ParentComponent', () => {
  let spectator: SpectatorHost<ParentComponent>;

  const createHost = createHostFactory({
    component: ParentComponent,
    declarations: [MockComponent(ChildComponent)],
    template: `
      <app-parent 
        [input]="inputValue" 
        (output)="handleOutput($event)">
      </app-parent>`,
  });

  beforeEach(() => {
    spectator = createHost(undefined, {
      hostProps: {
        inputValue: 'test',
        handleOutput: jest.fn(),
      },
    });
  });

  it('should pass input to child component', () => {
    // Arrange
    const childComponent = spectator.query(ChildComponent);

    // Assert
    expect(childComponent.input).toBe('test');
  });
});

Testing Events and Outputs

it('should emit when button is clicked', () => {
  // Arrange
  const outputSpy = jest.fn();
  spectator.component.outputEvent.subscribe(outputSpy);

  // Act
  spectator.click('button');

  // Assert
  expect(outputSpy).toHaveBeenCalledWith(expectedValue);
});

Testing Services

import { createServiceFactory, SpectatorService } from '@ngneat/spectator/jest';
import { UserService } from './user.service';
import { HttpClient } from '@angular/common/http';

describe('UserService', () => {
  let spectator: SpectatorService<UserService>;
  let httpClient: HttpClient;

  const createService = createServiceFactory({
    service: UserService,
    mocks: [HttpClient],
  });

  beforeEach(() => {
    spectator = createService();
    httpClient = spectator.inject(HttpClient);
  });

  it('should fetch users', () => {
    // Arrange
    const mockUsers = [{ id: 1, name: 'John' }];
    httpClient.get.mockReturnValue(of(mockUsers));

    // Act
    let result;
    spectator.service.getUsers().subscribe((users) => {
      result = users;
    });

    // Assert
    expect(httpClient.get).toHaveBeenCalledWith('/api/users');
    expect(result).toEqual(mockUsers);
  });
});

Testing HTTP Services

import {
  createHttpFactory,
  HttpMethod,
  SpectatorHttp,
} from '@ngneat/spectator/jest';
import { UserHttpService } from './user-http.service';

describe('UserHttpService', () => {
  let spectator: SpectatorHttp<UserHttpService>;

  const createHttp = createHttpFactory({
    service: UserHttpService,
  });

  beforeEach(() => (spectator = createHttp()));

  it('should call the correct API endpoint when getting users', () => {
    spectator.service.getUsers().subscribe();
    spectator.expectOne('/api/users', HttpMethod.GET);
  });

  it('should include auth token in the headers', () => {
    spectator.service.getUsers().subscribe();
    const req = spectator.expectOne('/api/users', HttpMethod.GET);
    expect(req.request.headers.get('Authorization')).toBeTruthy();
  });
});

Testing Directives

import {
  createDirectiveFactory,
  SpectatorDirective,
} from '@ngneat/spectator/jest';
import { HighlightDirective } from './highlight.directive';

describe('HighlightDirective', () => {
  let spectator: SpectatorDirective<HighlightDirective>;

  const createDirective = createDirectiveFactory({
    directive: HighlightDirective,
    template: `<div highlight="yellow">Testing</div>`,
  });

  beforeEach(() => (spectator = createDirective()));

  it('should change the background color', () => {
    expect(spectator.element).toHaveStyle({
      backgroundColor: 'yellow',
    });
  });

  it('should respond to mouse events', () => {
    spectator.dispatchMouseEvent(spectator.element, 'mouseover');
    expect(spectator.element).toHaveStyle({
      backgroundColor: 'yellow',
      fontWeight: 'bold',
    });

    spectator.dispatchMouseEvent(spectator.element, 'mouseout');
    expect(spectator.element).toHaveStyle({
      backgroundColor: 'yellow',
      fontWeight: 'normal',
    });
  });
});

Testing Angular Standalone Components

import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { StandaloneComponent } from './standalone.component';

describe('StandaloneComponent', () => {
  let spectator: Spectator<StandaloneComponent>;

  const createComponent = createComponentFactory({
    component: StandaloneComponent,
    // No need for imports as they are part of the component itself
  });

  beforeEach(() => {
    spectator = createComponent();
  });

  it('should create standalone component', () => {
    expect(spectator.component).toBeTruthy();
  });
});

Testing Deferrable Views (@defer)

import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { ComponentWithDefer } from './component-with-defer.component';

describe('ComponentWithDefer', () => {
  let spectator: Spectator<ComponentWithDefer>;

  const createComponent = createComponentFactory({
    component: ComponentWithDefer,
  });

  beforeEach(() => {
    spectator = createComponent();
  });

  it('should render defer block content', () => {
    // Render the completed state of the first defer block
    spectator.deferBlock().renderComplete();

    expect(spectator.query('.deferred-content')).toExist();
    expect(spectator.query('.placeholder-content')).not.toExist();
  });

  it('should show loading state', () => {
    // Render the loading state of the first defer block
    spectator.deferBlock().renderLoading();

    expect(spectator.query('.loading-indicator')).toExist();
  });

  it('should show placeholder content', () => {
    // Render the placeholder state of the first defer block
    spectator.deferBlock().renderPlaceholder();

    expect(spectator.query('.placeholder-content')).toExist();
  });

  it('should work with multiple defer blocks', () => {
    // For the second defer block in the template
    spectator.deferBlock(1).renderComplete();

    expect(spectator.query('.second-deferred-content')).toExist();
  });
});

Common Patterns

Query Elements

// By CSS selector
const element = spectator.query('.class-name');

// By directive/component
const child = spectator.query(ChildComponent);

// Multiple elements
const elements = spectator.queryAll('.item');

Trigger Events

// Click events
spectator.click('.button');
spectator.click(buttonElement);

// Input events
spectator.typeInElement('value', 'input');

// Custom events
spectator.triggerEventHandler(MyComponent, 'eventName', eventValue);

Custom Matchers

Spectator provides custom matchers to make assertions more readable:

// DOM matchers
expect('.button').toExist();
expect('.inactive-element').not.toExist();
expect('.title').toHaveText('Welcome');
expect('.username').toContainText('John');
expect('input').toHaveValue('test');
expect('.error').toHaveClass('visible');
expect('button').toBeDisabled();
expect('div').toHaveAttribute('aria-label', 'Close');
expect('.menu').toHaveData({ testId: 'main-menu' });
expect('img').toHaveProperty('src', 'path/to/image.jpg');
expect('.parent').toHaveDescendant('.child');
expect('.parent').toHaveDescendantWithText({
  selector: '.child',
  text: 'Child text',
});

// Object matchers
expect(object).toBePartial({ id: 1 });

Test Async Operations

it('should handle async operations', async () => {
  // Arrange
  const response = { data: 'test' };
  service.getData.mockResolvedValue(response);

  // Act
  await spectator.component.loadData();

  // Assert
  expect(spectator.component.data).toEqual(response);
});

Tips and Tricks

  1. Debugging Tests

    • Use spectator.debug() to log the current DOM state
    • Use console.log sparingly and remove before committing
    • Set breakpoints in your IDE for step-by-step debugging
  2. Common Pitfalls

    • Don't test implementation details
    • Avoid testing third-party libraries
    • Don't test multiple concerns in a single test
    • Remember to clean up subscriptions
  3. Performance

    • Mock heavy dependencies
    • Keep test setup minimal
    • Use beforeAll for expensive operations shared across tests
  4. Change Detection

    • Use spectator.detectChanges() after modifying component properties
    • For OnPush components with a host, use spectator.detectComponentChanges()
  5. Injection

    • Use spectator.inject(Service) to access injected services
    • Use spectator.inject(Service, true) to get service from the component injector

Running Tests

When working in an Nx workspace, there are several ways to run tests:

Running Tests for a Specific Project

# Run all tests for a specific project
npx nx test <project-name>

# Example: Run tests for the core/config library
npx nx test core-config

Running Tests with Watch Mode

# Run tests in watch mode for active development
npx nx test <project-name> --watch

Running Tests with Coverage

# Run tests with coverage reporting
npx nx test <project-name> --code-coverage

Running a Specific Test File

# Run a specific test file
npx nx test <project-name> --test-file=path/to/your.spec.ts

Running Affected Tests

# Run tests only for projects affected by recent changes
npx nx affected:test

These commands help you target exactly which tests to run, making your testing workflow more efficient.

References

ng-mocks Guide

Overview

ng-mocks is a powerful library that helps with Angular testing by:

  • Mocking Components, Directives, Pipes, Modules, Services, and Tokens
  • Reducing boilerplate in tests
  • Providing a simple interface to access declarations

It's particularly useful for isolating components by mocking their dependencies, which makes tests faster and more reliable.

Global Configuration

For optimal setup, configure ng-mocks in your test setup file:

// src/test-setup.ts or equivalent
import { ngMocks } from 'ng-mocks';

// Auto-spy all methods in mock declarations and providers
ngMocks.autoSpy('jest'); // or 'jasmine'

// Reset customizations after each test automatically
ngMocks.defaultMock(AuthService, () => ({
  isLoggedIn$: EMPTY,
  currentUser$: EMPTY,
}));

Key APIs

MockBuilder

MockBuilder provides a fluent API to configure TestBed with mocks:

beforeEach(() => {
  return MockBuilder(
    ComponentUnderTest, // Keep this as real
    ParentModule, // Mock everything else
  )
    .keep(ReactiveFormsModule) // Keep this as real
    .mock(SomeOtherDependency, { customConfig: true }); // Custom mock
});

MockRender

MockRender is an enhanced version of TestBed.createComponent that:

  • Respects all lifecycle hooks
  • Handles OnPush change detection
  • Creates a wrapper component that binds inputs and outputs
// Simple rendering
const fixture = MockRender(ComponentUnderTest);

// With inputs
const fixture = MockRender(ComponentUnderTest, {
  name: 'Test User',
  id: 123,
});

// Access the component instance
const component = fixture.point.componentInstance;

MockInstance

MockInstance helps configure mocks before they're initialized:

// Adding a spy
const saveSpy = MockInstance(StorageService, 'save', jest.fn());

// Verify the spy was called
expect(saveSpy).toHaveBeenCalledWith(expectedData);

ngMocks Helpers

The library provides several helper functions:

// Change form control values
ngMocks.change('[name=email]', 'test@example.com');

// Trigger events
ngMocks.trigger(element, 'click');
ngMocks.trigger(element, 'keyup.control.s'); // Complex events

// Find elements
const emailField = ngMocks.find('[name=email]');
const submitBtn = ngMocks.findAll('button[type="submit"]');

Complete Example

Here's a full example of testing a component with ng-mocks:

describe('ProfileComponent', () => {
  // Reset customizations after each test
  MockInstance.scope();

  beforeEach(() => {
    return MockBuilder(ProfileComponent, ProfileModule).keep(
      ReactiveFormsModule,
    );
  });

  it('saves profile data on ctrl+s hotkey', () => {
    // Prepare test data
    const profile = {
      email: 'test@email.com',
      firstName: 'Test',
      lastName: 'User',
    };

    // Mock service method
    const saveSpy = MockInstance(StorageService, 'save', jest.fn());

    // Render with inputs
    const { point } = MockRender(ProfileComponent, { profile });

    // Change form value
    ngMocks.change('[name=email]', 'updated@email.com');

    // Trigger hotkey
    ngMocks.trigger(point, 'keyup.control.s');

    // Verify behavior
    expect(saveSpy).toHaveBeenCalledWith({
      email: 'updated@email.com',
      firstName: profile.firstName,
      lastName: profile.lastName,
    });
  });
});

Integration with Spectator

While Spectator and ng-mocks have some overlapping functionality, they can be used together effectively:

describe('CombinedExample', () => {
  let spectator: Spectator<MyComponent>;

  const createComponent = createComponentFactory({
    component: MyComponent,
    declarations: [
      // Use ng-mocks to mock child components
      MockComponent(ComplexChildComponent),
      MockDirective(ComplexDirective),
    ],
    providers: [
      // Use ng-mocks to mock a service with default behavior
      MockProvider(ComplexService),
    ],
  });

  beforeEach(() => {
    // Configure a mock instance before the component is created
    MockInstance(
      ComplexService,
      'getData',
      jest.fn().mockReturnValue(of(['test'])),
    );

    // Create the component with Spectator
    spectator = createComponent();
  });

  it('should work with mocked dependencies', () => {
    // Use Spectator for interactions
    spectator.click('button');

    // Use ng-mocks to verify interactions with mocked dependencies
    const service = ngMocks.get(ComplexService);
    expect(service.getData).toHaveBeenCalled();
  });
});

When to Use ng-mocks

ng-mocks is particularly useful when:

  1. You need to mock complex Angular artifacts like components, directives, or modules
  2. You want to customize mock behavior at the instance level
  3. You need to simulate complex user interactions
  4. You're testing parent components that depend on multiple child components

For more details and advanced usage, refer to the official ng-mocks documentation.