Search code examples
angularunit-testingangular-cliuser-input

Angular2+ integration unit-testing: How to fake user typing something into input, with 'keydown'-event AND 'input'-event


I want to test "real" typing of an user for my input element. To unit-test if my number.component in combination with my number-only.directive only accepts numeric inputs.
The problem is the ngModel isn't updated on 'keydown' (KeyboardEvent), but is needed so the directive is triggered.
The 'input' event requires to set the value of the nativeElement before dispatching it, which would skip the directive.

I already experimented with fakeAsync, tick and whenStable, but didn't manage to recreate the flow of an actual user typing into the input field.

number.component.html

<input numberOnly class="number-input ml-2 mr-2" type="text" [(ngModel)]="value">

number-only-directive.ts

import { Directive, ElementRef, HostListener } from '@angular/core';

@Directive({
    selector: '[NumberOnly]'
})
export class NumberOnlyDirective {

    // Allow decimal numbers. The \. is only allowed once to occur
    private regex: RegExp = new RegExp(/^[0-9]+(\.[0-9]*){0,1}$/g);

    // Allow key codes for special events. Reflect :
    // Backspace, tab, end, home
    private specialKeys: Array<string> = ['Backspace', 'Tab', 'End', 'Home'];

    constructor(private el: ElementRef) {
    }

    @HostListener('keydown', ['$event'])
    onKeyDown(event: KeyboardEvent) {
        // Allow Backspace, tab, end, and home keys
        if (this.specialKeys.indexOf(event.key) !== -1) {
            return;
        }

        // Do not use event.keycode this is deprecated.
        // See: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/keyCode
        const current: string = this.el.nativeElement.value;
        // We need this because the current value on the DOM element
        // is not yet updated with the value from this event
        const next: string = current.concat(event.key);
        if (next && !String(next).match(this.regex)) {
            event.preventDefault();
        }
    }

}

number.component.spec.ts (not working just to get an idea what I want to achieve)

it('should prohibit non-numeric input and keep the value 1', fakeAsync(() => {
    const numberDebug = fixture.debugElement.query(By.css('.number-input'));
    const numberInput = numberDebug.nativeElement as HTMLInputElement;
    numberDebug.triggerEventHandler('keydown', { bubbles: true, key: '1' });
    // numberInput.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: '1' }));
    tick();
    fixture.detectChanges();
    expect(component.value).toEqual(1);
    expect(numberInput.value).toEqual('1');

    const eventMock = new KeyboardEvent('keydown', { key: 'a' });
    numberInput.dispatchEvent(eventMock);
    tick();
    // somehow check if event passed the directive      
    // if so fire 'input' event
    fixture.detectChanges();
    expect(component.value).toEqual(1);
    expect(numberInput.value).toEqual('1');
}));

Solution

  • I found a solution.
    Was missing the fact that the event should be cancelable (thanks to this).

    After fixing this, the event.defaultPrevented property was correctly set for every KeyboardEvent, resulting in this correct functioning test:

    describe('NumberComponent', () => {
        let component: NumberComponent;
        let fixture: ComponentFixture<NumberComponent>;
    
        beforeEach(async(() => {
            TestBed.configureTestingModule({
                declarations: [NumberComponent, NumberOnlyDirective],
                imports: [FormsModule]
            })
                .compileComponents();
        }));
    
        beforeEach(async(() => {
            fixture = TestBed.createComponent(NumberComponent);
            component = fixture.componentInstance;
    
            fixture.detectChanges();
        }));
    
        it('should prohibit non-numeric input', () => {
            let numberDebug = fixture.debugElement.query(By.css('.number-input'));
            let numberInput = numberDebug.nativeElement as HTMLInputElement;
    
            fakeTyping('12abc34de', numberInput);
    
            expect(numberInput.value).toBe('1234');
        });
    
        function fakeTyping(value: string, inputEl: HTMLInputElement) {
            let result: string = '';
            for (let char of value) {
                let eventMock = createKeyDownEvent(char);
                inputEl.dispatchEvent(eventMock);
                if (eventMock.defaultPrevented) {
                    // invalid char
                } else {
                    result = result.concat(char);
                }
            }
    
            inputEl.value = result;
            inputEl.dispatchEvent(new Event('input'));
            fixture.detectChanges();
        }
    });
    
    export function createKeyDownEvent(value: string, cancelable = true) {  
        return new KeyboardEvent('keydown', { key: value, cancelable })  
    }