Skip to main content

How to test signal forms in Angular

Find a signal form's input, read its model value, and change it with ngMocks.change. Keep FormField real so Angular connects the input to the model. Signal forms require Angular 21 or newer.

This component connects an input to f.inputValue through [formField]. The input initially displays Ada:

import { Component, signal } from '@angular/core';
import { form, FormField } from '@angular/forms/signals';

@Component({
selector: 'target-signal-forms-native',
imports: [FormField],
template: '<input [formField]="f.inputValue" />',
})
class TargetComponent {
public readonly model = signal({ inputValue: 'Ada' });
public readonly f = form(this.model);
}

Test setup

Configure the testing module in beforeEach with MockBuilder. Keep TargetComponent, FormField, and its root services real so Angular connects the input and the field:

beforeEach(() =>
MockBuilder(TargetComponent)
// Keep the field binding and the services used by native input events.
.keep(FormField)
.keep(NG_MOCKS_ROOT_PROVIDERS),
);
Keep root providers real

Keep NG_MOCKS_ROOT_PROVIDERS: native input handling in Angular 22 needs the real root services. Without them, an input event can fail with validityMonitor.isBadInput is not a function.

The token keeps root providers throughout the test. Use explicit .mock(MyService) calls for application services you want to replace.

Testing the input

Blur and submission

The native signal field below updates immediately. ngMocks.change also includes blur for native inputs, so the edited field becomes dirty and touched.

For form submission, wait for the submission action to finish before asserting its completed result.

Inside it, render the component with MockRender, then use ngMocks.reveal with the same field tree that the template binds to formField. Pass component.f.inputValue without calling it, so the selector matches the bound field tree by reference. Reuse that element in subsequent helper calls. Read component.model().inputValue for the component's model value and input.nativeNode.value for the displayed text:

// Render the component.
const fixture = MockRender(TargetComponent);
const component = fixture.point.componentInstance;

// Find the input.
const input = ngMocks.reveal(['formField', component.f.inputValue]);

// Read the value.
expect(component.model().inputValue).toBe('Ada');
expect(input.nativeNode.value).toBe('Ada');

// Change the value.
ngMocks.change(input, 'Grace');
// Render any bindings that depend on the updated model.
fixture.detectChanges();

// Assert the result.
expect(component.model().inputValue).toBe('Grace');
expect(input.nativeNode.value).toBe('Grace');

Other native controls

For each recipe below, add the binding to TargetComponent's template and the indicated property to its model signal. Keep the same test setup and render the component as above. Read component.model() for the model values and the native properties below for the displayed state.

ControlReadChange
Text input or textareaelement.valueA string
Checkboxelement.checkedtrue to check; false to uncheck
Radio optionelement.checkedtrue to select; false to uncheck
Number inputelement.value (a string)A number, or null / undefined to clear
Single selectelement.valueAn option's string value

Textarea

Add textareaValue: 'Initial notes' to the model. The textarea reads and writes a string:

<textarea [formField]="f.textareaValue"></textarea>
// Find the textarea.
const textarea = ngMocks.reveal(['formField', component.f.textareaValue]);

// Read the value.
expect(component.model().textareaValue).toBe('Initial notes');
expect(textarea.nativeNode.value).toBe('Initial notes');

// Change the value.
ngMocks.change(textarea, 'Updated notes');
fixture.detectChanges();

// Assert the result.
expect(component.model().textareaValue).toBe('Updated notes');
expect(textarea.nativeNode.value).toBe('Updated notes');

Checkbox

Add checkboxValue: false to the model. Pass true to check the input or false to uncheck it. The field receives the boolean checked state:

<input type="checkbox" value="yes" [formField]="f.checkboxValue" />
// Find the checkbox.
const checkbox = ngMocks.reveal(['formField', component.f.checkboxValue]);

// Read the checked state.
expect(component.model().checkboxValue).toBe(false);
expect(checkbox.nativeNode.checked).toBe(false);

// Check the checkbox.
ngMocks.change(checkbox, true);
fixture.detectChanges();

// Assert the result.
expect(component.model().checkboxValue).toBe(true);
expect(checkbox.nativeNode.checked).toBe(true);

// Uncheck the checkbox.
ngMocks.change(checkbox, false);
fixture.detectChanges();

// Assert the result.
expect(component.model().checkboxValue).toBe(false);
expect(checkbox.nativeNode.checked).toBe(false);

Radio group

Add radioValue: 'first' to the model. Both options bind the same field, so use ngMocks.find to select the intended option by its value. Pass true to select it. Passing false unchecks that host without clearing the field's selected value; select another option to change the model.

<input type="radio" value="first" [formField]="f.radioValue" />
<input type="radio" value="second" [formField]="f.radioValue" />
// Find the radio options.
const first = ngMocks.find('input[type="radio"][value="first"]');
const second = ngMocks.find('input[type="radio"][value="second"]');

// Read the checked states.
expect(component.model().radioValue).toBe('first');
expect(first.nativeElement.checked).toBe(true);
expect(second.nativeElement.checked).toBe(false);

// Select the second option.
ngMocks.change('input[type="radio"][value="second"]', true);
// or ngMocks.change(second, true);
fixture.detectChanges();

// Assert the result.
expect(component.model().radioValue).toBe('second');
expect(first.nativeElement.checked).toBe(false);
expect(second.nativeElement.checked).toBe(true);

// Uncheck the second option.
ngMocks.change('input[type="radio"][value="second"]', false);
// or ngMocks.change(second, false);
fixture.detectChanges();

// Assert the result.
expect(component.model().radioValue).toBe('second');
expect(first.nativeElement.checked).toBe(false);
expect(second.nativeElement.checked).toBe(false);

Number input

Add numberValue: 1 as number | null to the model. Pass null or undefined to clear the input; Angular writes null to the field and model:

<input type="number" [formField]="f.numberValue" />
// Find the input.
const input = ngMocks.reveal(['formField', component.f.numberValue]);

// Read the value.
expect(component.model().numberValue).toBe(1);
expect(input.nativeNode.value).toBe('1');

// Change the value.
ngMocks.change(input, 42);
fixture.detectChanges();

// Assert the result.
expect(component.model().numberValue).toBe(42);
expect(input.nativeNode.value).toBe('42');

// Clear the value with null or undefined.
ngMocks.change(input, null);
fixture.detectChanges();

// Assert the result.
expect(component.model().numberValue).toBeNull();
expect(input.nativeNode.value).toBe('');

Single select

Add selectValue: 'first' to the model. Pass an option's string value to select it:

<select [formField]="f.selectValue">
<option value="first">First</option>
<option value="second">Second</option>
</select>
// Find the select.
const select = ngMocks.reveal(['formField', component.f.selectValue]);

// Read the value.
expect(component.model().selectValue).toBe('first');
expect(select.nativeNode.value).toBe('first');

// Change the value.
ngMocks.change(select, 'second');
fixture.detectChanges();

// Assert the result.
expect(component.model().selectValue).toBe('second');
expect(select.nativeNode.value).toBe('second');
expect(select.nativeNode.options[1].selected).toBe(true);

Multiple select

Angular 22's native FormField select binding reads a single value. For multiple selections, use a custom control that exchanges an array with the field. The executable example shows selecting and clearing values through that control.

Live example

Here is the complete text-input example. The complete native-control spec also contains the other native control examples above.

https://github.com/help-me-mom/ng-mocks/blob/main/examples/TestSignalForms/native.spec.ts
import { Component, signal } from '@angular/core';
import { form, FormField } from '@angular/forms/signals';

import { MockBuilder, MockRender, NG_MOCKS_ROOT_PROVIDERS, ngMocks } from 'ng-mocks';

@Component({
selector: 'target-signal-forms-native',
imports: [FormField],
template: '<input [formField]="f.inputValue" />',
})
class TargetComponent {
public readonly model = signal({ inputValue: 'Ada' });
public readonly f = form(this.model);
}

describe('TestSignalForms:native', () => {
beforeEach(() =>
MockBuilder(TargetComponent)
// Keep the field binding and the services used by native input events.
.keep(FormField)
.keep(NG_MOCKS_ROOT_PROVIDERS),
);

it('finds, reads, and changes a text field', () => {
// Render the component.
const fixture = MockRender(TargetComponent);
const component = fixture.point.componentInstance;

// Find the input.
const input = ngMocks.reveal(['formField', component.f.inputValue]);

// Read the value.
expect(component.model().inputValue).toBe('Ada');
expect(input.nativeNode.value).toBe('Ada');

// Change the value.
ngMocks.change(input, 'Grace');
// Render any bindings that depend on the updated model.
fixture.detectChanges();

// Assert the result.
expect(component.model().inputValue).toBe('Grace');
expect(input.nativeNode.value).toBe('Grace');
});
});