diff --git a/projects/testing-library/src/lib/models.ts b/projects/testing-library/src/lib/models.ts index 19190ab1..7952363a 100644 --- a/projects/testing-library/src/lib/models.ts +++ b/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 = { [P in keyof Q]: BoundFunction }; -export interface RenderResult extends RenderResultQueries, FireObject { +export interface RenderResult extends RenderResultQueries, FireObject, UserEvents { container: HTMLElement; debug: (element?: HTMLElement) => void; fixture: ComponentFixture; diff --git a/projects/testing-library/src/lib/testing-library.ts b/projects/testing-library/src/lib/testing-library.ts index caf63f0d..1b56e977 100644 --- a/projects/testing-library/src/lib/testing-library.ts +++ b/projects/testing-library/src/lib/testing-library.ts @@ -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 { @@ -84,6 +85,7 @@ export async function render( debug: (element = fixture.nativeElement) => console.log(prettyDOM(element)), ...getQueriesForElement(fixture.nativeElement, queries), ...eventsWithDetectChanges, + type: createType(eventsWithDetectChanges), } as any; } diff --git a/projects/testing-library/src/lib/user-events/index.ts b/projects/testing-library/src/lib/user-events/index.ts new file mode 100644 index 00000000..412f7708 --- /dev/null +++ b/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; +} + +const type = createType(fireEvent); + +export { createType, type }; diff --git a/projects/testing-library/src/lib/user-events/type.ts b/projects/testing-library/src/lib/user-events/type.ts new file mode 100644 index 00000000..c0b9848b --- /dev/null +++ b/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)); + }; +} diff --git a/projects/testing-library/src/public_api.ts b/projects/testing-library/src/public_api.ts index 9f627a90..a372fc5c 100644 --- a/projects/testing-library/src/public_api.ts +++ b/projects/testing-library/src/public_api.ts @@ -4,4 +4,5 @@ export * from './lib/models'; export * from './lib/testing-library'; +export * from './lib/user-events'; export * from '@testing-library/dom'; diff --git a/projects/testing-library/tests/user-events/type.spec.ts b/projects/testing-library/tests/user-events/type.spec.ts new file mode 100644 index 00000000..97127acc --- /dev/null +++ b/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: ` + +

{{ value }}

+ `, + }) + 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: ` + +

{{ value.value }}

+ `, + }) + 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: ` + +

{{ value }}

+ `, + }) + class FixtureComponent { + value = ''; + + onInput(event: KeyboardEvent) { + this.value = (event.target).value; + } + } + + const component = await render(FixtureComponent); + + assertType(component, () => component.fixture.componentInstance.value); + }); + + test('by reference', async () => { + @Component({ + selector: 'fixture', + template: ` + +

{{ input.value }}

+ `, + }) + 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: ` + + `, + }) + 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: ` + + `, + }) + 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(''); +}); diff --git a/src/app/__snapshots__/app.component.spec.ts.snap b/src/app/__snapshots__/app.component.spec.ts.snap index 8f9682ec..fefed968 100644 --- a/src/app/__snapshots__/app.component.spec.ts.snap +++ b/src/app/__snapshots__/app.component.spec.ts.snap @@ -58,6 +58,30 @@ exports[`matches snapshot 1`] = ` +
+ + +
`; diff --git a/src/app/app.component.html b/src/app/app.component.html index 1e153467..8eaeb267 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -21,3 +21,15 @@

Angular bl + +
+ + + +
diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 372ab066..09e94333 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -7,10 +7,12 @@ import { provideMock } from '@testing-library/angular/jest-utils'; import { AppComponent } from './app.component'; import { GreetService } from './greet.service'; +import { ReactiveFormsModule } from '@angular/forms'; test(`matches snapshot`, async () => { const { container } = await render('', { declarations: [AppComponent], + imports: [ReactiveFormsModule], providers: [provideMockStore()], }); expect(container).toMatchSnapshot(); @@ -19,6 +21,7 @@ test(`matches snapshot`, async () => { test(`should have a title`, async () => { const { getByText } = await render('', { declarations: [AppComponent], + imports: [ReactiveFormsModule], providers: [provideMockStore()], }); expect(getByText('Welcome to app!')).toBeDefined(); @@ -27,6 +30,7 @@ test(`should have a title`, async () => { test(`should render title in a h1 tag`, async () => { const { container } = await render('', { declarations: [AppComponent], + imports: [ReactiveFormsModule], providers: [provideMockStore()], }); expect(container.querySelector('h1').textContent).toContain('Welcome to app!'); @@ -35,6 +39,7 @@ test(`should render title in a h1 tag`, async () => { test(`should be able to get the Store`, async () => { await render('', { declarations: [AppComponent], + imports: [ReactiveFormsModule], providers: [provideMockStore()], }); expect(TestBed.get>(Store)).toBeDefined(); @@ -43,6 +48,7 @@ test(`should be able to get the Store`, async () => { test(`should provide a mock greet service`, async () => { const component = await render(AppComponent, { declarations: [AppComponent], + imports: [ReactiveFormsModule], providers: [provideMockStore(), provideMock(GreetService)], }); const service: GreetService = TestBed.get(GreetService); @@ -51,3 +57,38 @@ test(`should provide a mock greet service`, async () => { expect(service.greet).toHaveBeenCalled(); }); + +describe('Forms', () => { + test(`should have form validations`, async () => { + const component = await render(AppComponent, { + imports: [ReactiveFormsModule], + providers: [provideMockStore()], + }); + + const appComponent = component.fixture.componentInstance as AppComponent; + expect(appComponent.form.valid).toBe(false); + + const nameInput = component.getByLabelText('Name:'); + const ageInput = component.getByLabelText('Age:'); + + const nameValue = appComponent.form.get('name'); + const ageValue = appComponent.form.get('age'); + + component.type(nameInput, 'B'); + expect(nameValue.valid).toBe(false); + + component.type(nameInput, 'Bob'); + expect(nameValue.valid).toBe(true); + + component.type(ageInput, '17'); + expect(ageValue.valid).toBe(false); + + component.type(ageInput, '61'); + expect(ageValue.valid).toBe(false); + + component.type(ageInput, '20'); + expect(ageValue.valid).toBe(true); + + expect(appComponent.form.valid).toBe(true); + }); +}); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 137aab3d..33787300 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,6 +1,7 @@ import { Component } from '@angular/core'; import { Store } from '@ngrx/store'; import { GreetService } from './greet.service'; +import { FormBuilder, Validators } from '@angular/forms'; @Component({ selector: 'app-root', @@ -9,10 +10,16 @@ import { GreetService } from './greet.service'; }) export class AppComponent { title = 'app'; + form = this.fb.group({ + name: ['', [Validators.required, Validators.minLength(2)]], + age: ['', [Validators.min(18), Validators.max(28)]], + }); - constructor(private store: Store, private greetService: GreetService) {} + constructor(private store: Store, private greetService: GreetService, private fb: FormBuilder) {} greet() { this.greetService.greet(); } + + onSubmit() {} } diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 731af32c..49fb5756 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -3,10 +3,11 @@ import { NgModule } from '@angular/core'; import { AppComponent } from './app.component'; import { StoreModule } from '@ngrx/store'; +import { ReactiveFormsModule } from '@angular/forms'; @NgModule({ declarations: [AppComponent], - imports: [BrowserModule, StoreModule.forRoot({})], + imports: [BrowserModule, ReactiveFormsModule, StoreModule.forRoot({})], providers: [], bootstrap: [AppComponent], })