Files
ISA-Frontend/docs/guidelines/testing.md
2025-10-14 16:02:18 +00:00

37 KiB
Raw Blame History

Testing Guidelines

Table of Contents

  1. Introduction
  2. Core Testing Principles
  3. Testing Tools
  4. Best Practices
  5. End-to-End (E2E) Testing Attributes (data-what, data-which)
  6. Running Tests (Nx)
  7. References

Introduction

This document outlines the guidelines and best practices for writing unit tests in this Angular project. Consistent and effective testing ensures code quality, maintainability, and reliability.

Key Requirements:

  • Test files must end with .spec.ts.
  • Migration to Vitest: New libraries should use Vitest as the primary test runner. Existing libraries continue to use Jest until migrated.
  • Testing Framework Migration: New tests should use Angular Testing Utilities (TestBed, ComponentFixture, etc.). Existing tests using Spectator remain until migrated.
  • Employ ng-mocks for mocking complex dependencies like child components.

Core Testing Principles

Adhere to these fundamental principles for writing effective unit tests.

Arrange-Act-Assert (AAA) Pattern

Structure your tests using the AAA pattern for clarity and consistency:

  1. Arrange: Set up the test environment. Initialize objects, mock dependencies, and prepare inputs.
  2. Act: Execute the code being tested. Call the function or method under scrutiny.
  3. Assert: Verify the outcome. Check if the actual result matches the expected result.

Isolation: Mocking Dependencies

Unit tests should focus on a single unit of code in isolation.

  • Mock External Dependencies: Services, APIs, or other modules the unit interacts with should be mocked to prevent side effects and external failures from impacting the test. Spectator's mocks array in factory options simplifies this.
  • Mock Child Components: When testing a component, mock its child components using Spectator's overrideComponents to ensure you are only testing the parent component's logic and template, not the children's implementation details.

Error Case Testing

Go beyond the "happy path". Ensure your tests cover:

  • Edge cases
  • Invalid inputs
  • Error scenarios and how the unit handles them

Clarity and Isolation

  • Clear Names: Write descriptive describe and it block names that clearly state what is being tested and the expected outcome.
  • Independent Tests: Ensure tests do not depend on each other. Each test should set up its own state and clean up afterward. Avoid relying on the execution order of tests.

Testing Tools

Jest: The Test Runner

Jest is the testing framework currently used for existing libraries, providing features like test discovery, assertion functions (expect), and mocking capabilities (jest.fn(), jest.spyOn()). Note: New libraries should use Vitest instead of Jest.

Vitest: Modern Testing Framework

Vitest is the modern testing framework being adopted for new libraries. It provides fast execution, native ES modules support, and excellent developer experience.

Vitest Overview

  • Fast Execution: Powered by Vite, offering significantly faster test runs than Jest
  • Native ES Modules: Built-in support for ES modules without complex configuration
  • Jest Compatibility: Compatible with Jest's API, making migration straightforward
  • Hot Module Replacement: Tests can run in watch mode with instant feedback
  • TypeScript Support: First-class TypeScript support out of the box
  • Built-in Coverage: Integrated code coverage reporting with c8

Vitest Configuration

Vitest configuration is typically done in vitest.config.ts or within the Nx project configuration:

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import angular from '@analogjs/vite-plugin-angular';

export default defineConfig({
  plugins: [angular()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: ['src/test-setup.ts'],
    include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
    reporters: ['default'],
    coverage: {
      reporter: ['text', 'json', 'html'],
      exclude: [
        'node_modules/',
        'src/test-setup.ts',
      ],
    },
  },
});

CI/CD Integration: JUnit and Cobertura Reporting

Both Jest and Vitest are configured to generate JUnit XML reports and Cobertura coverage reports for Azure Pipelines integration.

Jest Configuration (Existing Libraries)

Jest projects inherit JUnit and Cobertura configuration from jest.preset.js:

// jest.preset.js (workspace root)
module.exports = {
  ...nxPreset,
  coverageReporters: ['text', 'cobertura'],
  reporters: [
    'default',
    [
      'jest-junit',
      {
        outputDirectory: 'testresults',
        outputName: 'TESTS',
        uniqueOutputName: 'true',
        classNameTemplate: '{classname}',
        titleTemplate: '{title}',
        ancestorSeparator: '  ',
        usePathForSuiteName: true,
      },
    ],
  ],
};

Key Points:

  • JUnit XML files are written to testresults/TESTS-{uuid}.xml
  • Cobertura coverage reports are written to coverage/{projectPath}/cobertura-coverage.xml
  • No additional configuration needed in individual Jest projects
  • Run with coverage: npx nx test <project> --code-coverage
Vitest Configuration (New Libraries)

Vitest projects require explicit JUnit and Cobertura configuration in their vite.config.mts files:

// libs/{domain}/{library}/vite.config.mts
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import angular from '@analogjs/vite-plugin-angular';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';

export default
// @ts-expect-error - Vitest reporter tuple types have complex inference issues, but config works correctly at runtime
defineConfig(() => ({
  root: __dirname,
  cacheDir: '../../../node_modules/.vite/libs/{domain}/{library}',
  plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
  test: {
    watch: false,
    globals: true,
    environment: 'jsdom',
    include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
    setupFiles: ['src/test-setup.ts'],
    reporters: [
      'default',
      ['junit', { outputFile: '../../../testresults/junit-{project-name}.xml' }],
    ],
    coverage: {
      reportsDirectory: '../../../coverage/libs/{domain}/{library}',
      provider: 'v8' as const,
      reporter: ['text', 'cobertura'],
    },
  },
}));

Key Points:

  • JUnit Reporter: Built into Vitest, no additional package needed
  • Output Path: Adjust relative path based on library depth:
    • 3 levels (libs/domain/library): Use ../../../testresults/
    • 4 levels (libs/domain/type/library): Use ../../../../testresults/
  • Coverage Reporter: Add 'cobertura' to the reporter array
  • TypeScript Suppression: Add // @ts-expect-error comment before defineConfig to suppress type inference warnings
  • Run with Coverage: npx nx test <project> --coverage.enabled=true
Azure Pipelines Integration

Both Jest and Vitest reports are consumed by Azure Pipelines:

# azure-pipelines.yml
- task: PublishTestResults@2
  displayName: Publish Test results
  inputs:
    testResultsFiles: '**/TESTS-*.xml'
    searchFolder: $(Build.StagingDirectory)/testresults
    testResultsFormat: JUnit
    mergeTestResults: false
    failTaskOnFailedTests: true

- task: PublishCodeCoverageResults@2
  displayName: Publish code Coverage
  inputs:
    codeCoverageTool: Cobertura
    summaryFileLocation: $(Build.StagingDirectory)/coverage/**/cobertura-coverage.xml

Verification:

  • JUnit XML files: testresults/junit-*.xml or testresults/TESTS-*.xml
  • Cobertura XML files: coverage/libs/{path}/cobertura-coverage.xml
New Library Checklist

When creating a new Vitest-based library, ensure:

  1. reporters array includes both 'default' and JUnit configuration
  2. JUnit outputFile uses correct relative path depth
  3. Coverage reporter array includes 'cobertura'
  4. Add // @ts-expect-error comment before defineConfig() if TypeScript errors appear
  5. Verify report generation: Run npx nx test <project> --coverage.enabled=true --skip-cache
  6. Check files exist:
    • testresults/junit-{project-name}.xml
    • coverage/libs/{path}/cobertura-coverage.xml

Core Testing Features

Vitest provides similar APIs to Jest with enhanced performance:

// Basic test structure
import { describe, it, expect, beforeEach, vi } from 'vitest';

describe('MyService', () => {
  beforeEach(() => {
    // Setup logic
  });

  it('should perform expected behavior', () => {
    // Test implementation
    expect(result).toBe(expected);
  });
});

Mocking in Vitest

Vitest provides powerful mocking capabilities:

import { vi } from 'vitest';

// Mock functions
const mockFn = vi.fn();
const mockFnWithReturn = vi.fn().mockReturnValue('mocked value');

// Mock modules
vi.mock('./my-module', () => ({
  MyClass: vi.fn(),
  myFunction: vi.fn(),
}));

// Spy on existing functions
const spy = vi.spyOn(object, 'method');

Example Test Structures with Vitest

Basic Service Test with Vitest:

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { MyService } from './my-service.service';
import { HttpClient } from '@angular/common/http';
import { of } from 'rxjs';

describe('MyService', () => {
  let service: MyService;
  let httpClientSpy: any;

  beforeEach(() => {
    httpClientSpy = {
      get: vi.fn(),
      post: vi.fn(),
    };

    TestBed.configureTestingModule({
      providers: [
        MyService,
        { provide: HttpClient, useValue: httpClientSpy },
      ],
    });

    service = TestBed.inject(MyService);
  });

  it('should fetch data successfully', async () => {
    // Arrange
    const mockData = { id: 1, name: 'Test' };
    httpClientSpy.get.mockReturnValue(of(mockData));

    // Act
    const result = await service.getData().toPromise();

    // Assert
    expect(result).toEqual(mockData);
    expect(httpClientSpy.get).toHaveBeenCalledWith('/api/data');
  });
});

Angular Testing Utilities with Vitest:

For new tests, use Angular's official testing utilities with Vitest:

import { describe, it, expect, beforeEach } from 'vitest';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MyComponent } from './my-component.component';

describe('MyComponent', () => {
  let component: MyComponent;
  let fixture: ComponentFixture<MyComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [MyComponent], // For standalone components
    }).compileComponents();

    fixture = TestBed.createComponent(MyComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

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

Angular Testing Utilities: Official Framework

Angular Testing Utilities are the official testing tools provided by the Angular framework. New tests should use these utilities instead of Spectator.

Angular Testing Utilities Overview

  • Official Support: Maintained by the Angular team, ensuring compatibility with Angular updates
  • Comprehensive API: Full access to Angular's testing capabilities without abstractions
  • TestBed Integration: Direct integration with Angular's testing module for dependency injection
  • Type Safety: Full TypeScript support with proper type inference
  • Standard Approach: Aligns with Angular documentation and community best practices

TestBed Configuration

TestBed is the primary API for configuring and creating Angular testing modules:

import { TestBed } from '@angular/core/testing';
import { Component } from '@angular/core';

@Component({
  selector: 'app-test',
  template: '<h1>{{ title }}</h1>',
  standalone: true,
})
class TestComponent {
  title = 'Test Title';
}

describe('TestComponent', () => {
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [TestComponent], // For standalone components
      // providers: [], // Add service providers
      // declarations: [], // For non-standalone components (legacy)
    }).compileComponents();
  });
});

Angular Component Testing

Testing components with Angular Testing Utilities:

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { DebugElement } from '@angular/core';
import { MyComponent } from './my-component.component';

describe('MyComponent', () => {
  let component: MyComponent;
  let fixture: ComponentFixture<MyComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [MyComponent],
    }).compileComponents();

    fixture = TestBed.createComponent(MyComponent);
    component = fixture.componentInstance;
  });

  it('should display title', () => {
    // Arrange
    const expectedTitle = 'Test Title';
    component.title = expectedTitle;

    // Act
    fixture.detectChanges();

    // Assert
    const titleElement: HTMLElement = fixture.nativeElement.querySelector('h1');
    expect(titleElement.textContent).toContain(expectedTitle);
  });

  it('should emit event on button click', () => {
    // Arrange
    spyOn(component.buttonClicked, 'emit');
    const button: HTMLButtonElement = fixture.nativeElement.querySelector('button');

    // Act
    button.click();

    // Assert
    expect(component.buttonClicked.emit).toHaveBeenCalled();
  });

  it('should find element by directive', () => {
    // Using DebugElement for more advanced querying
    const debugElement: DebugElement = fixture.debugElement;
    const buttonDebugElement = debugElement.query(By.css('button'));
    
    expect(buttonDebugElement).toBeTruthy();
  });
});

Angular Service Testing

Testing services with dependency injection:

import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { UserService } from './user.service';
import { User } from './user.model';

describe('UserService', () => {
  let service: UserService;
  let httpMock: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [UserService],
    });

    service = TestBed.inject(UserService);
    httpMock = TestBed.inject(HttpTestingController);
  });

  afterEach(() => {
    httpMock.verify(); // Verify no outstanding HTTP requests
  });

  it('should fetch users', () => {
    // Arrange
    const mockUsers: User[] = [
      { id: 1, name: 'User 1' },
      { id: 2, name: 'User 2' },
    ];

    // Act
    service.getUsers().subscribe(users => {
      // Assert
      expect(users).toEqual(mockUsers);
    });

    // Assert HTTP request
    const req = httpMock.expectOne('/api/users');
    expect(req.request.method).toBe('GET');
    req.flush(mockUsers);
  });
});

Angular HTTP Testing

Using HttpClientTestingModule for HTTP testing:

import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { ApiService } from './api.service';

describe('ApiService', () => {
  let service: ApiService;
  let httpMock: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [ApiService],
    });

    service = TestBed.inject(ApiService);
    httpMock = TestBed.inject(HttpTestingController);
  });

  it('should handle HTTP errors', () => {
    const errorMessage = 'Server error';

    service.getData().subscribe({
      next: () => fail('Should have failed'),
      error: (error) => {
        expect(error.status).toBe(500);
        expect(error.statusText).toBe('Server Error');
      },
    });

    const req = httpMock.expectOne('/api/data');
    req.flush(errorMessage, { status: 500, statusText: 'Server Error' });
  });
});

Mocking with Angular

Angular provides several ways to mock dependencies:

import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';

// Mock service implementation
const mockUserService = {
  getUsers: jasmine.createSpy('getUsers').and.returnValue(of([])),
  createUser: jasmine.createSpy('createUser').and.returnValue(of({})),
};

describe('ComponentWithDependency', () => {
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [ComponentWithDependency],
      providers: [
        { provide: UserService, useValue: mockUserService },
      ],
    }).compileComponents();
  });

  it('should call user service', () => {
    const fixture = TestBed.createComponent(ComponentWithDependency);
    const component = fixture.componentInstance;

    component.loadUsers();

    expect(mockUserService.getUsers).toHaveBeenCalled();
  });
});

Spectator: Simplifying Angular Tests (Legacy)

Spectator significantly reduces boilerplate and simplifies testing Angular components, directives, and services. Note: This is legacy for existing tests. New tests should use Angular Testing Utilities.

Spectator Overview

  • Less Boilerplate: Streamlines TestBed configuration.
  • Easy DOM Querying: Provides intuitive methods (query, queryAll, byText, etc.) to find elements.
  • Clean Event API: Simplifies triggering user interactions (click, typeInElement, keyboard).
  • Custom Matchers: Offers Jest matchers (toExist, toHaveText, toBeDisabled, etc.) for more readable assertions.

Core Factory Methods

Spectator provides factory functions to set up the testing environment:

  • createComponentFactory: For testing components in isolation.
  • createHostFactory: For testing components within a host template (useful for inputs/outputs).
  • createDirectiveFactory: For testing directives.
  • createServiceFactory: For testing services.
  • createHttpFactory: For testing services that interact with HttpClient.

Example Usage (Component):

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

const createComponent = createComponentFactory({
  component: MyComponent,
  mocks: [SomeService], // Automatically mocks SomeService
  detectChanges: false, // Control initial change detection
});

let spectator: Spectator<MyComponent>;
beforeEach(() => spectator = createComponent());

Querying Elements

Use Spectator's query methods to find elements for interaction or assertion:

// CSS selectors
const button = spectator.query('button.submit');
const inputs = spectator.queryAll('input');
// By component/directive type
const child = spectator.query(ChildComponent);
// By text, label, placeholder, etc.
const submitBtn = spectator.query(byText('Submit'));
const usernameInput = spectator.query(byLabel('Username'));

Working with Inputs/Outputs

Easily set inputs and subscribe to outputs:

// Set single input
spectator.setInput('user', { name: 'Test' });
// Set multiple inputs
spectator.setInput({ user: { name: 'Test' }, isAdmin: true });
// Host component input
// spectator.setHostInput('propName', value); // If using createHostFactory

// Subscribe to output
const outputSpy = jest.fn();
spectator.output('userLoggedIn').subscribe(outputSpy);
spectator.click('button.login');
expect(outputSpy).toHaveBeenCalled();

Event Triggering

Simulate user interactions:

spectator.click('.login-button');
spectator.typeInElement('test user', 'input[name="username"]');
spectator.keyboard.pressEnter('input[name="password"]');
spectator.selectOption(spectator.query('select'), 'Option 2');

Custom Matchers

Improve assertion readability:

expect('.error-message').not.toExist();
expect('h1.title').toHaveText('Welcome');
expect('input.email').toHaveValue('test@example.com');
expect('button.submit').toBeDisabled();
expect('.parent').toHaveDescendant('.child');

ng-mocks: Advanced Mocking

ng-mocks excels at mocking complex Angular artifacts like Components, Directives, Pipes, and Modules, further enhancing test isolation. Note: While ng-mocks offers powerful features like MockComponent and MockDirective, in a standalone component architecture, the primary way to replace dependencies (including child components or directives) is often through Spectator's overrideComponents feature, as shown in the examples below. ng-mocks might still be useful for features like MockProvider or MockInstance.

ng-mocks Overview

  • Deep Mocking: Easily mocks entire modules or specific declarations/providers.
  • Boilerplate Reduction: Simplifies complex TestBed setups.
  • Helper Utilities: Provides functions for interactions (ngMocks.change, ngMocks.click) and querying (ngMocks.find).

Key APIs

  • MockBuilder: Fluent API to configure TestBed, specifying what to keep real and what to mock.
  • MockRender: Enhanced TestBed.createComponent that respects lifecycle hooks and simplifies input/output binding.
  • MockComponent, MockDirective, MockPipe, MockProvider: Functions to create shallow mocks of specific artifacts. (Note: For standalone, prefer overrideComponents for components/directives).
  • MockInstance: Allows customizing mock behavior before initialization (e.g., setting up spies).

When to Use ng-mocks

  • Mocking specific services or pipes using MockProvider when more control is needed than Spectator's mocks array offers.
  • When needing fine-grained control over mock behavior using MockInstance.
  • Testing components that rely heavily on content projection (ng-content).
  • Caution: Avoid MockComponent and MockDirective for standalone components/directives; use Spectator's overrideComponents instead.

Integration with Angular Testing Utilities

ng-mocks integrates well with Angular Testing Utilities. Use Angular's TestBed for the primary test setup and ng-mocks for advanced mocking scenarios:

import { TestBed } from '@angular/core/testing';
import { MockBuilder, MockRender, MockProvider } from 'ng-mocks';
import { ParentComponent } from './parent.component';
import { ChildService } from './child.service';

describe('ParentComponent with ng-mocks', () => {
  beforeEach(() => {
    return MockBuilder(ParentComponent)
      .provide(MockProvider(ChildService, {
        getData: () => of(['mocked data']),
      }));
  });

  it('should render with mocked service', () => {
    const fixture = MockRender(ParentComponent);
    const component = fixture.point.componentInstance;
    
    expect(component).toBeTruthy();
  });
});

Integration with Spectator (Legacy)

Spectator and ng-mocks can work together for existing tests. Use Spectator for the primary test setup and interaction/assertion. Use Spectator's overrideComponents to replace standalone child components/directives. Use ng-mocks' MockProvider or MockInstance when needed for services or advanced mocking scenarios.

// Example using Spectator's overrideComponents for a standalone child
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { ParentComponent } from './parent.component';
import { ChildComponent } from './child.component';
import { MockChildComponent } from './mock-child.component'; // A standalone mock component
import { ComplexService } from './complex.service';
import { MockProvider } from 'ng-mocks'; // ng-mocks can still be used for providers

const createComponent = createComponentFactory({
  component: ParentComponent, // Assume ParentComponent imports ChildComponent
  overrideComponents: [
    [
      ParentComponent, // Target the component whose imports need overriding
      {
        remove: { imports: [ChildComponent] }, // Remove the real standalone component
        add: { imports: [MockChildComponent] }, // Add the mock standalone component
      },
    ],
  ],
  providers: [
    MockProvider(ComplexService, { // Mock service using ng-mocks' MockProvider
      getData: () => of(['mocked data']),
    }),
  ],
});

// ... rest of the test using Spectator API ...

Best Practices

General

  • Focus: Each test (it block) should ideally test one specific behavior or scenario.
  • Readability: Use clear variable names and structure tests logically (AAA).
  • Avoid Logic: Do not put complex logic within tests. Keep them simple and direct.
  • No Implementation Details: Test the public API or observable behavior, not private methods or internal state.

Component Testing

  • Use createComponentFactory for most components. Use createHostFactory only when testing interaction via template bindings (inputs/outputs) is necessary.
  • Test inputs change behavior as expected.
  • Test outputs are emitted correctly upon interaction.
  • Test conditional rendering (@if, @switch) and loops (@for).
  • Verify important DOM elements are rendered correctly based on state.
  • Mock Standalone Child Components/Directives using Spectator's overrideComponents feature to isolate the component under test.

Service Testing

  • Use createServiceFactory.
  • Mock dependencies using the mocks array.
  • Test public methods, ensuring they return expected values or have the correct side effects (like calling mocked dependencies).
  • For services using HttpClient, use createHttpFactory and SpectatorHttp for easy request expectation (spectator.expectOne).

Directive Testing

  • Use createDirectiveFactory.
  • Test how the directive affects the host element or other elements based on inputs or events.
  • If a directive has standalone dependencies, use overrideDirectives (similar to overrideComponents) in the factory options.

Mocking

  • Prefer Spectator's mocks array for simple service mocking.
  • Use spectator.inject(ServiceToMock) to get the mocked instance and configure its methods (e.g., mockService.method.mockReturnValue(...)).
  • Use Spectator's overrideComponents or overrideDirectives to replace standalone component/directive dependencies with mocks.
  • Use MockProvider from ng-mocks if you need more control over service mocking than Spectator's mocks array provides, or for mocking tokens/values.

Async Operations

  • Use async/await with Promises.
  • For RxJS Observables, subscribe within the test or use helpers like waitForAsync or Jest's timer mocks if needed, though often direct subscription is sufficient. Spectator handles basic async operations well.

Performance

  • Mock heavy dependencies (e.g., HTTP calls, complex computations).
  • Keep test setup (beforeEach) lean. Use beforeAll only for setup that is truly read-only and shared across all tests in a describe block.

Debugging

  • Use spectator.debug() to print the current component's DOM structure to the console.
  • Use standard console.log statements temporarily.
  • Utilize your IDE's debugger with breakpoints.

End-to-End (E2E) Testing Attributes (data-what, data-which)

In end-to-end (E2E) testing of web applications, data-what and data-which are custom HTML data attributes used to enhance testability. They help identify elements or components in the DOM for testing purposes, making it easier for testing frameworks (like Selenium, Cypress, or WebdriverIO) to locate and interact with specific UI elements.

Purpose of data-what and data-which

  • Identification: These attributes provide a stable, test-specific way to tag elements, avoiding reliance on brittle selectors like CSS classes, IDs, or XPath, which may change due to styling or structural updates.
  • Semantics: They can describe the purpose (data-what) or specific instance/context (data-which) of an element, improving clarity in test scripts.
  • Maintainability: By using custom attributes, developers can decouple test logic from presentation, reducing the risk of tests breaking when the UI is refactored.

Example Usage

<button data-what="submit-button" data-which="form-login">Login</button>
  • data-what="submit-button": Indicates the element's role or type (e.g., a submit button).
  • data-which="form-login": Specifies the context or instance (e.g., the submit button for the login form).

In a test script (e.g., using WebdriverIO):

// Find the specific button
const loginButton = await $('[data-what="submit-button"][data-which="form-login"]');
await loginButton.click();

This targets the exact button, even if its class or ID changes.

Why Use Them?

  • Robustness: Unlike classes or IDs, which are often tied to styling or JavaScript, data-what and data-which are dedicated to testing, reducing the chance of accidental changes breaking tests.
  • Clarity: They make test scripts self-documenting by describing the element's purpose and context.
  • Scalability: In complex applications with multiple similar elements (e.g., multiple "Save" buttons), data-which helps differentiate them.

Best Practices

  • Use clear, consistent naming conventions (e.g., data-what="button", data-which="checkout-page").

  • Avoid overusing or duplicating attributes unnecessarily.

  • Use additional data-* attributes for dynamic identifiers: When you need to associate specific data like an item ID or name with an element for testing, add separate data-* attributes instead of encoding them into data-which. This keeps selectors clean and allows for more precise targeting.

    Example:

    <oms-feature-return-summary-item
      [returnProcess]="item"
      data-what="list-item"
      data-which="return-process-item"
      [attr.data-item-id]="item.id"
      [attr.data-item-name]="item.name"
    ></oms-feature-return-summary-item>
    

    E2E Test Query (WebdriverIO):

    // Find a specific item by ID
    const specificItem = await $('[data-what="list-item"][data-which="return-process-item"][data-item-id="12345"]');
    await specificItem.waitForExist();
    
    // Find all return process items
    const allItems = await $$('[data-what="list-item"][data-which="return-process-item"]');
    console.log(`Found ${allItems.length} items.`);
    
  • Ensure developers and testers agree on the attribute schema to maintain consistency.

  • Verify in Unit Tests: Ensure these data-* attributes are correctly applied in your component unit tests. Use Spectator's querying capabilities to check for their presence and dynamic values.

    Unit Test Snippet (Spectator):

    it('should apply correct data attributes', () => {
      // Arrange
      const testItem = { id: 'abc', name: 'Test Item' };
      spectator.setInput('returnProcess', testItem);
      spectator.detectChanges();
    
      // Act
      const element = spectator.queryHost('[data-what="list-item"]'); // Or query the specific element inside the component
    
      // Assert
      expect(element).toHaveAttribute('data-what', 'list-item');
      expect(element).toHaveAttribute('data-which', 'return-process-item');
      expect(element).toHaveAttribute('data-item-id', 'abc');
      expect(element).toHaveAttribute('data-item-name', 'Test Item');
    });
    

While data-what and data-which are not standardized HTML attributes, their use as custom data-* attributes aligns with HTML5 specifications, making them valid and widely supported. If you encounter them in a specific framework or codebase, check the project's testing documentation for their exact conventions.


Running Tests (Nx)

Use the Nx CLI to run tests efficiently within the monorepo. The commands differ depending on whether the project uses Jest or Vitest.

Run Tests for a Specific Project

For Jest-based projects (existing libraries):

npx nx test <project-name>
# Example: npx nx test core-logging
# Example: npx nx test isa-app

For Vitest-based projects (new libraries):

npx nx test <project-name>
# Nx automatically detects and uses the appropriate test runner
# Example: npx nx test new-feature-lib

Run Tests in Watch Mode

Jest projects:

npx nx test <project-name> --watch

Vitest projects:

npx nx test <project-name> --watch
# Or use Vitest's native watch mode
npx vitest --watch

Run Tests with Coverage Report

Jest projects:

npx nx test <project-name> --code-coverage

Vitest projects:

npx nx test <project-name> --coverage
# Or use Vitest directly
npx vitest --coverage

(Coverage reports are typically generated in the coverage/ directory)

Run a Specific Test File

Jest projects:

npx nx test <project-name> --test-file=libs/core/utils/src/lib/my-util.spec.ts

Vitest projects:

npx nx test <project-name> --test-file=libs/new-feature/src/lib/my-util.spec.ts
# Or use Vitest directly
npx vitest src/lib/my-util.spec.ts

Run Tests with UI (Vitest only)

Vitest provides an interactive UI for test exploration:

npx vitest --ui

Run Affected Tests

Optimize CI/CD by running tests only for projects affected by your code changes:

# Run tests for projects affected by changes compared to main branch
npx nx affected:test --base=main --head=HEAD

# Or run tests for uncommitted changes
npx nx affected:test

Migration Notes

Test Runner Migration

  • Existing libraries continue using Jest until explicitly migrated
  • New libraries should be configured with Vitest from the start
  • Both test runners can coexist in the same monorepo
  • Nx handles the appropriate test runner selection automatically based on project configuration

Testing Framework Migration

  • New tests should use Angular Testing Utilities (TestBed, ComponentFixture, etc.)
  • Existing Spectator tests remain until explicitly migrated
  • Both testing approaches can coexist in the same codebase
  • Angular Testing Utilities provide better long-term maintainability and align with Angular's official documentation

Benefits of the New Approach

  • Official Support: Angular Testing Utilities are maintained by the Angular team
  • Better Integration: Direct integration with Angular's dependency injection and testing modules
  • Improved Performance: Vitest provides significantly faster test execution than Jest
  • Future-Proof: Aligns with Angular's testing direction and community best practices

References