Testing patterns

Recipes for common tests with @blotch/angular-lynx-testing-library: signal changes, events, host data, native APIs, error fallbacks and snapshots. Set up the library first, as in Testing.

Change a signal, then wait

Angular re-renders after a signal changes, not at the moment it changes. Call waitForUpdate() before you check the result:

src/app/label.test.ts
import { Component, signal } from '@angular/core';
import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';

@Component({
  selector: 'app-label',
  imports: [LYNX_ELEMENTS],
  template: `<text>{{ label() }}</text>`,
})
class Label {
  readonly label = signal('initial');
}

it('shows the new label', async () => {
  const { container, componentRef } = await render(Label);
  expect(container.querySelector('text')!.textContent).toBe('initial');

  (componentRef.instance as Label).label.set('updated');
  await waitForUpdate();

  expect(container.querySelector('text')!.textContent).toBe('updated');
});

waitForUpdate() lets Angular's scheduled change detection run, then forces one more pass so every update reaches the tree.

Fire events

fireEvent sends Lynx events, such as a tap, to an element:

src/app/counter.test.ts
import { Component, signal } from '@angular/core';
import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
import {
  fireEvent,
  render,
  waitForUpdate,
} from '@blotch/angular-lynx-testing-library';

@Component({
  selector: 'app-counter',
  imports: [LYNX_ELEMENTS],
  template: `
    <view (bindtap)="increment()">
      <text>{{ count() }}</text>
    </view>
  `,
})
class Counter {
  readonly count = signal(0);

  increment(): void {
    this.count.update((n) => n + 1);
  }
}

it('counts taps', async () => {
  const { container } = await render(Counter);

  fireEvent.tap(container.querySelector('view')!);
  await waitForUpdate();

  expect(container.querySelector('text')!.textContent).toBe('1');
});
  • Your handler runs right away. fireEvent.tap() calls increment() before it returns.
  • The screen updates afterwards. As on a device, Angular re-renders after the handler, so await waitForUpdate() before you check the tree.
  • Pass event data as the second argument. Its properties are copied onto the event your handler receives. Lynx's <input> puts its text in event.detail.value, for example:
fireEvent.input(input, { detail: { value: 'hello' } });

The event reference lists every event fireEvent can send.

Fake host data

No host app runs in a test, so InitData and GlobalData hold nothing. Provide a stand-in for LynxInitData or LynxGlobalData with the values you need:

src/app/welcome.test.ts
import { Component, computed, inject, signal } from '@angular/core';
import { LYNX_ELEMENTS, LynxInitData } from '@blotch/angular-lynx';
import { render } from '@blotch/angular-lynx-testing-library';

@Component({
  selector: 'app-welcome',
  imports: [LYNX_ELEMENTS],
  template: `<text>Hi {{ userId() }}</text>`,
})
class Welcome {
  readonly #initData = inject(LynxInitData);
  readonly userId = computed(
    () => this.#initData.initData()?.['userId'] ?? 'guest',
  );
}

it('greets the user named in InitData', async () => {
  const { getByText } = await render(Welcome, {
    providers: [
      {
        provide: LynxInitData,
        useValue: { initData: signal({ userId: 'user_42' }) },
      },
    ],
  });

  expect(getByText('Hi user_42')).toBeTruthy();
});

LynxTheme and LynxLocale read GlobalData, so the same trick sets them. For example, { provide: LynxGlobalData, useValue: { globalData: signal({ theme: 'Dark' }) } } makes LynxTheme.isDarkMode() return true.

Fake native APIs

Services that call the phone return nothing or throw in jsdom: LynxNativeModule.call() returns undefined, and LynxTextMeasure.measure() throws. Provide fakes:

import { LynxNativeModule, LynxTextMeasure } from '@blotch/angular-lynx';

const { getByText } = await render(ProfileHeader, {
  providers: [
    { provide: LynxNativeModule, useValue: { call: async () => 'device-123' } },
    { provide: LynxTextMeasure, useValue: { measure: () => ({ width: 120 }) } },
  ],
});

To test a service on its own, without rendering anything, use Angular's TestBed with the same providers. ng test runs TestBed tests as they are.

Test an error fallback

@boundary works in tests. Give the test an error handler that records caught errors. The default LynxErrorHandler reports them to Lynx, and the test environment's stand-in for Lynx throws them again.

src/app/price.test.ts
import { Component, ErrorHandler, Injectable, signal } from '@angular/core';
import { LYNX_ELEMENTS, LynxErrorHandler } from '@blotch/angular-lynx';
import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';

@Injectable()
class RecordingErrorHandler extends LynxErrorHandler {
  readonly caught: Error[] = [];

  override onViewError(error: Error): void {
    this.caught.push(error);
  }
}

@Component({
  selector: 'app-price',
  imports: [LYNX_ELEMENTS],
  template: `
    @boundary {
      <text>{{ format(price()) }}</text>
    } @error {
      <text>Price unavailable</text>
    }
  `,
})
class Price {
  readonly price = signal(10);

  format(value: number): string {
    if (Number.isNaN(value)) throw new Error('Bad price');
    return `$${value}`;
  }
}

it('shows the fallback when the price is bad', async () => {
  const handler = new RecordingErrorHandler();
  const { getByText, componentRef } = await render(Price, {
    providers: [{ provide: ErrorHandler, useValue: handler }],
  });

  (componentRef.instance as Price).price.set(Number.NaN);
  await waitForUpdate();

  expect(getByText('Price unavailable')).toBeTruthy();
  expect(handler.caught).toHaveLength(1);
});

Snapshot tests

asFragment() copies the rendered Lynx element tree, ready for a snapshot:

const { asFragment } = await render(Label);
expect(asFragment()).toMatchSnapshot();

Each call takes a new copy, so you can snapshot before and after a change.

Reach into the component

componentRef is the rendered component's ComponentRef:

const { componentRef } = await render(Label);

// Read a service from the app's injector.
const store = componentRef.injector.get(CartStore);

// Change state on the instance, then wait for the update.
(componentRef.instance as Label).label.set('updated');
await waitForUpdate();

componentRef.setInput() works under ng test, which compiles components ahead of time. Under plain Vitest, which compiles them at runtime, it doesn't. Change writable signals on the instance there.

Custom queries

Pass queries to add your own query helpers next to the standard ones:

const queryByTag = (container: HTMLElement, tag: string) =>
  container.querySelector(tag);

const result = await render(Label, { queries: { queryByTag } });

expect(result.queryByTag('text')).not.toBeNull();
expect(result.getByText('initial')).not.toBeNull();

Cleanup

The library destroys the rendered app after each test. To turn that off, set the environment variable ATL_SKIP_AUTO_CLEANUP=true and call cleanup() or unmount() yourself.