Writing a test

This page is the one test every other one on this site builds on: render a component, find what it drew, interact with it, and assert. Every sample is Vitest, and every one is taken from a test in this repository that runs on every change, so it works as written, imports included. For node:test, swap Vitest's test and expect for node:test and node:assert, as Setup shows; nothing else changes.

Render, interact, assert

import { Component, signal } from '@angular/core';
import { Pressable, Text, View } from '@ng-native/components';
import { render, screen, userEvent } from '@ng-native/testing';
import { expect, it } from 'vitest';

@Component({
  selector: 'app-counter',
  imports: [Pressable, Text, View],
  template: `
    <view>
      <text>{{ count() }}</text>
      <pressable
        accessibilityRole="button"
        accessibilityLabel="Count"
        (press)="count.set(count() + 1)"
      >
        <text>+1</text>
      </pressable>
    </view>
  `,
})
class Counter {
  protected readonly count = signal(0);
}

it('counts a press', async () => {
  await render(Counter);

  await userEvent.setup().press(screen.getByRole('button', { name: 'Count' }));

  expect(screen.getByText('1')).toBeTruthy();
});

The component is declared in the test file, decorator and all; the Vitest plugin compiles it ahead of time exactly as Metro would. render() mounts it and resolves once the first frame has been committed, and screen queries whatever was rendered last.

The button is found by its role and its name, the same two things a screen reader announces for it: accessibilityRole gives the <pressable> the button role, and accessibilityLabel names it. Querying by role first checks the accessible role and name your test renderer exposes - it is not proof that VoiceOver or TalkBack announce the same control the same way, so verify an important flow on a device with each running before relying on it. With no label, the name is the text inside the node, so getByRole('button', { name: '+1' }) would find it too.

userEvent.press puts a finger down and lifts it again, a task apart, through the same responder system a real touch goes through. Every interaction is awaited, because change detection runs on a later task and a query before it would still see 0.

Inputs and outputs

inputs sets a component's inputs before its first change detection pass, which is what lets a required input work, and on subscribes to its outputs:

import { Component, input, output } from '@angular/core';
import { Pressable, Text } from '@ng-native/components';
import { render, screen, userEvent } from '@ng-native/testing';
import { expect, it, vi } from 'vitest';

@Component({
  selector: 'app-greeting',
  imports: [Pressable, Text],
  template: `
    <pressable accessibilityRole="button" (press)="greeted.emit(name())">
      <text>Hello, {{ name() }}</text>
    </pressable>
  `,
})
class Greeting {
  readonly name = input.required<string>();
  readonly greeted = output<string>();
}

it('takes inputs and listens to outputs on the class', async () => {
  const greeted = vi.fn();
  await render(Greeting, { inputs: { name: 'Ada' }, on: { greeted } });

  await userEvent.press(screen.getByRole('button', { name: 'Hello, Ada' }));

  expect(greeted).toHaveBeenCalledWith('Ada');
});

Or render a template, the way a parent component would use it. imports is what the template may use, and componentProperties is what its bindings read. Continuing in the same file, with Greeting from above still in scope:

it('renders a template, as a parent would use the component', async () => {
  const greeted = vi.fn();
  await render('<app-greeting [name]="name" (greeted)="greeted($event)" />', {
    imports: [Greeting],
    componentProperties: { name: 'Grace', greeted },
  });

  await userEvent.press(screen.getByRole('button', { name: 'Hello, Grace' }));

  expect(greeted).toHaveBeenCalledWith('Grace');
});

The template form is compiled just in time, so it needs @angular/compiler installed as a development dependency. The component class form does not.

rerender changes inputs on a component that is already rendered, and waits for the result - also continuing with Greeting from above:

it('rerenders with new inputs', async () => {
  const { rerender } = await render(Greeting, { inputs: { name: 'Ada' } });

  await rerender({ inputs: { name: 'Grace' } });

  expect(screen.getByText('Hello, Grace')).toBeTruthy();
});

Recipes

Each of these picks up from here, one task at a time:

Debugging

screen.debug() prints the committed tree: view names, text, and the props a query can find a node by. For Counter above:

View
  Paragraph
    RawText "0"
  View accessibilityRole="button" accessibilityLabel="Count"
    Paragraph
      RawText "+1"

A getBy* query that finds nothing throws with the same tree in its message, so a failing test already says what was there instead:

Unable to find a node with role "button" and name "Increment".

View
  Paragraph
    RawText "0"
  View accessibilityRole="button" accessibilityLabel="Count"
    Paragraph
      RawText "+1"

The names are Fabric's, not the template's: a <view> or a <pressable> is a View, a <text> is a Paragraph with its text in a RawText, and a <text-input> is a TextInput. When the props that matter are not the ones printed, fabric.render({ props: true }) on the render result prints every prop of every node.

Angular components as native iOS and Android views, built and shipped with Expo.

An alpha. MIT licensed. Sponsor its development.

An independent project, not affiliated with or endorsed by Google, the Angular team or Expo. Angular is a trademark of Google LLC.