Testing Form Controls
When working with forms, it is often useful to access the FormControl object bound to a specific HTML element.
In the case of a Component implementing the ControlValueAccessor and accepting the [formControl]
as input, that is straightforward.
You can just pass it in the test setup()
function:
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [MyCustomInputTextComponent],
}).compileComponents();
});
const setup = () => {
const page = new Page(TestBed.createComponent(MyCustomInputTextComponent));
const formControl = new FormControl<string>('');
page.fixture.componentRef.setInput('formControl', formControl);
return { page, formControl };
};
it('should do something', () => {
const { page, formControl } = setup();
// we have access to the formControl that is passed as input inside the MyCustomInputTextComponent
});
However, consider now the scenario where we have to test another component that initializes some protected formControl
and passes it down to a child element of its template.
Also, the child element could just be an <input>
HTML element and we could technically check the effect of changes on the value of the formControl on it.
But what if we are instead passing it to a custom child component that is mocked?
Consider the following code:
@Component({
selector: 'app-form-example-component',
template: `
<app-custom-text-input [formControl]="formControl" data-testid="custom-text-input" />
`,
imports: [CustomTextInputComponent, ReactiveFormsModule],
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FormExampleComponent {
protected readonly formControl = new FormControl<string>('Custom initial value');
}
What this component does is quite simple:
- Creates an instance of a
formControl
and initializes its value to "Custom initial value" - Passes it down to the custom
<app-custom-text-input>
Now suppose that we want to write a unit test for this component where the inner <app-custom-text-input>
is mocked.
How can we access the formControl
?
You might be tempted to just declare it public
, but that would go against the encapsulation principle.
Another possible approach would be using an ugly type hack like (page.fixture.componentInstance as any)['formControl']
.
However, both these hacky approaches would only allow us to test the control value initialization,
not the part where we correctly pass it to the <app-custom-text-input>
element.
In other words, we wouldn't be testing our component template but only its class.
To solve this issue properly and avoid hacky solutions, the ngx-page-object-model
library provides a getFormControlOfDebugElement()
method that, given a debug element, it returns the [formControl]
bound to it:
getFormControlOfDebugElement(debugElement: DebugHtmlElement, assert = true): AbstractControl
We can then implement the test for our component like the following.
First let's create a Page Object to get the <app-custom-text-input>
element from the component rendered template:
class Page extends PageObjectModel<FormExampleComponent> {
customTextInput(): DebugHtmlElement<HTMLElement> {
return this.getDebugElementByTestId('custom-text-input');
}
}
Now, in the test, use it to get such element and then use the getFormControlOfDebugElement()
method provided by the ngx-page-object-model
library to get the formControl
bound to it:
it('should initialize a formControl and pass it to the <app-custom-text-input> component', () => {
const { page } = setup();
page.detectChanges();
// get the form control through the DOM
const customTextInput = page.customTextInput();
const formControl = getFormControlOfDebugElement(customTextInput);
expect(formControl.value).toEqual('Custom initial value');
});
This way we access the formControl
object from the DOM instead of the Component's class.
By doing so we are implicitly testing that it is correctly bound to the <app-custom-text-input>
element
and then we check its value by simply calling formControl.value
.
The full test code looks like this:
import { TestBed } from '@angular/core/testing';
import { DebugHtmlElement, PageObjectModel, getFormControlOfDebugElement } from 'ngx-page-object-model';
import { FormExampleComponent } from './form-example.component';
import { CustomTextInputComponent } from './custom-text-input.component';
describe(FormExampleComponent.name, () => {
class Page extends PageObjectModel<FormExampleComponent> {
customTextInput(): DebugHtmlElement<HTMLElement> {
return this.getDebugElementByTestId('custom-text-input');
}
}
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [FormExampleComponent, CustomTextInputComponent],
// Note: assume that CustomTextInputComponent can also just be mocked here
}).compileComponents();
});
const setup = () => {
const page = new Page(TestBed.createComponent(FormExampleComponent));
return { page };
};
it('should initialize a formControl and pass it to the <app-custom-text-input> component', () => {
const { page } = setup();
page.detectChanges();
// get the form control through the DOM
const customTextInput = page.customTextInput();
const formControl = getFormControlOfDebugElement(customTextInput);
expect(formControl.value).toEqual('Custom initial value');
});
})
The full source code of this example can be found here.