diff --git a/projects/ppwcode/ng-unit-testing/src/lib/data-access/mock-builder.spec.ts b/projects/ppwcode/ng-unit-testing/src/lib/data-access/mock-builder.spec.ts new file mode 100644 index 00000000..aa6d57d6 --- /dev/null +++ b/projects/ppwcode/ng-unit-testing/src/lib/data-access/mock-builder.spec.ts @@ -0,0 +1,62 @@ +import { DateTime } from 'luxon' +import { describe, expect, it } from 'vitest' +import { MockBuilder } from './mock-builder' + +interface ExampleMock { + nested: { + value: string + } + items: Array<{ + id: string + }> + date: DateTime +} + +class ExampleMockBuilder extends MockBuilder { + private constructor(value: ExampleMock) { + super(value) + } + + static default(): ExampleMockBuilder { + return new ExampleMockBuilder({ + nested: { value: 'default' }, + items: [{ id: 'default-item' }], + date: DateTime.fromISO('2026-08-03T00:00:00.000Z') + }) + } + + build(): ExampleMock { + return this.buildValue() + } + + withNested(nested: ExampleMock['nested']): this { + return this.withValue('nested', nested) + } +} + +describe('MockBuilder', () => { + it('creates independent values for each build', () => { + const builder = ExampleMockBuilder.default() + const first = builder.build() + const second = builder.build() + + first.nested.value = 'changed' + first.items[0].id = 'changed-item' + + expect(second).toEqual({ + nested: { value: 'default' }, + items: [{ id: 'default-item' }], + date: DateTime.fromISO('2026-08-03T00:00:00.000Z') + }) + expect(second.date).not.toBe(first.date) + }) + + it('does not retain references supplied to a fluent setter', () => { + const nested = { value: 'provided' } + const built = ExampleMockBuilder.default().withNested(nested).build() + + nested.value = 'changed' + + expect(built.nested).toEqual({ value: 'provided' }) + }) +}) diff --git a/projects/ppwcode/ng-unit-testing/src/lib/data-access/mock-builder.ts b/projects/ppwcode/ng-unit-testing/src/lib/data-access/mock-builder.ts new file mode 100644 index 00000000..1ea68466 --- /dev/null +++ b/projects/ppwcode/ng-unit-testing/src/lib/data-access/mock-builder.ts @@ -0,0 +1,82 @@ +import { DateTime } from 'luxon' + +/** + * Returns whether a value is a record that can be copied property by property. + */ +const isPlainObject = (value: object): boolean => { + const prototype = Object.getPrototypeOf(value) + + return prototype === Object.prototype || prototype === null +} + +/** + * Creates an independent copy of the mutable structures that mock builders support. + * + * Immutable primitive values are returned unchanged. Luxon DateTime values receive a + * new instance as well, so separate builds never share object references. + */ +const cloneMockValue = (value: T): T => { + if (Array.isArray(value)) { + return value.map(cloneMockValue) as T + } + + if (value instanceof Date) { + return new Date(value.getTime()) as T + } + + if (DateTime.isDateTime(value)) { + return value.reconfigure({}) as T + } + + if (typeof value === 'object' && value !== null && isPlainObject(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, nestedValue]) => [key, cloneMockValue(nestedValue)]) + ) as T + } + + return value +} + +/** + * Base class for mutable fluent mock builders. + * + * Subclasses initialize it through a static default factory, update its private draft + * with fluent setters, and expose {@link buildValue} through their public `build` method. + * Every supplied value and build result is cloned to keep mock object graphs independent. + */ +export abstract class MockBuilder { + #value: T + + /** + * Creates a builder with an independent copy of its default draft. + */ + protected constructor(value: T) { + this.#value = cloneMockValue(value) + } + + /** + * Provides subclasses with the current private draft for composing convenience setters. + */ + protected get value(): T { + return this.#value + } + + /** + * Creates a fresh result from the current draft for a subclass public `build` method. + */ + protected buildValue(): T { + return cloneMockValue(this.#value) + } + + /** + * Replaces one draft property with an independent copy and keeps fluent chaining on this builder. + */ + protected withValue(key: TKey, value: T[TKey]): this { + this.#value = { + ...this.#value, + [key]: cloneMockValue(value) + } + + return this + } +} diff --git a/projects/ppwcode/ng-unit-testing/src/lib/data-access/test-dto-to-entity-mapping.ts b/projects/ppwcode/ng-unit-testing/src/lib/data-access/test-dto-to-entity-mapping.ts new file mode 100644 index 00000000..caaffe9d --- /dev/null +++ b/projects/ppwcode/ng-unit-testing/src/lib/data-access/test-dto-to-entity-mapping.ts @@ -0,0 +1,27 @@ +export interface DtoToEntityMappingTest { + // Lambda that will create the dto. + createDto: () => TDto + // Mapping function that will map the dto to entity. + map: (dto: TDto) => TEntity + // The expected entity value. + expected: TEntity +} + +/** + * Defines the standardized happy-path test for a complete DTO-to-entity mapping. + * + * This helper registers an `it` test. Keep exceptional mapping scenarios, such as null handling or default values, + * in explicit, descriptively named tests in the owning spec. + */ +export const testDtoToEntityMapping = ({ + createDto, + map, + expected +}: DtoToEntityMappingTest): void => { + it('should map from dto to entity', () => { + const dto = createDto() + const entity = map(dto) + + expect(entity).toEqual(expected) + }) +} diff --git a/projects/ppwcode/ng-unit-testing/src/lib/data-access/test-entity-to-dto-mapping.ts b/projects/ppwcode/ng-unit-testing/src/lib/data-access/test-entity-to-dto-mapping.ts new file mode 100644 index 00000000..9ec0ec47 --- /dev/null +++ b/projects/ppwcode/ng-unit-testing/src/lib/data-access/test-entity-to-dto-mapping.ts @@ -0,0 +1,27 @@ +export interface EntityToDtoMappingTest { + // Lambda that will create the entity. + createEntity: () => TEntity + // Mapping function that will map the entity to dto. + map: (entity: TEntity) => TDto + // The expected dto value. + expected: TDto +} + +/** + * Defines the standardized happy-path test for a complete entity-to-DTO mapping. + * + * This helper registers an `it` test. Keep exceptional mapping scenarios, such as null handling or derived values, + * in explicit, descriptively named tests in the owning spec. + */ +export const testEntityToDtoMapping = ({ + createEntity, + map, + expected +}: EntityToDtoMappingTest): void => { + it('should map from entity to dto', () => { + const entity = createEntity() + const dto = map(entity) + + expect(dto).toEqual(expected) + }) +} diff --git a/projects/ppwcode/ng-unit-testing/src/lib/forms/errors/expect-field-tree-has-error.ts b/projects/ppwcode/ng-unit-testing/src/lib/forms/errors/expect-field-tree-has-error.ts new file mode 100644 index 00000000..04d754a7 --- /dev/null +++ b/projects/ppwcode/ng-unit-testing/src/lib/forms/errors/expect-field-tree-has-error.ts @@ -0,0 +1,13 @@ +import { FieldTree } from '@angular/forms/signals' +import { getFieldTreeErrorKinds } from './get-field-tree-error-kinds' + +/** + * Asserts that a signal-form field contains an error with the expected kind. + */ +export const expectFieldTreeHasError = ( + field: FieldTree, + expectedErrorKind: string +): void => { + const errorKinds = getFieldTreeErrorKinds(field) + expect(errorKinds).toContain(expectedErrorKind) +} diff --git a/projects/ppwcode/ng-unit-testing/src/lib/forms/errors/expect-field-tree-has-errors.ts b/projects/ppwcode/ng-unit-testing/src/lib/forms/errors/expect-field-tree-has-errors.ts new file mode 100644 index 00000000..a60f1079 --- /dev/null +++ b/projects/ppwcode/ng-unit-testing/src/lib/forms/errors/expect-field-tree-has-errors.ts @@ -0,0 +1,13 @@ +import { FieldTree } from '@angular/forms/signals' +import { getFieldTreeErrorKinds } from './get-field-tree-error-kinds' + +/** + * Asserts the exact ordered list of error kinds on a signal-form field. + */ +export const expectFieldTreeHasErrors = ( + field: FieldTree, + expectedErrorKinds: Array +): void => { + const errorKinds = getFieldTreeErrorKinds(field) + expect(errorKinds).toEqual(expectedErrorKinds) +} diff --git a/projects/ppwcode/ng-unit-testing/src/lib/forms/errors/expect-field-tree-not-has-error.ts b/projects/ppwcode/ng-unit-testing/src/lib/forms/errors/expect-field-tree-not-has-error.ts new file mode 100644 index 00000000..cc672f8c --- /dev/null +++ b/projects/ppwcode/ng-unit-testing/src/lib/forms/errors/expect-field-tree-not-has-error.ts @@ -0,0 +1,13 @@ +import { FieldTree } from '@angular/forms/signals' +import { getFieldTreeErrorKinds } from './get-field-tree-error-kinds' + +/** + * Asserts that a signal-form field does not contain an error with the given kind. + */ +export const expectFieldTreeNotHasError = ( + field: FieldTree, + expectedErrorKind: string +): void => { + const errorKinds = getFieldTreeErrorKinds(field) + expect(errorKinds).not.toContain(expectedErrorKind) +} diff --git a/projects/ppwcode/ng-unit-testing/src/lib/forms/errors/get-field-tree-error-kinds.ts b/projects/ppwcode/ng-unit-testing/src/lib/forms/errors/get-field-tree-error-kinds.ts new file mode 100644 index 00000000..2922c5a0 --- /dev/null +++ b/projects/ppwcode/ng-unit-testing/src/lib/forms/errors/get-field-tree-error-kinds.ts @@ -0,0 +1,13 @@ +import { FieldTree } from '@angular/forms/signals' + +/** + * Returns the validation error kinds currently present on a signal-form field. + * + * Prefer the focused error assertion helpers when the test only checks presence, absence, or an exact list. + */ +export const getFieldTreeErrorKinds = ( + field: FieldTree +): Array => + field() + .errors() + .map((error) => error.kind) diff --git a/projects/ppwcode/ng-unit-testing/src/lib/forms/expect-field-tree-invalid.ts b/projects/ppwcode/ng-unit-testing/src/lib/forms/expect-field-tree-invalid.ts new file mode 100644 index 00000000..23797727 --- /dev/null +++ b/projects/ppwcode/ng-unit-testing/src/lib/forms/expect-field-tree-invalid.ts @@ -0,0 +1,10 @@ +import { FieldTree } from '@angular/forms/signals' + +/** + * Asserts that a signal-form field or form tree is invalid. + */ +export const expectFieldTreeInvalid = ( + field: FieldTree +): void => { + expect(field().valid()).toBe(false) +} diff --git a/projects/ppwcode/ng-unit-testing/src/lib/forms/expect-field-tree-not-required.ts b/projects/ppwcode/ng-unit-testing/src/lib/forms/expect-field-tree-not-required.ts new file mode 100644 index 00000000..4514f7e6 --- /dev/null +++ b/projects/ppwcode/ng-unit-testing/src/lib/forms/expect-field-tree-not-required.ts @@ -0,0 +1,10 @@ +import { FieldTree } from '@angular/forms/signals' + +/** + * Asserts that a signal-form field is not marked as required. + */ +export const expectFieldTreeNotRequired = ( + field: FieldTree +): void => { + expect(field().required()).toBe(false) +} diff --git a/projects/ppwcode/ng-unit-testing/src/lib/forms/expect-field-tree-required.ts b/projects/ppwcode/ng-unit-testing/src/lib/forms/expect-field-tree-required.ts new file mode 100644 index 00000000..41bac9f1 --- /dev/null +++ b/projects/ppwcode/ng-unit-testing/src/lib/forms/expect-field-tree-required.ts @@ -0,0 +1,10 @@ +import { FieldTree } from '@angular/forms/signals' + +/** + * Asserts that a signal-form field is marked as required. + */ +export const expectFieldTreeRequired = ( + field: FieldTree +): void => { + expect(field().required()).toBe(true) +} diff --git a/projects/ppwcode/ng-unit-testing/src/lib/forms/expect-field-tree-valid.ts b/projects/ppwcode/ng-unit-testing/src/lib/forms/expect-field-tree-valid.ts new file mode 100644 index 00000000..896dc16f --- /dev/null +++ b/projects/ppwcode/ng-unit-testing/src/lib/forms/expect-field-tree-valid.ts @@ -0,0 +1,10 @@ +import { FieldTree } from '@angular/forms/signals' + +/** + * Asserts that a signal-form field or form tree is valid. + */ +export const expectFieldTreeValid = ( + field: FieldTree +): void => { + expect(field().valid()).toBe(true) +} diff --git a/projects/ppwcode/ng-unit-testing/src/lib/forms/initialise-test-form.ts b/projects/ppwcode/ng-unit-testing/src/lib/forms/initialise-test-form.ts new file mode 100644 index 00000000..953a6510 --- /dev/null +++ b/projects/ppwcode/ng-unit-testing/src/lib/forms/initialise-test-form.ts @@ -0,0 +1,17 @@ +import { TestBed } from '@angular/core/testing' +import { FieldTree } from '@angular/forms/signals' + +/** + * Creates a signal form inside Angular's TestBed injection context. + * + * Pass a parameterless form factory directly, or pass a single-parameter form factory together with its argument. + * Wrap the factory in a parameterless callback only when it needs multiple arguments or other dependencies. + * + * @param formCreator Form factory to invoke in the injection context. + * @param args Optional single argument forwarded to the form factory. + * @returns The initialized signal form tree. + */ +export const initialiseTestForm = ( + formCreator: (...args: TArguments) => FieldTree, + ...args: TArguments +): FieldTree => TestBed.runInInjectionContext(() => formCreator(...args)) diff --git a/projects/ppwcode/ng-unit-testing/src/lib/outputs/expect-output-event-emits-sequence.ts b/projects/ppwcode/ng-unit-testing/src/lib/outputs/expect-output-event-emits-sequence.ts new file mode 100644 index 00000000..b8dc0bf5 --- /dev/null +++ b/projects/ppwcode/ng-unit-testing/src/lib/outputs/expect-output-event-emits-sequence.ts @@ -0,0 +1,34 @@ +import { OutputEmitterRef } from '@angular/core' + +interface ExpectOutputEventEmitsSequenceOptions = void> { + outputEvent: OutputEmitterRef + when: () => TWhen + expectedValues: Array +} + +/** + * Asserts that invoking `when` makes an Angular output emit the complete expected value sequence. + */ +export function expectOutputEventEmitsSequence( + options: ExpectOutputEventEmitsSequenceOptions> +): Promise +export function expectOutputEventEmitsSequence(options: ExpectOutputEventEmitsSequenceOptions): void +export function expectOutputEventEmitsSequence( + options: ExpectOutputEventEmitsSequenceOptions> +): void | Promise { + const { outputEvent, when, expectedValues } = options + const emittedValues: Array = [] + + outputEvent.subscribe((value) => emittedValues.push(value)) + + const verify = () => { + expect(emittedValues).toEqual(expectedValues) + } + + const possiblePromise = when() + if (possiblePromise) { + return possiblePromise.then(verify) + } + + verify() +} diff --git a/projects/ppwcode/ng-unit-testing/src/lib/outputs/expect-output-event-emits.ts b/projects/ppwcode/ng-unit-testing/src/lib/outputs/expect-output-event-emits.ts new file mode 100644 index 00000000..f828b7e0 --- /dev/null +++ b/projects/ppwcode/ng-unit-testing/src/lib/outputs/expect-output-event-emits.ts @@ -0,0 +1,37 @@ +import { OutputEmitterRef } from '@angular/core' + +interface ExpectOutputEventEmitsOptions = void> { + outputEvent: OutputEmitterRef + when: () => TWhen + expectedValue: TValue +} + +/** + * Asserts that invoking `when` makes an Angular output emit `expectedValue` exactly once. + */ +export function expectOutputEventEmits( + options: ExpectOutputEventEmitsOptions> +): Promise +export function expectOutputEventEmits(options: ExpectOutputEventEmitsOptions): void +export function expectOutputEventEmits( + options: ExpectOutputEventEmitsOptions> +): void | Promise { + const { outputEvent, when, expectedValue } = options + + const verify = () => { + expect(eventHandler).toHaveBeenCalledOnce() + expect(eventHandler).toHaveBeenCalledWith(expectedValue) + } + const eventHandler = vi.fn() + + outputEvent.subscribe(eventHandler) + + // It is possible that the `when` lambda is an async function or a returned Promise. If that's the case, we need + // to wait with the verifications until that Promise resolves. Otherwise, we can immediately verify in a sync way. + const possiblePromise = when() + if (possiblePromise) { + return possiblePromise.then(verify) + } + + verify() +} diff --git a/projects/ppwcode/ng-unit-testing/src/lib/outputs/expect-output-event-not-emits.ts b/projects/ppwcode/ng-unit-testing/src/lib/outputs/expect-output-event-not-emits.ts new file mode 100644 index 00000000..fff5fa61 --- /dev/null +++ b/projects/ppwcode/ng-unit-testing/src/lib/outputs/expect-output-event-not-emits.ts @@ -0,0 +1,40 @@ +import { OutputEmitterRef } from '@angular/core' + +interface ExpectOutputEventNotEmitsOptions = void> { + outputEvent: OutputEmitterRef + when: () => TWhen +} + +/** + * Asserts that invoking `when` makes an Angular output not emit. + * + * The helper subscribes before invoking `when` and supports both synchronous and asynchronous interactions. Await the + * returned promise when `when` returns a promise. + * + * @param options Output under test and the interaction that must not emit. + */ +export function expectOutputEventNotEmits( + options: ExpectOutputEventNotEmitsOptions> +): Promise +export function expectOutputEventNotEmits(options: ExpectOutputEventNotEmitsOptions): void +export function expectOutputEventNotEmits( + options: ExpectOutputEventNotEmitsOptions> +): void | Promise { + const { outputEvent, when } = options + + const verify = () => { + expect(eventHandler).not.toHaveBeenCalled() + } + const eventHandler = vi.fn() + + outputEvent.subscribe(eventHandler) + + // It is possible that the `when` lambda is an async function or a returned Promise. If that's the case, we need + // to wait with the verifications until that Promise resolves. Otherwise, we can immediately verify in a sync way. + const possiblePromise = when() + if (possiblePromise) { + return possiblePromise.then(verify) + } + + verify() +} diff --git a/projects/ppwcode/ng-unit-testing/src/lib/setup/test-setup.ts b/projects/ppwcode/ng-unit-testing/src/lib/setup/test-setup.ts new file mode 100644 index 00000000..2c515a31 --- /dev/null +++ b/projects/ppwcode/ng-unit-testing/src/lib/setup/test-setup.ts @@ -0,0 +1,133 @@ +import { ComponentRef, InputSignal, Type } from '@angular/core' +import { ComponentFixture, TestBed, TestModuleMetadata } from '@angular/core/testing' +import { TranslationObject } from '@ngx-translate/core' +import { provideStaticTranslations } from '../translations/provide-static-translations' + +/** + * Maps a component's signal input properties to the values required by those inputs. + * + * Every detected `InputSignal` is required in the resulting bindings object. This makes missing required setup visible + * at compile time when a fixture is instantiated. + */ +export type InputBindings = { + [TKey in keyof TComponent as TComponent[TKey] extends InputSignal + ? TKey + : never]: TComponent[TKey] extends InputSignal ? TValue : never +} + +/** + * Configures the static translations provided while preparing a component test. + */ +export interface TestTranslationsSettings { + /** Translation values returned by the test translation loader. */ + translations: TranslationObject + + /** Active and fallback language used by the translation service. */ + language: string +} + +const defaultTestTranslationsSettings: TestTranslationsSettings = { + translations: {}, + language: 'nl' +} + +/** + * Configures and compiles Angular's testing module with deterministic static translations. + * + * Existing metadata, including custom imports and providers, is preserved. Translation settings default to an empty + * Dutch dictionary. + * + * @param testModuleMetadata Angular testing-module metadata to configure. + * @param translationSettings Static translations and language used by the test. + */ +export const prepareTestingModule = async ( + testModuleMetadata: TestModuleMetadata, + { translations, language }: TestTranslationsSettings = defaultTestTranslationsSettings +): Promise => { + await TestBed.configureTestingModule({ + ...testModuleMetadata, + providers: [...(testModuleMetadata.providers ?? []), ...provideStaticTranslations(translations, language)] + }).compileComponents() +} + +/** + * Creates a component fixture from an already configured TestBed and assigns all supplied signal inputs. + * + * This helper does not wait for fixture stability. Use `prepareAndInstantiateTestComponent` when setup, creation, and + * stabilization should happen as one operation. + * + * @param component Component type to instantiate. + * @param inputBindings Values assigned through `ComponentRef.setInput`. + * @returns The newly created component fixture. + */ +export const instantiateTestComponent = ( + component: Type, + inputBindings: InputBindings +): ComponentFixture => { + const fixture = TestBed.createComponent(component) + + setInputBindings(fixture.componentRef, inputBindings) + + return fixture +} + +/** + * Assigns typed signal-input values to an existing component reference. + * + * @param componentRef Component reference that receives the input values. + * @param inputBindings Input property names and their values. + */ +export function setInputBindings( + componentRef: ComponentRef, + inputBindings: InputBindings +): void +export function setInputBindings( + componentRef: ComponentRef, + inputBindings: Partial> +): void +export function setInputBindings(componentRef: ComponentRef, inputBindings: object): void { + Object.entries(inputBindings).forEach(([binding, value]) => { + componentRef.setInput(binding, value) + }) +} + +/** + * Configures TestBed, creates a component with its signal inputs, and waits until the fixture is stable. + * + * Use this variant when the test requires custom imports or providers in addition to the component under test. + * + * @param testModuleMetadata Angular testing-module metadata to configure. + * @param component Component type to instantiate. + * @param inputBindings Values assigned to the component's signal inputs. + * @param translationSettings Static translations and language used by the test. + * @returns A stable component fixture ready to query or interact with. + */ +export const prepareAndInstantiateTestComponent = async ( + testModuleMetadata: TestModuleMetadata, + component: Type, + inputBindings: InputBindings, + translationSettings: TestTranslationsSettings = defaultTestTranslationsSettings +): Promise> => { + await prepareTestingModule(testModuleMetadata, translationSettings) + const fixture = instantiateTestComponent(component, inputBindings) + fixture.detectChanges() + await fixture.whenStable() + return fixture +} + +/** + * Configures and creates a standalone component using only the component import and its signal inputs. + * + * Use `prepareAndInstantiateTestComponent` instead when additional TestBed imports or providers are required. + * + * @param component Standalone component type to import and instantiate. + * @param inputBindings Values assigned to the component's signal inputs. + * @param translationSettings Static translations and language used by the test. + * @returns A stable component fixture ready to query or interact with. + */ +export const prepareAndInstantiateDefaultTestComponent = async ( + component: Type, + inputBindings: InputBindings, + translationSettings: TestTranslationsSettings = defaultTestTranslationsSettings +): Promise> => + prepareAndInstantiateTestComponent({ imports: [component] }, component, inputBindings, translationSettings) diff --git a/projects/ppwcode/ng-unit-testing/src/lib/templates/expect-disabled.ts b/projects/ppwcode/ng-unit-testing/src/lib/templates/expect-disabled.ts new file mode 100644 index 00000000..ea70ff73 --- /dev/null +++ b/projects/ppwcode/ng-unit-testing/src/lib/templates/expect-disabled.ts @@ -0,0 +1,22 @@ +import { FieldTree } from '@angular/forms/signals' + +interface DisableableControl { + readonly disabled: boolean +} + +/** + * Asserts that a signal-form field is disabled. + */ +export function expectDisabled( + field: FieldTree +): void +/** + * Asserts that a native or Angular control is disabled. + */ +export function expectDisabled(control: DisableableControl): void +export function expectDisabled( + target: DisableableControl | FieldTree +): void { + const disabled = typeof target === 'function' ? target().disabled() : target.disabled + expect(disabled, 'Expected control or field to be disabled').toBe(true) +} diff --git a/projects/ppwcode/ng-unit-testing/src/lib/templates/expect-enabled.ts b/projects/ppwcode/ng-unit-testing/src/lib/templates/expect-enabled.ts new file mode 100644 index 00000000..b5d306e9 --- /dev/null +++ b/projects/ppwcode/ng-unit-testing/src/lib/templates/expect-enabled.ts @@ -0,0 +1,22 @@ +import { FieldTree } from '@angular/forms/signals' + +interface DisableableControl { + readonly disabled: boolean +} + +/** + * Asserts that a signal-form field is enabled. + */ +export function expectEnabled( + field: FieldTree +): void +/** + * Asserts that a native or Angular control is enabled. + */ +export function expectEnabled(control: DisableableControl): void +export function expectEnabled( + target: DisableableControl | FieldTree +): void { + const disabled = typeof target === 'function' ? target().disabled() : target.disabled + expect(disabled, 'Expected control or field to be enabled').toBe(false) +} diff --git a/projects/ppwcode/ng-unit-testing/src/lib/templates/expect-not-rendered.ts b/projects/ppwcode/ng-unit-testing/src/lib/templates/expect-not-rendered.ts new file mode 100644 index 00000000..ec2d2cd7 --- /dev/null +++ b/projects/ppwcode/ng-unit-testing/src/lib/templates/expect-not-rendered.ts @@ -0,0 +1,36 @@ +import { Type } from '@angular/core' +import { ComponentFixture } from '@angular/core/testing' +import { By } from '@angular/platform-browser' + +/** + * Asserts that no element with the exact `data-testid` is rendered in the fixture. + */ +export function expectNotRendered(fixture: ComponentFixture, testId: string): void +/** + * Asserts that the given component or directive is not rendered in the fixture. + */ +export function expectNotRendered( + fixture: ComponentFixture, + directive: Type +): void +export function expectNotRendered( + fixture: ComponentFixture, + target: Type | string +): void { + if (typeof target === 'string') { + const matches = fixture.debugElement + .queryAll(By.css('[data-testid]')) + .filter( + ({ nativeElement }: { nativeElement: Element }) => nativeElement.getAttribute('data-testid') === target + ) + + expect( + matches, + `Expected data-testid="${target}" not to be rendered, but found ${matches.length}` + ).toHaveLength(0) + return + } + + const matches = fixture.debugElement.queryAll(By.directive(target)) + expect(matches, `Expected ${target.name} not to be rendered, but found ${matches.length}`).toHaveLength(0) +} diff --git a/projects/ppwcode/ng-unit-testing/src/lib/templates/expect-rendered.ts b/projects/ppwcode/ng-unit-testing/src/lib/templates/expect-rendered.ts new file mode 100644 index 00000000..d04baaa5 --- /dev/null +++ b/projects/ppwcode/ng-unit-testing/src/lib/templates/expect-rendered.ts @@ -0,0 +1,31 @@ +import { Type } from '@angular/core' +import { ComponentFixture } from '@angular/core/testing' +import { getByTestId } from './get-by-test-id' +import { queryByDirective } from './query-by-directive' + +/** + * Asserts that exactly one element with the given `data-testid` is rendered in the fixture. + * + * Use this overload when rendering is the assertion and the native element is not needed afterward. + */ +export function expectRendered(fixture: ComponentFixture, testId: string): void +/** + * Asserts that the given component or directive is rendered in the fixture. + * + * Use this overload when rendering is the assertion and the typed instance is not needed afterward. + */ +export function expectRendered( + fixture: ComponentFixture, + directive: Type +): void +export function expectRendered( + fixture: ComponentFixture, + target: Type | string +): void { + if (typeof target === 'string') { + expect(getByTestId(fixture, target), `Expected data-testid="${target}" to be rendered`).toBeTruthy() + return + } + + expect(queryByDirective(fixture, target), `Expected ${target.name} to be rendered`).not.toBeNull() +} diff --git a/projects/ppwcode/ng-unit-testing/src/lib/templates/get-all-by-test-id.ts b/projects/ppwcode/ng-unit-testing/src/lib/templates/get-all-by-test-id.ts new file mode 100644 index 00000000..b6b2d509 --- /dev/null +++ b/projects/ppwcode/ng-unit-testing/src/lib/templates/get-all-by-test-id.ts @@ -0,0 +1,20 @@ +import { ComponentFixture } from '@angular/core/testing' +import { By } from '@angular/platform-browser' + +/** + * Returns all native elements whose `data-testid` exactly matches `testId`, in DOM order. + */ +export function getAllByTestId( + fixture: ComponentFixture, + testId: string +): Array { + const matches = fixture.debugElement + .queryAll(By.css('[data-testid]')) + .filter(({ nativeElement }: { nativeElement: Element }) => nativeElement.getAttribute('data-testid') === testId) + + if (matches.length === 0) { + throw new Error(`Expected at least one element with data-testid="${testId}" to be rendered`) + } + + return matches.map(({ nativeElement }) => nativeElement as TElement) +} diff --git a/projects/ppwcode/ng-unit-testing/src/lib/templates/get-by-directive.ts b/projects/ppwcode/ng-unit-testing/src/lib/templates/get-by-directive.ts new file mode 100644 index 00000000..0ecfbf75 --- /dev/null +++ b/projects/ppwcode/ng-unit-testing/src/lib/templates/get-by-directive.ts @@ -0,0 +1,37 @@ +import { Type } from '@angular/core' +import { ComponentFixture } from '@angular/core/testing' +import { queryByDirective, QueryByDirectiveOptions } from './query-by-directive' + +/** + * Returns the first rendered instance of a required component or directive. + * + * Throws a descriptive error when no match is rendered. Prefer this over a nullable query when the test assumes that + * the match exists. + */ +export function getByDirective( + fixture: ComponentFixture, + directive: Type +): TDirective +/** + * Returns another token from the injector of the first element matching the required component or directive. + * + * Throws a descriptive error when no matching element is rendered. + */ +export function getByDirective( + fixture: ComponentFixture, + directive: Type, + options: QueryByDirectiveOptions +): TRead +export function getByDirective( + fixture: ComponentFixture, + directive: Type, + options?: QueryByDirectiveOptions +): TDirective | TRead { + const result = options ? queryByDirective(fixture, directive, options) : queryByDirective(fixture, directive) + + if (result === null) { + throw new Error(`Expected ${directive.name} to be rendered`) + } + + return result +} diff --git a/projects/ppwcode/ng-unit-testing/src/lib/templates/get-by-test-id.ts b/projects/ppwcode/ng-unit-testing/src/lib/templates/get-by-test-id.ts new file mode 100644 index 00000000..00e108ca --- /dev/null +++ b/projects/ppwcode/ng-unit-testing/src/lib/templates/get-by-test-id.ts @@ -0,0 +1,28 @@ +import { ComponentFixture } from '@angular/core/testing' +import { By } from '@angular/platform-browser' + +/** + * Returns the required native element whose `data-testid` exactly matches `testId`. + * + * Use this helper when a test needs to inspect or interact with a unique rendered element. It throws a descriptive + * error when the fixture contains no match or more than one match. Use `expectRendered` instead when rendering itself + * is the only assertion. + */ +export function getByTestId( + fixture: ComponentFixture, + testId: string +): TElement { + const matches = fixture.debugElement + .queryAll(By.css('[data-testid]')) + .filter(({ nativeElement }: { nativeElement: Element }) => nativeElement.getAttribute('data-testid') === testId) + + if (matches.length === 0) { + throw new Error(`Expected an element with data-testid="${testId}" to be rendered`) + } + + if (matches.length > 1) { + throw new Error(`Expected one element with data-testid="${testId}", but found ${matches.length}`) + } + + return matches[0].nativeElement as TElement +} diff --git a/projects/ppwcode/ng-unit-testing/src/lib/templates/query-by-directive.ts b/projects/ppwcode/ng-unit-testing/src/lib/templates/query-by-directive.ts new file mode 100644 index 00000000..f91dfe23 --- /dev/null +++ b/projects/ppwcode/ng-unit-testing/src/lib/templates/query-by-directive.ts @@ -0,0 +1,43 @@ +import { ProviderToken, Type } from '@angular/core' +import { ComponentFixture } from '@angular/core/testing' +import { By } from '@angular/platform-browser' + +/** + * Options for retrieving another token from the injector of the element matched by a directive or component type. + */ +export interface QueryByDirectiveOptions { + /** Token to retrieve from the matching element injector. */ + read: ProviderToken +} + +/** + * Returns the first rendered instance of a component or directive, or `null` when it is not rendered. + * + * Use this helper only when absence is a valid result. Use `getByDirective` when the test assumes that the match + * exists. + */ +export function queryByDirective( + fixture: ComponentFixture, + directive: Type +): TDirective | null +/** + * Returns another token from the injector of the first matching element, or `null` when no match is rendered. + */ +export function queryByDirective( + fixture: ComponentFixture, + directive: Type, + options: QueryByDirectiveOptions +): TRead | null +export function queryByDirective( + fixture: ComponentFixture, + directive: Type, + options?: QueryByDirectiveOptions +): TDirective | TRead | null { + const debugElement = fixture.debugElement.query(By.directive(directive)) + + if (!debugElement) { + return null + } + + return options ? debugElement.injector.get(options.read) : debugElement.injector.get(directive) +} diff --git a/projects/ppwcode/ng-unit-testing/src/lib/translations/provide-static-translations.ts b/projects/ppwcode/ng-unit-testing/src/lib/translations/provide-static-translations.ts new file mode 100644 index 00000000..17d3126e --- /dev/null +++ b/projects/ppwcode/ng-unit-testing/src/lib/translations/provide-static-translations.ts @@ -0,0 +1,25 @@ +import { Provider } from '@angular/core' +import { + TranslateLoader, + TranslationObject, + provideTranslateLoader, + provideTranslateService +} from '@ngx-translate/core' +import { Observable, of } from 'rxjs' + +export const provideStaticTranslations = ( + translations: TranslationObject, + language: string = 'nl' +): Array => { + class StaticTranslationLoader implements TranslateLoader { + public getTranslation(_: string): Observable { + return of(translations) + } + } + + return provideTranslateService({ + lang: language, + fallbackLang: language, + loader: provideTranslateLoader(StaticTranslationLoader) + }) +} diff --git a/projects/ppwcode/ng-unit-testing/src/public-api.ts b/projects/ppwcode/ng-unit-testing/src/public-api.ts index ea132f55..9d1e3a7d 100644 --- a/projects/ppwcode/ng-unit-testing/src/public-api.ts +++ b/projects/ppwcode/ng-unit-testing/src/public-api.ts @@ -4,7 +4,32 @@ export * from './lib/a11y/axe' export * from './lib/constants' +export * from './lib/data-access/mock-builder' +export * from './lib/data-access/test-dto-to-entity-mapping' +export * from './lib/data-access/test-entity-to-dto-mapping' +export * from './lib/forms/errors/expect-field-tree-has-error' +export * from './lib/forms/errors/expect-field-tree-has-errors' +export * from './lib/forms/errors/expect-field-tree-not-has-error' +export * from './lib/forms/errors/get-field-tree-error-kinds' +export * from './lib/forms/expect-field-tree-invalid' +export * from './lib/forms/expect-field-tree-not-required' +export * from './lib/forms/expect-field-tree-required' +export * from './lib/forms/expect-field-tree-valid' +export * from './lib/forms/initialise-test-form' export * from './lib/http/http-call-tester' export * from './lib/http/http-client-testing-controller' export * from './lib/http/throw-error-response' +export * from './lib/outputs/expect-output-event-emits' +export * from './lib/outputs/expect-output-event-emits-sequence' +export * from './lib/outputs/expect-output-event-not-emits' export * from './lib/routing/activated-route' +export * from './lib/setup/test-setup' +export * from './lib/templates/expect-disabled' +export * from './lib/templates/expect-enabled' +export * from './lib/templates/expect-not-rendered' +export * from './lib/templates/expect-rendered' +export * from './lib/templates/get-all-by-test-id' +export * from './lib/templates/get-by-directive' +export * from './lib/templates/get-by-test-id' +export * from './lib/templates/query-by-directive' +export * from './lib/translations/provide-static-translations'