Mert Özen Further With Every Line
Testing & Quality Frontend Testing — #04

Testing Like a User with Angular Testing Library: render, Role Queries and user-event

Mert Özen Nov 14, 2026 12 min 12 views
Testing Like a User with Angular Testing Library: render, Role Queries and user-event

Testing like a user means finding elements by role and label and interacting in the order a user does: the test written with fireEvent.input failed because it skipped the blur that marks the field as touched, while the user-event version passed. On Bookstore's Signal Forms checkout form we cover render(), query priority, error messages tied with aria-describedby, findBy for async content, navigation with routes and a comparison with the same TestBed test, all with real runs.

Version note: The code in this post was built and run with Angular 22.2 (TypeScript 6.0), Vitest 5.1, jsdom 30, @testing-library/angular 19.6, @testing-library/user-event 14.6 and @testing-library/jest-dom 7.0 on Node.js 24.

Guess First

The checkout page of Bookstore, the online bookstore used as the shared example across this site's series, has a form written with Signal Forms, which became stable in Angular 22: full name, email and city. An error message appears after the user touches a field and leaves it. Two tests were written for the same behavior; both use Angular Testing Library's render() and screen API, and the only difference is how they fill in the field:

it('shows an error for an invalid email (fireEvent)', async () => {
  await render(CheckoutForm);

  fireEvent.input(screen.getByLabelText('Email'), { target: { value: 'ada.example.com' } });

  expect(screen.getByText('Enter a valid email address.')).toBeTruthy();
});

it('shows an error for an invalid email (user-event)', async () => {
  await render(CheckoutForm);
  const user = userEvent.setup();

  await user.type(screen.getByLabelText('Email'), 'ada.example.com');
  await user.tab();

  expect(screen.getByText('Enter a valid email address.')).toBeTruthy();
});

Both type an invalid email and look for the same error message. Which one passes? If one fails, why?

Answer: fireEvent Is One Event, user-event Is a User

The test written with fireEvent failed, the one written with user-event passed:

 ×  bookstore-web  src/app/checkout/checkout-form-events.spec.ts > CheckoutForm > shows an error for an invalid email (fireEvent) 27ms
 ✓  bookstore-web  src/app/checkout/checkout-form-events.spec.ts > CheckoutForm > shows an error for an invalid email (user-event) 68ms

TestingLibraryElementError: Unable to find an element with the text: Enter a valid email address.. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.

For the form to show the error message, the field has to be both invalid and touched; Signal Forms' FormField directive marks the field as touched on the blur event. fireEvent.input sends a single input event: the value reaches the form and the field becomes invalid, but it is not touched because it never received focus and never lost it. user.type, on the other hand, replays what a user does: it clicks the element to focus it and sends keyboard and input events for every character. user.tab() moves focus to the next field, which blurs the email field. That is exactly what a real user does.

The test written with fireEvent did not catch a bug, it just tested the wrong scenario. That is the subject of this post: writing tests based on what the user does, not on the component's internals. Angular Testing Library and user-event exist for this.

The Testing Library Approach

Kent C. Dodds, the author of Testing Library, summed up the library's guiding principle in a 2018 post: "The more your tests resemble the way your software is used, the more confidence they can give you." In practice that means three rules: find elements by the role, label and text the user sees; imitate the interaction the user performs; check the result the user sees. Class names, component fields and private methods stay out of the test.

Angular Testing Library's render() configures TestBed itself, creates the component and runs the first change detection. The options we use in Bookstore:

await render(CheckoutForm, { on: { placeOrder } });
await render(CatalogPage, { providers: [provideHttpClientTesting()] });
await render(CartPage, { routes: [{ path: 'checkout', component: CheckoutForm }] });

inputs sets inputs, on takes functions to subscribe to outputs, providers adds TestBed providers, routes gives the router its routes. Queries go through screen, which always looks at document.body. The library's source shows that render() changes Testing Library's eventWrapper setting: with autoDetectChanges on (the default), detectChanges is called on every mounted fixture after each DOM event. That is why you do not need the await fixture.whenStable() from post 3 after user.click or user.type.

Setup takes three packages: @testing-library/angular 19.6 (which requires @angular/core >= 21.0.0 as a peer dependency), @testing-library/dom 10.4 and @testing-library/user-event 14.6. For DOM matchers such as toBeInTheDocument, toBeInvalid and toHaveAccessibleDescription we also installed @testing-library/jest-dom 7.0 and added one line to the setupFiles file we created in post 1:

// src/test-setup.ts
import { registerLocaleData } from '@angular/common';
import localeTr from '@angular/common/locales/tr';
import '@testing-library/jest-dom/vitest';

registerLocaleData(localeTr);

The /vitest subpath both adds the matchers to Vitest's expect and brings their types; without this line toHaveAccessibleDescription failed to compile: TS2339: Property 'toHaveAccessibleDescription' does not exist on type 'Assertion<void, HTMLElement>'.

Query Priority and Accessibility

The Testing Library documentation splits queries into three groups and recommends using them in this order. First the queries accessible to everyone: getByRole, getByLabelText, getByPlaceholderText, getByText, getByDisplayValue. Then the semantic queries: getByAltText, getByTitle. getByTestId comes last. A data-testid is something the user never sees; a test tied to a test-only attribute keeps passing as long as the attribute is there, even if the button's name changes or the button disappears. That is why it is the last resort.

getByRole comes first because it looks at the tree a screen reader sees: the element's role and accessible name. This has a side effect: you cannot find an element without an accessible name with these queries. When I tried a field whose label was not tied to the input with for, Angular Testing Library gave this error:

TestingLibraryElementError: Found a label with the text of: Email, however no form control was found associated to that label. Make sure you're using the "for" attribute or "aria-labelledby" attribute correctly.

This error is also an accessibility bug: a screen reader cannot announce this field as "Email" either. Problems like this, surfacing while you try to write the test, are the most concrete side benefit of using Testing Library. Testing accessibility systematically, with automated checks such as axe, is the subject of the upcoming Accessibility (a11y) series.

In the checkout form we tied the error message to the input with aria-describedby and set aria-invalid while the field is invalid. That lets the test check not just that the message is somewhere on the screen, but that it belongs to the right field: toHaveAccessibleDescription reads the field's accessible description.

The Checkout Form with Signal Forms

The form component is written with Signal Forms' form(), FormField and FormRoot APIs. FormRoot adds novalidate to the <form> element and runs submission.action on submit only when the form is valid:

@Component({
  selector: 'app-checkout-form',
  imports: [FormField, FormRoot],
  template: `
    <form [formRoot]="checkoutForm" aria-labelledby="checkout-title">
      <h2 id="checkout-title">Shipping details</h2>

      <label for="email">Email</label>
      <input
        id="email"
        type="email"
        autocomplete="email"
        [formField]="checkoutForm.email"
        [attr.aria-invalid]="errorFor(checkoutForm.email) ? true : null"
        [attr.aria-describedby]="errorFor(checkoutForm.email) ? 'email-error' : null"
      />
      @if (errorFor(checkoutForm.email); as message) {
        <p id="email-error" class="error">{{ message }}</p>
      }

      <button type="submit">Place order</button>
    </form>
  `,
})
export class CheckoutForm {
  readonly placeOrder = output<CheckoutDetails>();

  private readonly details = signal<CheckoutDetails>({ fullName: '', email: '', city: '' });
  protected readonly checkoutForm = form(
    this.details,
    (path) => {
      required(path.fullName, { message: 'Enter your full name.' });
      required(path.email, { message: 'Enter your email address.' });
      email(path.email, { message: 'Enter a valid email address.' });
      required(path.city, { message: 'Choose a city.' });
    },
    {
      submission: {
        action: async (field) => {
          this.placeOrder.emit(field().value());
        },
      },
    },
  );

  protected errorFor(field: FieldTree<string>): string | undefined {
    const state = field();
    return state.touched() && state.invalid() ? state.errors()[0]?.message : undefined;
  }
}

The full name and city fields in the template follow the same pattern as the email; to save space I only show the email here. The tests:

describe('CheckoutForm', () => {
  it('shows an error after the user leaves an invalid email', async () => {
    await render(CheckoutForm);
    const user = userEvent.setup();

    await user.type(screen.getByLabelText('Email'), 'ada.example.com');
    await user.tab();

    const emailInput = screen.getByRole('textbox', { name: 'Email' });
    expect(emailInput).toHaveAccessibleDescription('Enter a valid email address.');
    expect(emailInput).toBeInvalid();
  });

  it('shows every missing field when an empty form is submitted', async () => {
    const placeOrder = vi.fn();
    await render(CheckoutForm, { on: { placeOrder } });
    const user = userEvent.setup();

    await user.click(screen.getByRole('button', { name: 'Place order' }));

    expect(screen.getByRole('textbox', { name: 'Full name' })).toHaveAccessibleDescription('Enter your full name.');
    expect(screen.getByRole('textbox', { name: 'Email' })).toHaveAccessibleDescription('Enter your email address.');
    expect(screen.getByRole('combobox', { name: 'City' })).toHaveAccessibleDescription('Choose a city.');
    expect(placeOrder).not.toHaveBeenCalled();
  });

  it('places the order with the details the user entered', async () => {
    const placeOrder = vi.fn();
    await render(CheckoutForm, { on: { placeOrder } });
    const user = userEvent.setup();

    await user.type(screen.getByLabelText('Full name'), 'Ada Lovelace');
    await user.type(screen.getByLabelText('Email'), 'ada@example.com');
    await user.selectOptions(screen.getByLabelText('City'), 'Izmir');
    await user.click(screen.getByRole('button', { name: 'Place order' }));

    expect(placeOrder).toHaveBeenCalledExactlyOnceWith({ fullName: 'Ada Lovelace', email: 'ada@example.com', city: 'Izmir' });
  });
});

The second test checks Signal Forms' submit behavior from the user's point of view: when the empty form is submitted, submit() marks every field as touched, three errors appear at once and placeOrder is never called. The role of a select element is combobox; you do not need to memorize roles, because when a getByRole query fails the error message lists the roles and names on the page.

I learned something while writing these tests: typing "ada@example" into the email field produced no error. The regular expression in the source of Signal Forms' email validator does not require a dot in the domain. If Bookstore needs a stricter rule, it has to be checked with pattern or on the API side; the test showed what the framework accepts instead of letting us assume it.

Waiting for Async Content with findBy

The catalog page loads books over HTTP. I rendered the same page with Testing Library and looked for a book with getByRole right after answering the request:

TestingLibraryElementError: Unable to find an accessible element with the role "heading" and name "Dune"

Here are the accessible roles:

  heading:

  Name "Catalog":
  <h1 />

  --------------------------------------------------
  searchbox:

  Name "Search books":
  <input
    aria-label="Search books"
    type="search"
  />

The page was still showing "Loading books…". As we saw in post 2, the resource handles the response through a promise, while getBy* looks at the DOM as it is right now and decides immediately. For content that arrives asynchronously there is findBy*:

it('shows the books once the catalog arrives', async () => {
  await render(CatalogPage, {
    providers: [provideHttpClientTesting()],
    deferBlockBehavior: DeferBlockBehavior.Manual,
  });
  TestBed.inject(HttpTestingController).expectOne('/api/books').flush([dune, emma]);

  expect(await screen.findByRole('heading', { name: 'Dune' })).toBeInTheDocument();
  expect(screen.queryByRole('heading', { name: 'Emma' })).not.toBeInTheDocument();
});

The deferBlockBehavior option is the Manual mode we chose in post 3 for the @defer (on viewport) blocks in the cards; render() passes it on to TestBed. findBy* returns a promise and repeats the query until the element appears; according to the documentation the default timeout is 1000 ms. Angular Testing Library calls detectChanges during this wait as well. The difference between the three families in short: getBy throws right away if the element is missing; queryBy returns null if it is missing, which makes it the right tool for "not shown" assertions; findBy waits for it to appear. We check with queryByRole that Emma, which is out of stock, is not in the list; written with getByRole, it would throw before reaching the assertion.

To test that the "Checkout" link on the cart page leads to checkout, the routes option of render() is enough:

it('takes the user to checkout', async () => {
  await render(CartPage, { routes: [{ path: 'checkout', component: CheckoutForm }] });
  TestBed.inject(CartStore).add(dune);
  const user = userEvent.setup();

  await user.click(await screen.findByRole('link', { name: 'Checkout' }));

  expect(TestBed.inject(Router).url).toBe('/checkout');
});

Because of the content projection from post 3, the link is only visible when the cart has items. We fill the cart after render(), because render() configures TestBed itself. When I wrote TestBed.inject(CartStore) first, the test failed with this error: "Cannot configure the test module when the test module has already been instantiated." Because the change comes from a signal rather than an event, we wait for the link with findByRole. More thorough routing tests with route parameters, guards and resolvers come in post 5 with RouterTestingHarness.

The Same Test, Two Approaches

For comparison I also wrote the email test with TestBed only:

it('shows an error after the user leaves an invalid email', async () => {
  const fixture = TestBed.createComponent(CheckoutForm);
  await fixture.whenStable();
  const input: HTMLInputElement = fixture.nativeElement.querySelector('#email');

  input.value = 'ada.example.com';
  input.dispatchEvent(new Event('input'));
  input.dispatchEvent(new Event('blur'));
  await fixture.whenStable();

  expect(fixture.nativeElement.querySelector('#email-error').textContent).toBe('Enter a valid email address.');
  expect(input.getAttribute('aria-describedby')).toBe('email-error');
});

Both tests pass and check the same behavior. The differences:

TopicTestBedTesting Library
Finding elementsselectors such as #email and .errorrole, label and text
Interactionone event at a time with dispatchEventthe user's sequence with user-event
Change detectionwhenStable at every stepautomatic after events
Message of a failing testCannot read properties of nullwhat it looked for, the roles and the DOM on the page
Duration (in this suite)3–5 ms14–93 ms
What breaks itan id or class changea change in visible text or accessible name

Forgetting to send the blur event by hand in the TestBed test creates the same bug as the fireEvent test at the start; when I tried it, the test failed with TypeError: Cannot read properties of null (reading 'textContent') and the message did not say why. In other words, whoever writes the test has to know the form's touched logic. The Testing Library test does not need that knowledge; you write what the user does. In Bookstore we use Testing Library for the screens the user sees (forms, lists, pages) and TestBed for behavior that does not depend on the DOM (which value an output was called with, @defer states, signals) and for small building blocks. Both can live in the same file; Bookstore's catalog-page.spec.ts does exactly that.

In Production

user-event is slow, and that is a deliberate choice. The "places the order" test took 66–70 ms with the default settings; with userEvent.setup({ delay: null }) it dropped to 14–15 ms. delay decides whether a setTimeout is awaited between characters; null removes the wait entirely. Across hundreds of form tests the difference adds up. But removing the wait can change scenarios that test time-dependent behavior such as debouncing; in Bookstore we kept the default.

Treat accessible names as a contract. If the "Place order" text changes, the tests fail, and that is correct: the user now sees a different button too. In a multilingual app whose texts come from translation files, you need to run the tests in one locale and use that locale's texts.

Watch package compatibility. The library's version number does not follow Angular's; 19.6 supports Angular 21 and later. Check its peer dependency range before upgrading Angular's major version. The package also contains a leaner entry point called @testing-library/angular/zoneless, but on the day of writing the official API page did not describe it; in Bookstore we use the documented main entry point.

Common Pitfalls

Looking for async content with getBy. As in the catalog example above, the error comes from the content not having arrived yet. Use findBy for anything that appears after HTTP, a resource or a timer.

Wrapping everything in a waiting helper. Coming from React's act habit and wrapping every interaction in waitFor or whenStable is unnecessary; Angular Testing Library runs change detection itself after events. Use waitFor only for a condition you actually wait for.

Making fireEvent your default tool. The test at the start of this post tested a single event, not a user scenario. Keep fireEvent for events that user-event does not support.

Writing broad text queries. In one experiment I collected the error messages with queryAllByText(/Enter|Choose/); the result also contained the "Choose…" option in the city list. A query narrowed by role and name does not accidentally match a new text added to the page.

Adding data-testid to every element. If an element without a role and a name is hard to find, the problem is usually in the markup, not in the test: a div instead of a button, or an input without a label.

A Small Experiment

★☆☆ Add a "Phone" field to the checkout form (optional). Add its label and an autocomplete="tel" attribute, then write a test that finds it with getByRole('textbox', { name: 'Phone' }) and checks that it shows no error when left empty.

★★☆ Delete the for attribute on the email field and run the tests. Which tests failed, with which message? Fix it with an aria-label instead and watch how getByLabelText behaves.

★★★ Write a Testing Library test for the search box on the catalog page: when the user types "dune", a search=dune request goes out after 300 ms. You will need to combine Vitest's fake timers with userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); we saw in post 2 that whenStable gets stuck with fake timers.

The tests in this post used dependencies either as the real thing (CartStore, PricePipe) or through HttpTestingController. In the next post we look at all the options for faking dependencies: hand-written fakes with provider overrides, vi.fn and vi.spyOn, faking child components with ng-mocks, MSW at the network level, and RouterTestingHarness for the order detail page.