Tomas J
Tomas J

Reputation: 31

Angular unit test problem with ngModel using ng-mocks

SOLVED. Code in the end.

I have a specific issue with using ng-mocks and testing ngModel.

The following is my unit test, with marked line which fails:

        it('should not move focus, and should delete current input value when backspace clicked inside filled input', fakeAsync(() => {
            const fixture = MockRender(SixDigitInputComponent);
            const inputs = ngMocks.findAll('input');
            const focusSpy = spyOn(inputs[0].nativeElement, 'focus');
            
            ngMocks.change(inputs[0], 3);
            ngMocks.change(inputs[1], 2);

            expect(inputs[0].nativeElement.value).toBe('3');
            expect(inputs[1].nativeElement.value).toBe('2');

            ngMocks.trigger(inputs[1], 'keydown.backspace');
            fixture.detectChanges();
            tick();
            expect(inputs[0].nativeElement.value).toBe('3'); // fails here "expects '' to be '3'"
            expect(inputs[1].nativeElement.value).toBe('');

            expect(focusSpy).not.toHaveBeenCalled();
        }));

All other expect lines work fine.

But when i replaced ngMocks.change with these lines, all passed:

            inputs[0].nativeElement.value = 3;
            inputs[0].nativeElement.dispatchEvent(new Event('input'));
            inputs[1].nativeElement.value = 2;
            inputs[1].nativeElement.dispatchEvent(new Event('input'));

some.component.html

<div class="code-input-container">
    <input
        #inputField
        (paste)="pasteValue($event)"
        (keydown.backspace)="clearFieldAndBacktrack(0)"
        (ngModelChange)="writeAndMoveToNext($event, 0)"
        [ngModel]="displayNumbers[0]"
        type="text"
        maxlength="1"
        placeholder="0"
    />
    <input
        #inputField
        (paste)="pasteValue($event)"
        (keydown.backspace)="clearFieldAndBacktrack(1)"
        (ngModelChange)="writeAndMoveToNext($event, 1)"
        [ngModel]="displayNumbers[1]"
        maxlength="1"
        type="text"
        placeholder="0"
    />
    <input
        #inputField
        (paste)="pasteValue($event)"
        (keydown.backspace)="clearFieldAndBacktrack(2)"
        (ngModelChange)="writeAndMoveToNext($event, 2)"
        [ngModel]="displayNumbers[2]"
        maxlength="1"
        type="text"
        placeholder="0"
    />
</div>

some.component.ts

    public writeAndMoveToNext(digit: string, i: number): void {
        if (!digit || !this.regex.test(digit)) {
            return;
        }
        this.displayNumbers[i] = Number(digit);
        this.focusOn(i + 1);
        this.onTouch();
        this.onChangeFn(this.displayNumbers.join(''));
    }

    public clearFieldAndBacktrack(i: number): void {
        if (i === -1) {
            return;
        }
        // on second backspace, clear and focus on previous input field
        if (this.displayNumbers[i] === null) {
            this.clearFieldAndBacktrack(i - 1);
            return;
        }
        this.focusOn(i);
        this.displayNumbers[i] = null;
        this.onTouch();
        this.onChangeFn(this.displayNumbers.join(''));
    }

Why this didnt trigger ngModel change in DOM:

            ngMocks.change(inputs[0], 3);
            ngMocks.change(inputs[1], 2);

And this did:

            inputs[0].nativeElement.value = 3;
            inputs[0].nativeElement.dispatchEvent(new Event('input'));
            inputs[1].nativeElement.value = 2;
            inputs[1].nativeElement.dispatchEvent(new Event('input'));

Solution:

    it('should move focus to previous input and delete its value when backspace is clicked inside empty input', fakeAsync(() => {
            const fixture = MockRender(SixDigitInputComponent);
            const inputs = ngMocks.findAll('input');
            const focusSpy = spyOn(inputs[0].nativeElement, 'focus');

            ngMocks.change(inputs[0], '3');
            ngMocks.change(inputs[1], '2');
            fixture.detectChanges();

            expect(inputs[0].nativeElement.value).toBe('3');
            expect(inputs[1].nativeElement.value).toBe('2');

            ngMocks.trigger(inputs[1], 'keydown.backspace');
            fixture.detectChanges();
            fixture.whenStable().then(() => {
                expect(inputs[0].nativeElement.value).toBe(3);
                expect(inputs[1].nativeElement.value).toBe('');
                expect(focusSpy).not.toHaveBeenCalled();
            });
    }));

Upvotes: 2

Views: 887

Answers (1)

satanTime
satanTime

Reputation: 13574

I would assume that you keep FormsModule in your tests.

In this case, it's important to keep in mind that NgModel requires await fixture.whenStable();, and fixture.detectChanges() with tick() aren't enough.

remove fakeAsync and switch to async/await:

  it('should ...', async () => { // <-- make it async
    const fixture = MockRender(TargetComponent);
    await fixture.whenStable(); // <-- let ngModel render inputs correctly

    const inputs = ngMocks.findAll('input');
    const focusSpy = spyOn(inputs[0].nativeElement, 'focus');

    ngMocks.change(inputs[0], '3');
    ngMocks.change(inputs[1], '2');
    fixture.detectChanges(); // <-- let ngModel render inputs correctly
    await fixture.whenStable(); // <-- let ngModel render inputs correctly

    expect(inputs[0].nativeElement.value).toBe('3');
    expect(inputs[1].nativeElement.value).toBe('2');

    ngMocks.trigger(inputs[1], 'keydown.backspace');
    fixture.detectChanges(); // <-- let ngModel render inputs correctly
    await fixture.whenStable(); // <-- let ngModel render inputs correctly

    expect(inputs[0].nativeElement.value).toBe('3');
    expect(inputs[1].nativeElement.value).toBe('');

    expect(focusSpy).not.toHaveBeenCalled();
  });

Now nativeElements should be in sync with ngModel and its value.

Live example: https://codesandbox.io/p/sandbox/hardcore-butterfly-ks2r8l?file=%2Fsrc%2Ftest.spec.ts%3A98%2C1

Upvotes: 1

Related Questions