Skip to content

Commit

Permalink
feat: add type function (#37)
Browse files Browse the repository at this point in the history
  • Loading branch information
timdeschryver committed Aug 24, 2019
1 parent b65505e commit 41365e3
Show file tree
Hide file tree
Showing 11 changed files with 396 additions and 3 deletions.
3 changes: 2 additions & 1 deletion projects/testing-library/src/lib/models.ts
@@ -1,10 +1,11 @@
import { Type } from '@angular/core';
import { ComponentFixture } from '@angular/core/testing';
import { FireObject, Queries, queries, BoundFunction } from '@testing-library/dom';
import { UserEvents } from './user-events';

export type RenderResultQueries<Q extends Queries = typeof queries> = { [P in keyof Q]: BoundFunction<Q[P]> };

export interface RenderResult extends RenderResultQueries, FireObject {
export interface RenderResult extends RenderResultQueries, FireObject, UserEvents {
container: HTMLElement;
debug: (element?: HTMLElement) => void;
fixture: ComponentFixture<any>;
Expand Down
2 changes: 2 additions & 0 deletions projects/testing-library/src/lib/testing-library.ts
Expand Up @@ -3,6 +3,7 @@ import { By } from '@angular/platform-browser';
import { TestBed, ComponentFixture } from '@angular/core/testing';
import { getQueriesForElement, prettyDOM, fireEvent, FireObject, FireFunction } from '@testing-library/dom';
import { RenderResult, RenderOptions } from './models';
import { createType } from './user-events';

@Component({ selector: 'wrapper-component', template: '' })
class WrapperComponent implements OnInit {
Expand Down Expand Up @@ -84,6 +85,7 @@ export async function render<T>(
debug: (element = fixture.nativeElement) => console.log(prettyDOM(element)),
...getQueriesForElement(fixture.nativeElement, queries),
...eventsWithDetectChanges,
type: createType(eventsWithDetectChanges),
} as any;
}

Expand Down
10 changes: 10 additions & 0 deletions projects/testing-library/src/lib/user-events/index.ts
@@ -0,0 +1,10 @@
import { fireEvent } from '@testing-library/dom';
import { createType } from './type';

export interface UserEvents {
type: ReturnType<typeof createType>;
}

const type = createType(fireEvent);

export { createType, type };
73 changes: 73 additions & 0 deletions projects/testing-library/src/lib/user-events/type.ts
@@ -0,0 +1,73 @@
import { FireFunction, FireObject } from '@testing-library/dom';

function wait(time) {
return new Promise(function(resolve) {
setTimeout(() => resolve(), time);
});
}

// implementation from https://github.com/testing-library/user-event
export function createType(fireEvent: FireFunction & FireObject) {
function createFireChangeEvent(value: string) {
return function fireChangeEvent(event) {
if (value !== event.target.value) {
fireEvent.change(event.target);
}
event.target.removeEventListener('blur', fireChangeEvent);
};
}

return async function type(element: HTMLElement, value: string, { allAtOnce = false, delay = 0 } = {}) {
const initialValue = (element as HTMLInputElement).value;

if (allAtOnce) {
fireEvent.input(element, { target: { value } });
element.addEventListener('blur', createFireChangeEvent(initialValue));
return;
}

let actuallyTyped = '';
for (let index = 0; index < value.length; index++) {
const char = value[index];
const key = char;
const keyCode = char.charCodeAt(0);

if (delay > 0) {
await wait(delay);
}

const downEvent = fireEvent.keyDown(element, {
key: key,
keyCode: keyCode,
which: keyCode,
});

if (downEvent) {
const pressEvent = fireEvent.keyPress(element, {
key: key,
keyCode,
charCode: keyCode,
});

if (pressEvent) {
actuallyTyped += key;
fireEvent.input(element, {
target: {
value: actuallyTyped,
},
bubbles: true,
cancelable: true,
});
}
}

fireEvent.keyUp(element, {
key: key,
keyCode: keyCode,
which: keyCode,
});
}

element.addEventListener('blur', createFireChangeEvent(initialValue));
};
}
1 change: 1 addition & 0 deletions projects/testing-library/src/public_api.ts
Expand Up @@ -4,4 +4,5 @@

export * from './lib/models';
export * from './lib/testing-library';
export * from './lib/user-events';
export * from '@testing-library/dom';
221 changes: 221 additions & 0 deletions projects/testing-library/tests/user-events/type.spec.ts
@@ -0,0 +1,221 @@
import { ReactiveFormsModule, FormsModule, FormControl } from '@angular/forms';
import { render, RenderResult } from '../../src/public_api';
import { Component, ViewChild, Input } from '@angular/core';
import { fakeAsync, tick } from '@angular/core/testing';

describe('updates the value', () => {
test('with a template-driven form', async () => {
@Component({
selector: 'fixture',
template: `
<input type="text" [(ngModel)]="value" data-testid="input" />
<p data-testid="text">{{ value }}</p>
`,
})
class FixtureComponent {
value: string;
}

const component = await render(FixtureComponent, {
imports: [FormsModule],
});

assertType(component, () => component.fixture.componentInstance.value);
});

test('with a reactive form', async () => {
@Component({
selector: 'fixture',
template: `
<input type="text" [formControl]="value" data-testid="input" />
<p data-testid="text">{{ value.value }}</p>
`,
})
class FixtureComponent {
value = new FormControl('');
}

const component = await render(FixtureComponent, {
imports: [ReactiveFormsModule],
});

assertType(component, () => component.fixture.componentInstance.value.value);
});

test('with events', async () => {
@Component({
selector: 'fixture',
template: `
<input type="text" (input)="onInput($event)" data-testid="input" />
<p data-testid="text">{{ value }}</p>
`,
})
class FixtureComponent {
value = '';

onInput(event: KeyboardEvent) {
this.value = (<HTMLInputElement>event.target).value;
}
}

const component = await render(FixtureComponent);

assertType(component, () => component.fixture.componentInstance.value);
});

test('by reference', async () => {
@Component({
selector: 'fixture',
template: `
<input type="text" data-testid="input" #input />
<p data-testid="text">{{ input.value }}</p>
`,
})
class FixtureComponent {
@ViewChild('input', { static: false }) value;
}

const component = await render(FixtureComponent);

assertType(component, () => component.fixture.componentInstance.value.nativeElement.value);
});

function assertType(component: RenderResult, value: () => string) {
const input = '@testing-library/angular';
const inputControl = component.getByTestId('input') as HTMLInputElement;
component.type(inputControl, input);

expect(value()).toBe(input);
expect(component.getByTestId('text').textContent).toBe(input);
expect(inputControl.value).toBe(input);
expect(inputControl).toHaveProperty('value', input);
}
});

describe('options', () => {
@Component({
selector: 'fixture',
template: `
<input
type="text"
data-testid="input"
(input)="onInput($event)"
(change)="onChange($event)"
(keydown)="onKeyDown($event)"
(keypress)="onKeyPress($event)"
(keyup)="onKeyUp($event)"
/>
`,
})
class FixtureComponent {
onInput($event) {}
onChange($event) {}
onKeyDown($event) {}
onKeyPress($event) {}
onKeyUp($event) {}
}

async function setup() {
const componentProperties = {
onInput: jest.fn(),
onChange: jest.fn(),
onKeyDown: jest.fn(),
onKeyPress: jest.fn(),
onKeyUp: jest.fn(),
};
const component = await render(FixtureComponent, { componentProperties });

return { component, ...componentProperties };
}

describe('allAtOnce', () => {
test('false: updates the value one char at a time', async () => {
const { component, onInput, onChange, onKeyDown, onKeyPress, onKeyUp } = await setup();

const inputControl = component.getByTestId('input') as HTMLInputElement;
const inputValue = 'foobar';
component.type(inputControl, inputValue);

expect(onInput).toBeCalledTimes(inputValue.length);
expect(onKeyDown).toBeCalledTimes(inputValue.length);
expect(onKeyPress).toBeCalledTimes(inputValue.length);
expect(onKeyUp).toBeCalledTimes(inputValue.length);

component.blur(inputControl);
expect(onChange).toBeCalledTimes(1);
});

test('true: updates the value in one time and does not trigger other events', async () => {
const { component, onInput, onChange, onKeyDown, onKeyPress, onKeyUp } = await setup();

const inputControl = component.getByTestId('input') as HTMLInputElement;
const inputValue = 'foobar';
component.type(inputControl, inputValue, { allAtOnce: true });

expect(onInput).toBeCalledTimes(1);
expect(onKeyDown).toBeCalledTimes(0);
expect(onKeyPress).toBeCalledTimes(0);
expect(onKeyUp).toBeCalledTimes(0);

component.blur(inputControl);
expect(onChange).toBeCalledTimes(1);
});
});

describe('delay', () => {
test('delays the input', fakeAsync(async () => {
const { component } = await setup();

const inputControl = component.getByTestId('input') as HTMLInputElement;
const inputValue = 'foobar';
component.type(inputControl, inputValue, { delay: 25 });

[...inputValue].forEach((_, i) => {
expect(inputControl.value).toBe(inputValue.substr(0, i));
tick(25);
});
}));
});
});

test('should not type when event.preventDefault() is called', async () => {
@Component({
selector: 'fixture',
template: `
<input
type="text"
data-testid="input"
(input)="onInput($event)"
(change)="onChange($event)"
(keydown)="onKeyDown($event)"
(keypress)="onKeyPress($event)"
(keyup)="onKeyUp($event)"
/>
`,
})
class FixtureComponent {
onInput($event) {}
onChange($event) {}
onKeyDown($event) {}
onKeyPress($event) {}
onKeyUp($event) {}
}

const componentProperties = {
onChange: jest.fn(),
onKeyDown: jest.fn().mockImplementation(event => event.preventDefault()),
};

const component = await render(FixtureComponent, { componentProperties });

const inputControl = component.getByTestId('input') as HTMLInputElement;
const inputValue = 'foobar';
component.type(inputControl, inputValue);

expect(componentProperties.onKeyDown).toHaveBeenCalledTimes(inputValue.length);

component.blur(inputControl);
expect(componentProperties.onChange).toBeCalledTimes(0);

expect(inputControl.value).toBe('');
});
24 changes: 24 additions & 0 deletions src/app/__snapshots__/app.component.spec.ts.snap
Expand Up @@ -58,6 +58,30 @@ exports[`matches snapshot 1`] = `
<button>
Greet
</button>
<form
class="ng-untouched ng-pristine ng-invalid"
ng-reflect-form="[object Object]"
novalidate=""
>
<label>
Name:
<input
class="ng-untouched ng-pristine ng-invalid"
formcontrolname="name"
ng-reflect-name="name"
type="text"
/>
</label>
<label>
Age:
<input
class="ng-untouched ng-pristine ng-valid"
formcontrolname="age"
ng-reflect-name="age"
type="number"
/>
</label>
</form>
</app-root>
</div>
`;
12 changes: 12 additions & 0 deletions src/app/app.component.html
Expand Up @@ -21,3 +21,15 @@ <h2><a target="_blank" rel="noopener" href="https://blog.angular.io/">Angular bl
</ul>

<button (click)="greet()">Greet</button>

<form [formGroup]="form" (ngSubmit)="onSubmit()">
<label>
Name:
<input type="text" formControlName="name" />
</label>

<label>
Age:
<input type="number" formControlName="age" />
</label>
</form>

0 comments on commit 41365e3

Please sign in to comment.