Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<ExampleMock> {
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' })
})
})
Original file line number Diff line number Diff line change
@@ -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 = <T>(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<T extends object> {
#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<TKey extends keyof T>(key: TKey, value: T[TKey]): this {
this.#value = {
...this.#value,
[key]: cloneMockValue(value)
}

return this
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
export interface DtoToEntityMappingTest<TDto, TEntity> {
// 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 = <TDto, TEntity>({
createDto,
map,
expected
}: DtoToEntityMappingTest<TDto, TEntity>): void => {
it('should map from dto to entity', () => {
const dto = createDto()
const entity = map(dto)

expect(entity).toEqual(expected)
})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
export interface EntityToDtoMappingTest<TEntity, TDto> {
// 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 = <TEntity, TDto>({
createEntity,
map,
expected
}: EntityToDtoMappingTest<TEntity, TDto>): void => {
it('should map from entity to dto', () => {
const entity = createEntity()
const dto = map(entity)

expect(dto).toEqual(expected)
})
}
Original file line number Diff line number Diff line change
@@ -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 = <TModel, TKey extends number | string, TMode extends 'writable' | 'readonly'>(
field: FieldTree<TModel, TKey, TMode>,
expectedErrorKind: string
): void => {
const errorKinds = getFieldTreeErrorKinds(field)
expect(errorKinds).toContain(expectedErrorKind)
}
Original file line number Diff line number Diff line change
@@ -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 = <TModel, TKey extends number | string, TMode extends 'writable' | 'readonly'>(
field: FieldTree<TModel, TKey, TMode>,
expectedErrorKinds: Array<string>
): void => {
const errorKinds = getFieldTreeErrorKinds(field)
expect(errorKinds).toEqual(expectedErrorKinds)
}
Original file line number Diff line number Diff line change
@@ -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 = <TModel, TKey extends number | string, TMode extends 'writable' | 'readonly'>(
field: FieldTree<TModel, TKey, TMode>,
expectedErrorKind: string
): void => {
const errorKinds = getFieldTreeErrorKinds(field)
expect(errorKinds).not.toContain(expectedErrorKind)
}
Original file line number Diff line number Diff line change
@@ -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 = <TModel, TKey extends number | string, TMode extends 'writable' | 'readonly'>(
field: FieldTree<TModel, TKey, TMode>
): Array<string> =>
field()
.errors()
.map((error) => error.kind)
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { FieldTree } from '@angular/forms/signals'

/**
* Asserts that a signal-form field or form tree is invalid.
*/
export const expectFieldTreeInvalid = <TModel, TKey extends string | number, TMode extends 'writable' | 'readonly'>(
field: FieldTree<TModel, TKey, TMode>
): void => {
expect(field().valid()).toBe(false)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { FieldTree } from '@angular/forms/signals'

/**
* Asserts that a signal-form field is not marked as required.
*/
export const expectFieldTreeNotRequired = <TModel, TKey extends string | number, TMode extends 'writable' | 'readonly'>(
field: FieldTree<TModel, TKey, TMode>
): void => {
expect(field().required()).toBe(false)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { FieldTree } from '@angular/forms/signals'

/**
* Asserts that a signal-form field is marked as required.
*/
export const expectFieldTreeRequired = <TModel, TKey extends string | number, TMode extends 'writable' | 'readonly'>(
field: FieldTree<TModel, TKey, TMode>
): void => {
expect(field().required()).toBe(true)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { FieldTree } from '@angular/forms/signals'

/**
* Asserts that a signal-form field or form tree is valid.
*/
export const expectFieldTreeValid = <TModel, TKey extends string | number, TMode extends 'writable' | 'readonly'>(
field: FieldTree<TModel, TKey, TMode>
): void => {
expect(field().valid()).toBe(true)
}
Original file line number Diff line number Diff line change
@@ -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 = <TFormModel, TArguments extends [] | [unknown]>(
formCreator: (...args: TArguments) => FieldTree<TFormModel>,
...args: TArguments
): FieldTree<TFormModel> => TestBed.runInInjectionContext(() => formCreator(...args))
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { OutputEmitterRef } from '@angular/core'

interface ExpectOutputEventEmitsSequenceOptions<TValue, TWhen extends void | Promise<void> = void> {
outputEvent: OutputEmitterRef<TValue>
when: () => TWhen
expectedValues: Array<TValue>
}

/**
* Asserts that invoking `when` makes an Angular output emit the complete expected value sequence.
*/
export function expectOutputEventEmitsSequence<TValue>(
options: ExpectOutputEventEmitsSequenceOptions<TValue, Promise<void>>
): Promise<void>
export function expectOutputEventEmitsSequence<TValue>(options: ExpectOutputEventEmitsSequenceOptions<TValue>): void
export function expectOutputEventEmitsSequence<TValue>(
options: ExpectOutputEventEmitsSequenceOptions<TValue, void | Promise<void>>
): void | Promise<void> {
const { outputEvent, when, expectedValues } = options
const emittedValues: Array<TValue> = []

outputEvent.subscribe((value) => emittedValues.push(value))

const verify = () => {
expect(emittedValues).toEqual(expectedValues)
}

const possiblePromise = when()
if (possiblePromise) {
return possiblePromise.then(verify)
}

verify()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { OutputEmitterRef } from '@angular/core'

interface ExpectOutputEventEmitsOptions<TValue, TWhen extends void | Promise<void> = void> {
outputEvent: OutputEmitterRef<TValue>
when: () => TWhen
expectedValue: TValue
}

/**
* Asserts that invoking `when` makes an Angular output emit `expectedValue` exactly once.
*/
export function expectOutputEventEmits<TValue>(
options: ExpectOutputEventEmitsOptions<TValue, Promise<void>>
): Promise<void>
export function expectOutputEventEmits<TValue>(options: ExpectOutputEventEmitsOptions<TValue>): void
export function expectOutputEventEmits<TValue>(
options: ExpectOutputEventEmitsOptions<TValue, void | Promise<void>>
): void | Promise<void> {
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()
}
Loading
Loading