Search code examples
angulardata-bindingkarma-jasmine

detectChanges() not working in Angular standalone component test with ControlValueAccessor


I have written a component test for a simple custom form control component 'my-input' (implementing the ControlValueAccessor interface) which only contains an input field.

In the component test I'm handing over a value for the inner input field via 'ngModel' and I'm expecting the value to appear in the input field, but it doesn't.

my-input.component.html

<input [(ngModel)]="value" />

my-input.component.ts

import { Component, forwardRef } from '@angular/core';
import {
  ControlValueAccessor,
  FormsModule,
  NG_VALUE_ACCESSOR,
} from '@angular/forms';

@Component({
  selector: 'my-input',
  templateUrl: './my-input.component.html',
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => MyInputComponent),
      multi: true,
    },
  ],
  standalone: true,
  imports: [FormsModule],
})
export class MyInputComponent implements ControlValueAccessor {
  inputValue?: string;

  onChange: any = () => {};
  onTouch: any = () => {};

  public get value(): string {
    return this.inputValue || '';
  }

  public set value(value: string) {
    this.inputValue = value;
    this.onChange(this.inputValue);
    this.onTouch(this.inputValue);
  }

  writeValue(value: string) {
    this.inputValue = value;
    this.onChange(this.value);
  }

  registerOnChange(fn: any) {
    this.onChange = fn;
  }

  registerOnTouched(fn: any) {
    this.onTouch = fn;
  }
}

my-input.component.spec.ts

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

import { MyInputComponent } from './my-input.component';
import { Component, DebugElement } from '@angular/core';
import { By } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';

describe('MyInputComponent', () => {
  @Component({
    imports: [MyInputComponent, FormsModule],
    standalone: true,
    template: `<my-input [(ngModel)]="someValue"></my-input>`,
  })
  class TestHostComponent {
    someValue?: string;
  }

  let hostComponent: TestHostComponent;
  let hostFixture: ComponentFixture<TestHostComponent>;
  let componentDe: DebugElement;

  beforeEach(async () => {
    await TestBed.compileComponents();
  });

  beforeEach(() => {
    hostFixture = TestBed.createComponent(TestHostComponent);
    hostComponent = hostFixture.componentInstance;
    componentDe = hostFixture.debugElement;
    hostFixture.detectChanges();
  });

  describe('when setting a value via ngModel', () => {
    it('should set its value correctly', async () => {
      const inputElement: HTMLInputElement = componentDe.query(
        By.css('input')
      ).nativeElement;

      expect(inputElement).toBeDefined();
      expect(inputElement.value).toEqual('');

      hostComponent.someValue = 'Hello';

      hostFixture.detectChanges();
      await hostFixture.whenStable();

      expect(inputElement.value).toEqual('Hello');
    });
  });
});

I tested 'my-input' component in an Angular app, which worked just fine:

main.ts

import { Component } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import 'zone.js';
import { MyInputComponent } from './my-input-component/my-input.component';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [MyInputComponent, FormsModule],
  template: `
    <h1>Hello from {{ name }}!</h1>
    <my-input [(ngModel)]="name"></my-input>
  `,
})
export class App {
  name = 'Angular';
}

bootstrapApplication(App);

You can try it on Stackblitz here: https://stackblitz.com/edit/stackblitz-starters-2uoepe?file=src%2Fmy-input-component%2Fmy-input.component.spec.ts


Solution

  • For some reason, I need to call hostFixture.detectChanges(); await hostFixture.whenStable(); twice, to make the test succeed.

    it('should set its value correctly', async () => {
         const inputElement: HTMLInputElement = componentDe.query(
           By.css('input')
         ).nativeElement;
    
         expect(inputElement).toBeDefined();
         expect(inputElement.value).toEqual('');
    
         hostComponent.someValue = 'Hello';
    
         hostFixture.detectChanges();
         await hostFixture.whenStable();
         hostFixture.detectChanges();
         await hostFixture.whenStable();
    
         expect(inputElement.value).toEqual('Hello');
    });